diff options
author | stanislavh <stanislav.honcharov@sonarsource.com> | 2023-10-03 14:46:42 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2023-10-04 20:03:19 +0000 |
commit | d8886186af2dced89b74843de577e6813729e8f1 (patch) | |
tree | 3332af93717b4cff963a8a4565096f41c52ec7b5 | |
parent | 4ce854727184915373545ea3c7e2fe49b3a88371 (diff) | |
download | sonarqube-d8886186af2dced89b74843de577e6813729e8f1.tar.gz sonarqube-d8886186af2dced89b74843de577e6813729e8f1.zip |
SONAR-20366 Migrate profiles list page
23 files changed, 562 insertions, 672 deletions
diff --git a/server/sonar-web/design-system/src/components/input/SearchSelectDropdown.tsx b/server/sonar-web/design-system/src/components/input/SearchSelectDropdown.tsx index ecbd8e59d55..355b232f9a3 100644 --- a/server/sonar-web/design-system/src/components/input/SearchSelectDropdown.tsx +++ b/server/sonar-web/design-system/src/components/input/SearchSelectDropdown.tsx @@ -80,7 +80,9 @@ export function SearchSelectDropdown< menuIsOpen, onChange, onInputChange, + isClearable, zLevel = PopupZLevel.Global, + placeholder = '', ...rest } = props; const [open, setOpen] = React.useState(false); @@ -94,9 +96,11 @@ export function SearchSelectDropdown< const ref = React.useRef<Select<Option, IsMulti, Group>>(null); + const computedControlLabel = controlLabel ?? (value as Option | undefined)?.label ?? null; + const toggleDropdown = React.useCallback( (value?: boolean) => { - setOpen(value === undefined ? !open : value); + setOpen(value ?? !open); }, [open], ); @@ -131,6 +135,13 @@ export function SearchSelectDropdown< [onInputChange], ); + const handleClear = () => { + onChange?.(null as OnChangeValue<Option, IsMulti>, { + action: 'clear', + removedValues: [], + }); + }; + React.useEffect(() => { if (open) { ref.current?.inputRef?.select(); @@ -164,7 +175,9 @@ export function SearchSelectDropdown< minLength={minLength} onChange={handleChange} onInputChange={handleInputChange} + placeholder={placeholder} selectRef={ref} + value={value} /> </StyledSearchSelectWrapper> </SearchHighlighterContext.Provider> @@ -176,8 +189,10 @@ export function SearchSelectDropdown< ariaLabel={controlAriaLabel} className={className} disabled={isDisabled} + isClearable={isClearable && Boolean(value)} isDiscreet={isDiscreet} - label={controlLabel} + label={computedControlLabel} + onClear={handleClear} onClick={() => { toggleDropdown(true); }} diff --git a/server/sonar-web/design-system/src/components/input/SearchSelectDropdownControl.tsx b/server/sonar-web/design-system/src/components/input/SearchSelectDropdownControl.tsx index 17f36ac498f..bba4a3623a7 100644 --- a/server/sonar-web/design-system/src/components/input/SearchSelectDropdownControl.tsx +++ b/server/sonar-web/design-system/src/components/input/SearchSelectDropdownControl.tsx @@ -20,18 +20,22 @@ import styled from '@emotion/styled'; import classNames from 'classnames'; +import { useIntl } from 'react-intl'; import tw from 'twin.macro'; import { INPUT_SIZES, themeBorder, themeColor, themeContrast } from '../../helpers'; import { Key } from '../../helpers/keyboard'; import { InputSizeKeys } from '../../types/theme'; -import { ChevronDownIcon } from '../icons'; +import { InteractiveIcon } from '../InteractiveIcon'; +import { ChevronDownIcon, CloseIcon } from '../icons'; interface SearchSelectDropdownControlProps { ariaLabel?: string; className?: string; disabled?: boolean; + isClearable?: boolean; isDiscreet?: boolean; label?: React.ReactNode | string; + onClear: VoidFunction; onClick: VoidFunction; placeholder?: string; size?: InputSizeKeys; @@ -43,11 +47,16 @@ export function SearchSelectDropdownControl(props: SearchSelectDropdownControlPr disabled, placeholder, label, + isClearable, isDiscreet, + onClear, onClick, size = 'full', ariaLabel = '', } = props; + + const intl = useIntl(); + return ( <StyledControl aria-label={ariaLabel} @@ -75,8 +84,21 @@ export function SearchSelectDropdownControl(props: SearchSelectDropdownControlPr }, )} > - <span className="sw-truncate">{label ?? placeholder}</span> - <ChevronDownIcon className="sw-ml-1" /> + <span className="sw-flex-1 sw-truncate">{label ?? placeholder}</span> + <div className="sw-flex sw-items-center"> + {isClearable && ( + <InteractiveIcon + Icon={CloseIcon} + aria-label={intl.formatMessage({ id: 'clear' })} + currentColor + onClick={() => { + onClear(); + }} + size="small" + /> + )} + <ChevronDownIcon /> + </div> </InputValue> </StyledControl> ); @@ -91,7 +113,7 @@ const StyledControl = styled.div` ${tw`sw-flex sw-justify-between sw-items-center`}; ${tw`sw-rounded-2`}; ${tw`sw-box-border`}; - ${tw`sw-px-3 sw-py-2`}; + ${tw`sw-px-3`}; ${tw`sw-body-sm`}; ${tw`sw-h-control`}; ${tw`sw-leading-4`}; @@ -128,6 +150,7 @@ const StyledControl = styled.div` `; const InputValue = styled.span` + height: 100%; width: 100%; color: ${themeContrast('inputBackground')}; diff --git a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx index 31d888d3c1d..375edebed50 100644 --- a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx +++ b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx @@ -45,8 +45,7 @@ const TEMP_PAGELIST_WITH_NEW_BACKGROUND = [ '/project/issues', '/project/activity', '/code', - '/profiles/show', - '/profiles/compare', + '/profiles', '/project/extension/securityreport/securityreport', '/projects', '/project/information', diff --git a/server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx b/server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx index 2f371153229..56df9ef1fe2 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx @@ -91,7 +91,6 @@ export default function AssigneeSelect(props: AssigneeSelectProps) { size="full" controlSize="full" inputId={inputId} - isClearable defaultOptions={defaultOptions} loadOptions={handleAssigneeSearch} onChange={props.onAssigneeSelect} diff --git a/server/sonar-web/src/main/js/apps/quality-gates/components/MetricSelect.tsx b/server/sonar-web/src/main/js/apps/quality-gates/components/MetricSelect.tsx index cc4f2cc0a2c..7790b657c46 100644 --- a/server/sonar-web/src/main/js/apps/quality-gates/components/MetricSelect.tsx +++ b/server/sonar-web/src/main/js/apps/quality-gates/components/MetricSelect.tsx @@ -85,7 +85,6 @@ export function MetricSelect({ metric, metricsArray, metrics, onMetricChange }: size="large" controlSize="full" inputId="condition-metric" - isClearable defaultOptions={optionsWithDomains} loadOptions={handleAssigneeSearch} onChange={handleChange} diff --git a/server/sonar-web/src/main/js/apps/quality-gates/components/QualityGatePermissionsAddModalRenderer.tsx b/server/sonar-web/src/main/js/apps/quality-gates/components/QualityGatePermissionsAddModalRenderer.tsx index 42c02834648..bf4200dd188 100644 --- a/server/sonar-web/src/main/js/apps/quality-gates/components/QualityGatePermissionsAddModalRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/quality-gates/components/QualityGatePermissionsAddModalRenderer.tsx @@ -73,8 +73,6 @@ export default function QualityGatePermissionsAddModalRenderer( controlAriaLabel={translate('quality_gates.permissions.search')} inputId={USER_SELECT_INPUT_ID} autoFocus - isClearable={false} - placeholder="" defaultOptions noOptionsMessage={() => translate('no_results')} onChange={props.onSelection} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfileApp-it.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfileApp-it.tsx index d1ddc9772cf..88bbd3f7bc9 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfileApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/__tests__/QualityProfileApp-it.tsx @@ -71,7 +71,7 @@ const ui = { renameButton: byRole('menuitem', { name: 'rename' }), setAsDefaultButton: byRole('menuitem', { name: 'set_as_default' }), newNameInput: byRole('textbox', { name: /quality_profiles.new_name/ }), - qualityProfilePageLink: byRole('link', { name: 'quality_profiles.page' }), + qualityProfilePageLink: byRole('link', { name: 'quality_profiles.back_to_list' }), rulesTotalRow: byRole('row', { name: /total/ }), rulesBugsRow: byRole('row', { name: /issue.type.BUG.plural/ }), rulesVulnerabilitiesRow: byRole('row', { name: /issue.type.VULNERABILITY/ }), @@ -515,7 +515,9 @@ describe('Every Users', () => { renderQualityProfile('i-dont-exist'); await ui.waitForDataLoaded(); - expect(await screen.findByText('quality_profiles.not_found')).toBeInTheDocument(); + expect( + await screen.findByRole('heading', { name: 'quality_profiles.not_found' }), + ).toBeInTheDocument(); expect(ui.qualityProfilePageLink.get()).toBeInTheDocument(); }); 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 9afe310a39b..cf51ccf8c44 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 @@ -85,7 +85,7 @@ const ui = { }), activateConfirmButton: byRole('button', { name: 'coding_rules.activate' }), namePropupInput: byRole('textbox', { name: 'quality_profiles.new_name required' }), - filterByLang: byRole('combobox', { name: 'quality_profiles.filter_by:' }), + filterByLang: byRole('combobox', { name: 'quality_profiles.select_lang' }), listLinkCQualityProfile: byRole('link', { name: 'c quality profile' }), headingNewCQualityProfile: byRole('heading', { name: 'New c quality profile' }), headingNewCQualityProfileFromCreateButton: byRole('heading', { @@ -113,7 +113,7 @@ const ui = { stagnantProfilesRegion: byRole('region', { name: 'quality_profiles.stagnant_profiles' }), recentlyAddedRulesRegion: byRole('region', { name: 'quality_profiles.latest_new_rules' }), newRuleLink: byRole('link', { name: 'Recently Added Rule' }), - seeAllNewRulesLink: byRole('link', { name: 'see_all 20 quality_profiles.latest_new_rules' }), + seeAllNewRulesLink: byRole('link', { name: 'quality_profiles.latest_new_rules.see_all_x.20' }), }; it('should list Quality Profiles and filter by language', async () => { diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonForm.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonForm.tsx index ea5c66a1e52..61380ed4cbf 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonForm.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/compare/ComparisonForm.tsx @@ -45,8 +45,6 @@ export default function ComparisonForm(props: Readonly<Props>) { .filter((p) => p.language === profile.language && p !== profile) .map((p) => ({ value: p.key, label: p.name, isDefault: p.isDefault })); - const value = options.find((o) => o.value === withKey); - const handleProfilesSearch = React.useCallback( (query: string, cb: (options: Options<Option>) => void) => { cb(options.filter((option) => option.label.toLowerCase().includes(query.toLowerCase()))); @@ -58,9 +56,7 @@ export default function ComparisonForm(props: Readonly<Props>) { <> <span className="sw-mr-2">{intl.formatMessage({ id: 'quality_profiles.compare_with' })}</span> <SearchSelectDropdown - placeholder="" controlPlaceholder={intl.formatMessage({ id: 'select_verb' })} - controlLabel={value?.label} controlAriaLabel={intl.formatMessage({ id: 'quality_profiles.compare_with' })} options={options} onChange={(option: Option) => props.onCompare(option.value)} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileNotFound.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileNotFound.tsx index 93941beea46..bbc277da4a6 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileNotFound.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/components/ProfileNotFound.tsx @@ -17,21 +17,22 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { Link } from 'design-system'; import * as React from 'react'; -import { NavLink } from 'react-router-dom'; -import { translate } from '../../../helpers/l10n'; +import { useIntl } from 'react-intl'; import { PROFILE_PATH } from '../constants'; export default function ProfileNotFound() { - return ( - <div className="quality-profile-not-found"> - <div className="note spacer-bottom"> - <NavLink end to={PROFILE_PATH}> - {translate('quality_profiles.page')} - </NavLink> - </div> + const intl = useIntl(); - <div>{translate('quality_profiles.not_found')}</div> + return ( + <div className="sw-text-center sw-mt-4"> + <h1 className="sw-body-md-highlight sw-mb-4"> + {intl.formatMessage({ id: 'quality_profiles.not_found' })} + </h1> + <Link className="sw-body-sm-highlight" to={PROFILE_PATH}> + {intl.formatMessage({ id: 'quality_profiles.back_to_list' })} + </Link> </div> ); } diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/home/Evolution.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/home/Evolution.tsx index 46445afc1f1..0990cd13fa6 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/home/Evolution.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/home/Evolution.tsx @@ -29,7 +29,7 @@ export interface EvolutionProps { export default function Evolution({ profiles }: EvolutionProps) { return ( - <div className="quality-profiles-evolution"> + <div className="sw-flex sw-flex-col sw-gap-12"> <EvolutionDeprecated profiles={profiles} /> <EvolutionStagnant profiles={profiles} /> <EvolutionRules /> diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionDeprecated.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionDeprecated.tsx index 5f03e1365d8..09b5aedfda7 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionDeprecated.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionDeprecated.tsx @@ -17,13 +17,13 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { DiscreetLink, FlagMessage, Note } from 'design-system'; import { sortBy } from 'lodash'; import * as React from 'react'; -import Link from '../../../components/common/Link'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; +import { useIntl } from 'react-intl'; import { getDeprecatedActiveRulesUrl } from '../../../helpers/urls'; -import ProfileLink from '../components/ProfileLink'; import { Profile } from '../types'; +import { getProfilePath } from '../utils'; interface Props { profiles: Profile[]; @@ -34,120 +34,131 @@ interface InheritedRulesInfo { from: Profile; } -export default class EvolutionDeprecated extends React.PureComponent<Props> { - getDeprecatedRulesInheritanceChain = (profile: Profile, profilesWithDeprecations: Profile[]) => { - let rules: InheritedRulesInfo[] = []; - let count = profile.activeDeprecatedRuleCount; +export default function EvolutionDeprecated({ profiles }: Readonly<Props>) { + const intl = useIntl(); - if (count === 0) { - return rules; - } + const profilesWithDeprecations = profiles.filter( + (profile) => profile.activeDeprecatedRuleCount > 0, + ); - if (profile.parentKey) { - const parentProfile = profilesWithDeprecations.find((p) => p.key === profile.parentKey); - if (parentProfile) { - const parentRules = this.getDeprecatedRulesInheritanceChain( - parentProfile, - profilesWithDeprecations, - ); - if (parentRules.length) { - count -= parentRules.reduce((n, rule) => n + rule.count, 0); - rules = rules.concat(parentRules); - } - } - } + if (profilesWithDeprecations.length === 0) { + return null; + } - if (count > 0) { - rules.push({ - count, - from: profile, - }); - } + const sortedProfiles = sortBy(profilesWithDeprecations, (p) => -p.activeDeprecatedRuleCount); - return rules; - }; - - renderInheritedInfo = (profile: Profile, profilesWithDeprecations: Profile[]) => { - const rules = this.getDeprecatedRulesInheritanceChain(profile, profilesWithDeprecations); - if (rules.length) { - return ( - <> - {rules.map((rule) => { - if (rule.from.key === profile.key) { - return null; - } - - return ( - <div key={rule.from.key}> - {' '} - {translateWithParameters( - 'coding_rules.filters.inheritance.x_inherited_from_y', - rule.count, - rule.from.name, + return ( + <section aria-label={intl.formatMessage({ id: 'quality_profiles.deprecated_rules' })}> + <h2 className="sw-heading-md sw-mb-6"> + {intl.formatMessage({ id: 'quality_profiles.deprecated_rules' })} + </h2> + <FlagMessage variant="error" className="sw-mb-3"> + {intl.formatMessage( + { id: 'quality_profiles.deprecated_rules_are_still_activated' }, + { count: profilesWithDeprecations.length }, + )} + </FlagMessage> + + <ul className="sw-flex sw-flex-col sw-gap-4 sw-body-sm"> + {sortedProfiles.map((profile) => ( + <li className="sw-flex sw-flex-col sw-gap-1" key={profile.key}> + <div className="sw-truncate"> + <DiscreetLink to={getProfilePath(profile.name, profile.language)}> + {profile.name} + </DiscreetLink> + </div> + + <Note> + {profile.languageName} + {', '} + <DiscreetLink + className="link-no-underline" + to={getDeprecatedActiveRulesUrl({ qprofile: profile.key })} + aria-label={intl.formatMessage( + { id: 'quality_profile.lang_deprecated_x_rules' }, + { count: profile.activeDeprecatedRuleCount, name: profile.languageName }, )} - </div> - ); - })} - </> - ); - } - return null; - }; + > + {intl.formatMessage( + { id: 'quality_profile.x_rules' }, + { count: profile.activeDeprecatedRuleCount }, + )} + </DiscreetLink> + </Note> + <EvolutionDeprecatedInherited + profile={profile} + profilesWithDeprecations={profilesWithDeprecations} + /> + </li> + ))} + </ul> + </section> + ); +} - render() { - const profilesWithDeprecations = this.props.profiles.filter( - (profile) => profile.activeDeprecatedRuleCount > 0, +function EvolutionDeprecatedInherited( + props: Readonly<{ + profile: Profile; + profilesWithDeprecations: Profile[]; + }>, +) { + const { profile, profilesWithDeprecations } = props; + const intl = useIntl(); + const rules = React.useMemo( + () => getDeprecatedRulesInheritanceChain(profile, profilesWithDeprecations), + [profile, profilesWithDeprecations], + ); + if (rules.length) { + return ( + <> + {rules.map((rule) => { + if (rule.from.key === profile.key) { + return null; + } + + return ( + <Note key={rule.from.key}> + {intl.formatMessage( + { id: 'coding_rules.filters.inheritance.x_inherited_from_y' }, + { count: rule.count, name: rule.from.name }, + )} + </Note> + ); + })} + </> ); + } + return null; +} - if (profilesWithDeprecations.length === 0) { - return null; - } +function getDeprecatedRulesInheritanceChain(profile: Profile, profilesWithDeprecations: Profile[]) { + let rules: InheritedRulesInfo[] = []; + let count = profile.activeDeprecatedRuleCount; - const sortedProfiles = sortBy(profilesWithDeprecations, (p) => -p.activeDeprecatedRuleCount); + if (count === 0) { + return rules; + } - return ( - <section - className="boxed-group boxed-group-inner quality-profiles-evolution-deprecated" - aria-label={translate('quality_profiles.deprecated_rules')} - > - <h2 className="h4 spacer-bottom">{translate('quality_profiles.deprecated_rules')}</h2> - <div className="spacer-bottom"> - {translateWithParameters( - 'quality_profiles.deprecated_rules_are_still_activated', - profilesWithDeprecations.length, - )} - </div> - <ul> - {sortedProfiles.map((profile) => ( - <li className="spacer-top" key={profile.key}> - <div className="text-ellipsis little-spacer-bottom"> - <ProfileLink language={profile.language} name={profile.name}> - {profile.name} - </ProfileLink> - </div> - <div className="note"> - {profile.languageName} - {', '} - <Link - className="link-no-underline" - to={getDeprecatedActiveRulesUrl({ qprofile: profile.key })} - aria-label={translateWithParameters( - 'quality_profile.lang_deprecated_x_rules', - profile.languageName, - profile.activeDeprecatedRuleCount, - )} - > - {translateWithParameters( - 'quality_profile.x_rules', - profile.activeDeprecatedRuleCount, - )} - </Link> - {this.renderInheritedInfo(profile, profilesWithDeprecations)} - </div> - </li> - ))} - </ul> - </section> - ); + if (profile.parentKey) { + const parentProfile = profilesWithDeprecations.find((p) => p.key === profile.parentKey); + if (parentProfile) { + const parentRules = getDeprecatedRulesInheritanceChain( + parentProfile, + profilesWithDeprecations, + ); + if (parentRules.length) { + count -= parentRules.reduce((n, rule) => n + rule.count, 0); + rules = rules.concat(parentRules); + } + } } + + if (count > 0) { + rules.push({ + count, + from: profile, + }); + } + + return rules; } diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionRules.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionRules.tsx index abe159f8959..6cce1debf5c 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionRules.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionRules.tsx @@ -17,136 +17,96 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { sortBy } from 'lodash'; +import { DiscreetLink, Link, Note } from 'design-system'; +import { noop, sortBy } from 'lodash'; import * as React from 'react'; +import { useIntl } from 'react-intl'; import { searchRules } from '../../../api/rules'; -import Link from '../../../components/common/Link'; import { toShortISO8601String } from '../../../helpers/dates'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; +import { translateWithParameters } from '../../../helpers/l10n'; import { formatMeasure } from '../../../helpers/measures'; import { getRulesUrl } from '../../../helpers/urls'; import { MetricType } from '../../../types/metrics'; -import { Dict, Rule, RuleActivation } from '../../../types/types'; +import { Rule, RuleActivation } from '../../../types/types'; const RULES_LIMIT = 10; -function parseRules(rules: Rule[], actives?: Dict<RuleActivation[]>): ExtendedRule[] { - return rules.map((rule) => { - const activations = actives?.[rule.key]; - return { ...rule, activations: activations ? activations.length : 0 }; - }); -} - interface ExtendedRule extends Rule { activations: number; } -interface State { - latestRules?: ExtendedRule[]; - latestRulesTotal?: number; -} - -export default class EvolutionRules extends React.PureComponent<{}, State> { - periodStartDate: string; - mounted = false; - - constructor(props: {}) { - super(props); - this.state = {}; +export default function EvolutionRules() { + const intl = useIntl(); + const [latestRules, setLatestRules] = React.useState<ExtendedRule[]>(); + const [latestRulesTotal, setLatestRulesTotal] = React.useState<number>(); + const periodStartDate = React.useMemo(() => { const startDate = new Date(); startDate.setFullYear(startDate.getFullYear() - 1); - this.periodStartDate = toShortISO8601String(startDate); - } - - componentDidMount() { - this.mounted = true; - this.loadLatestRules(); - } + return toShortISO8601String(startDate); + }, []); - componentWillUnmount() { - this.mounted = false; - } - - loadLatestRules() { + React.useEffect(() => { const data = { asc: false, - available_since: this.periodStartDate, + available_since: periodStartDate, f: 'name,langName,actives', ps: RULES_LIMIT, s: 'createdAt', }; - searchRules(data).then( - ({ actives, rules, paging: { total } }) => { - if (this.mounted) { - this.setState({ - latestRules: sortBy(parseRules(rules, actives), 'langName'), - latestRulesTotal: total, - }); - } - }, - () => { - /*noop*/ - }, - ); - } + searchRules(data).then(({ actives, rules, paging: { total } }) => { + setLatestRules(sortBy(parseRules(rules, actives), 'langName')); + setLatestRulesTotal(total); + }, noop); + }, [periodStartDate]); - render() { - const { latestRulesTotal, latestRules } = this.state; - - if (!latestRulesTotal || !latestRules) { - return null; - } + if (!latestRulesTotal || !latestRules) { + return null; + } - const newRulesTitle = translate('quality_profiles.latest_new_rules'); - const newRulesUrl = getRulesUrl({ available_since: this.periodStartDate }); - const seeAllRulesText = `${translate('see_all')} ${formatMeasure( - latestRulesTotal, - MetricType.ShortInteger, - )}`; + return ( + <section aria-label={intl.formatMessage({ id: 'quality_profiles.latest_new_rules' })}> + <h2 className="sw-heading-md sw-mb-6"> + {intl.formatMessage({ id: 'quality_profiles.latest_new_rules' })} + </h2> + <ul className="sw-flex sw-flex-col sw-gap-4 sw-body-sm"> + {latestRules.map((rule) => ( + <li className="sw-flex sw-flex-col sw-gap-1" key={rule.key}> + <div className="sw-truncate"> + <DiscreetLink to={getRulesUrl({ rule_key: rule.key })}>{rule.name}</DiscreetLink> + </div> + <Note className="sw-truncate"> + {rule.activations + ? translateWithParameters( + 'quality_profiles.latest_new_rules.activated', + rule.langName!, + rule.activations, + ) + : translateWithParameters( + 'quality_profiles.latest_new_rules.not_activated', + rule.langName!, + )} + </Note> + </li> + ))} + </ul> + {latestRulesTotal > RULES_LIMIT && ( + <div className="sw-mt-6 sw-body-sm-highlight"> + <Link to={getRulesUrl({ available_since: periodStartDate })}> + {intl.formatMessage( + { id: 'quality_profiles.latest_new_rules.see_all_x' }, + { count: formatMeasure(latestRulesTotal, MetricType.ShortInteger) }, + )} + </Link> + </div> + )} + </section> + ); +} - return ( - <section - className="boxed-group boxed-group-inner quality-profiles-evolution-rules" - aria-label={newRulesTitle} - > - <h2 className="h4 spacer-bottom">{newRulesTitle}</h2> - <ul> - {latestRules.map((rule) => ( - <li className="spacer-top" key={rule.key}> - <div className="text-ellipsis"> - <Link className="link-no-underline" to={getRulesUrl({ rule_key: rule.key })}> - {' '} - {rule.name} - </Link> - <div className="note"> - {rule.activations - ? translateWithParameters( - 'quality_profiles.latest_new_rules.activated', - rule.langName!, - rule.activations, - ) - : translateWithParameters( - 'quality_profiles.latest_new_rules.not_activated', - rule.langName!, - )} - </div> - </div> - </li> - ))} - </ul> - {latestRulesTotal > RULES_LIMIT && ( - <div className="spacer-top"> - <Link - className="small" - to={newRulesUrl} - aria-label={`${seeAllRulesText} ${newRulesTitle}`} - > - {seeAllRulesText} - </Link> - </div> - )} - </section> - ); - } +function parseRules(rules: Rule[], actives?: Record<string, RuleActivation[]>): ExtendedRule[] { + return rules.map((rule) => { + const activations = actives?.[rule.key]?.length ?? 0; + return { ...rule, activations }; + }); } diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionStagnant.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionStagnant.tsx index bb94d2c456a..40598fe7de9 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionStagnant.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/home/EvolutionStagnant.tsx @@ -17,18 +17,19 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { DiscreetLink, FlagMessage, Note } from 'design-system'; import * as React from 'react'; +import { useIntl } from 'react-intl'; import DateFormatter from '../../../components/intl/DateFormatter'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; -import ProfileLink from '../components/ProfileLink'; import { Profile } from '../types'; -import { isStagnant } from '../utils'; +import { getProfilePath, isStagnant } from '../utils'; interface Props { profiles: Profile[]; } export default function EvolutionStagnant(props: Props) { + const intl = useIntl(); const outdated = props.profiles.filter((profile) => !profile.isBuiltIn && isStagnant(profile)); if (outdated.length === 0) { @@ -36,38 +37,33 @@ export default function EvolutionStagnant(props: Props) { } return ( - <section - className="boxed-group boxed-group-inner quality-profiles-evolution-stagnant" - aria-label={translate('quality_profiles.stagnant_profiles')} - > - <h2 className="h4 spacer-bottom">{translate('quality_profiles.stagnant_profiles')}</h2> - <div className="spacer-bottom"> - {translate('quality_profiles.not_updated_more_than_year')} - </div> - <ul> + <section aria-label={intl.formatMessage({ id: 'quality_profiles.stagnant_profiles' })}> + <h2 className="sw-heading-md sw-mb-6"> + {intl.formatMessage({ id: 'quality_profiles.stagnant_profiles' })} + </h2> + + <FlagMessage variant="warning" className="sw-mb-3"> + {intl.formatMessage({ id: 'quality_profiles.not_updated_more_than_year' })} + </FlagMessage> + <ul className="sw-flex sw-flex-col sw-gap-4 sw-body-sm"> {outdated.map((profile) => ( - <li className="spacer-top" key={profile.key}> - <div className="text-ellipsis"> - <ProfileLink - className="link-no-underline" - language={profile.language} - name={profile.name} - > + <li className="sw-flex sw-flex-col sw-gap-1" key={profile.key}> + <div className="sw-truncate"> + <DiscreetLink to={getProfilePath(profile.name, profile.language)}> {profile.name} - </ProfileLink> + </DiscreetLink> </div> {profile.rulesUpdatedAt && ( - <DateFormatter date={profile.rulesUpdatedAt} long> - {(formattedDate) => ( - <div className="note"> - {translateWithParameters( - 'quality_profiles.x_updated_on_y', - profile.languageName, - formattedDate, - )} - </div> - )} - </DateFormatter> + <Note> + <DateFormatter date={profile.rulesUpdatedAt} long> + {(formattedDate) => + intl.formatMessage( + { id: 'quality_profiles.x_updated_on_y' }, + { name: profile.languageName, date: formattedDate }, + ) + } + </DateFormatter> + </Note> )} </li> ))} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/home/HomeContainer.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/home/HomeContainer.tsx index e06487ef75b..af756706f2d 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/home/HomeContainer.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/home/HomeContainer.tsx @@ -21,6 +21,7 @@ import * as React from 'react'; import { useOutletContext, useSearchParams } from 'react-router-dom'; import { QualityProfilesContextProps } from '../qualityProfilesContext'; import Evolution from './Evolution'; +import LanguageSelect from './LanguageSelect'; import PageHeader from './PageHeader'; import ProfilesList from './ProfilesList'; @@ -34,11 +35,12 @@ export default function HomeContainer() { <div> <PageHeader {...context} /> - <div className="page-with-sidebar"> - <main className="page-main"> + <div className="sw-grid sw-grid-cols-3 sw-gap-12 sw-mt-12"> + <main className="sw-col-span-2"> + <LanguageSelect currentFilter={selectedLanguage} languages={context.languages} /> <ProfilesList {...context} language={selectedLanguage} /> </main> - <aside className="page-sidebar"> + <aside> <Evolution {...context} /> </aside> </div> diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/home/LanguageSelect.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/home/LanguageSelect.tsx new file mode 100644 index 00000000000..974accd0587 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/quality-profiles/home/LanguageSelect.tsx @@ -0,0 +1,77 @@ +/* + * 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 { LabelValueSelectOption, SearchSelectDropdown } from 'design-system'; +import * as React from 'react'; +import { useIntl } from 'react-intl'; +import { useRouter } from '../../../components/hoc/withRouter'; +import { PROFILE_PATH } from '../constants'; +import { getProfilesForLanguagePath } from '../utils'; + +const MIN_LANGUAGES = 2; + +interface Props { + currentFilter?: string; + languages: Array<{ key: string; name: string }>; +} + +export default function LanguageSelect(props: Readonly<Props>) { + const { currentFilter, languages } = props; + const intl = useIntl(); + const router = useRouter(); + + const options = languages.map((language) => ({ + label: language.name, + value: language.key, + })); + + const handleLanguagesSearch = React.useCallback( + (query: string, cb: (options: LabelValueSelectOption<string>[]) => void) => { + cb(options.filter((option) => option.label.toLowerCase().includes(query.toLowerCase()))); + }, + [options], + ); + + if (languages.length < MIN_LANGUAGES) { + return null; + } + + return ( + <div className="sw-mb-4"> + <span className="sw-mr-2 sw-body-sm-highlight"> + {intl.formatMessage({ id: 'quality_profiles.filter_by' })} + </span> + <SearchSelectDropdown + className="sw-inline-block" + controlPlaceholder={intl.formatMessage({ id: 'quality_profiles.select_lang' })} + controlAriaLabel={intl.formatMessage({ id: 'quality_profiles.select_lang' })} + options={options} + onChange={(option: LabelValueSelectOption<string>) => + router.replace(!option ? PROFILE_PATH : getProfilesForLanguagePath(option.value)) + } + defaultOptions={options} + loadOptions={handleLanguagesSearch} + autoFocus + isClearable + controlSize="medium" + value={options.find((o) => o.value === currentFilter)} + /> + </div> + ); +} diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/home/PageHeader.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/home/PageHeader.tsx index 6fe50c91189..649e5a02f39 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/home/PageHeader.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/home/PageHeader.tsx @@ -17,12 +17,12 @@ * 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, ButtonSecondary, FlagMessage, Link } from 'design-system'; import * as React from 'react'; +import { useIntl } from 'react-intl'; import { Actions } from '../../../api/quality-profiles'; -import DocLink from '../../../components/common/DocLink'; -import { Button } from '../../../components/controls/buttons'; -import { Location, Router, withRouter } from '../../../components/hoc/withRouter'; -import { Alert } from '../../../components/ui/Alert'; +import { useLocation, useRouter } from '../../../components/hoc/withRouter'; +import { useDocUrl } from '../../../helpers/docs'; import { translate } from '../../../helpers/l10n'; import { Profile } from '../types'; import { getProfilePath } from '../utils'; @@ -32,106 +32,81 @@ import RestoreProfileForm from './RestoreProfileForm'; interface Props { actions: Actions; languages: Array<{ key: string; name: string }>; - location: Location; profiles: Profile[]; - router: Router; updateProfiles: () => Promise<void>; } -interface State { - createFormOpen: boolean; - restoreFormOpen: boolean; -} - -export class PageHeader extends React.PureComponent<Props, State> { - state: State = { - createFormOpen: false, - restoreFormOpen: false, - }; +export default function PageHeader(props: Readonly<Props>) { + const { actions, languages, profiles } = props; + const intl = useIntl(); + const location = useLocation(); + const router = useRouter(); + const docUrl = useDocUrl(); - handleCreateClick = () => { - this.setState({ createFormOpen: true }); - }; + const [modal, setModal] = React.useState<'' | 'createProfile' | 'restoreProfile'>(''); - handleCreate = (profile: Profile) => { - this.props.updateProfiles().then( + const handleCreate = (profile: Profile) => { + props.updateProfiles().then( () => { - this.props.router.push(getProfilePath(profile.name, profile.language)); + router.push(getProfilePath(profile.name, profile.language)); }, () => {}, ); }; - closeCreateForm = () => { - this.setState({ createFormOpen: false }); - }; + const closeModal = () => setModal(''); - handleRestoreClick = () => { - this.setState({ restoreFormOpen: true }); - }; + return ( + <header className="sw-grid sw-grid-cols-3 sw-gap-12"> + <div className="sw-col-span-2"> + <h1 className="sw-heading-lg sw-mb-4">{translate('quality_profiles.page')}</h1> + <div className="sw-body-sm"> + {intl.formatMessage({ id: 'quality_profiles.intro' })} - closeRestoreForm = () => { - this.setState({ restoreFormOpen: false }); - }; - - render() { - const { actions, languages, location, profiles } = this.props; - return ( - <header className="page-header"> - <h1 className="page-title">{translate('quality_profiles.page')}</h1> - - {actions.create && ( - <div className="page-actions"> - <Button + <Link className="sw-ml-2" to={docUrl('/instance-administration/quality-profiles/')}> + {intl.formatMessage({ id: 'learn_more' })} + </Link> + </div> + </div> + {actions.create && ( + <div className="sw-flex sw-flex-col sw-items-end"> + <div> + <ButtonPrimary disabled={languages.length === 0} id="quality-profiles-create" - onClick={this.handleCreateClick} + onClick={() => setModal('createProfile')} > - {translate('create')} - </Button> - <Button - className="little-spacer-left" + {intl.formatMessage({ id: 'create' })} + </ButtonPrimary> + <ButtonSecondary + className="sw-ml-2" id="quality-profiles-restore" - onClick={this.handleRestoreClick} + onClick={() => setModal('restoreProfile')} > - {translate('restore')} - </Button> - {languages.length === 0 && ( - <Alert className="spacer-top" variant="warning"> - {translate('quality_profiles.no_languages_available')} - </Alert> - )} + {intl.formatMessage({ id: 'restore' })} + </ButtonSecondary> </div> - )} - - <div className="page-description markdown"> - {translate('quality_profiles.intro1')} - <br /> - {translate('quality_profiles.intro2')} - <DocLink className="spacer-left" to="/instance-administration/quality-profiles/"> - {translate('learn_more')} - </DocLink> + {languages.length === 0 && ( + <FlagMessage className="sw-mt-2" variant="warning"> + {intl.formatMessage({ id: 'quality_profiles.no_languages_available' })} + </FlagMessage> + )} </div> + )} - {this.state.restoreFormOpen && ( - <RestoreProfileForm - onClose={this.closeRestoreForm} - onRestore={this.props.updateProfiles} - /> - )} + {modal === 'restoreProfile' && ( + <RestoreProfileForm onClose={closeModal} onRestore={props.updateProfiles} /> + )} - {this.state.createFormOpen && ( - <CreateProfileForm - languages={languages} - location={location} - onClose={this.closeCreateForm} - onCreate={this.handleCreate} - profiles={profiles} - /> - )} - </header> - ); - } + {modal === 'createProfile' && ( + <CreateProfileForm + languages={languages} + location={location} + onClose={closeModal} + onCreate={handleCreate} + profiles={profiles} + /> + )} + </header> + ); } - -export default withRouter(PageHeader); diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/home/ProfilesList.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/home/ProfilesList.tsx index 2fc20a70c5c..37e9269aea3 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/home/ProfilesList.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/home/ProfilesList.tsx @@ -17,15 +17,14 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { ContentCell, FlagMessage, HelperHintIcon, Table, TableRow } from 'design-system'; import { groupBy, pick, sortBy } from 'lodash'; import * as React from 'react'; +import { useIntl } from 'react-intl'; import HelpTooltip from '../../../components/controls/HelpTooltip'; -import { Alert } from '../../../components/ui/Alert'; -import { translate, translateWithParameters } from '../../../helpers/l10n'; import { Language } from '../../../types/languages'; import { Dict } from '../../../types/types'; import { Profile } from '../types'; -import ProfilesListHeader from './ProfilesListHeader'; import ProfilesListRow from './ProfilesListRow'; interface Props { @@ -35,95 +34,79 @@ interface Props { updateProfiles: () => Promise<void>; } -export default class ProfilesList extends React.PureComponent<Props> { - renderProfiles(profiles: Profile[]) { - return profiles.map((profile) => ( - <ProfilesListRow - key={profile.key} - profile={profile} - updateProfiles={this.props.updateProfiles} - isComparable={profiles.length > 1} - /> - )); - } +export default function ProfilesList(props: Readonly<Props>) { + const { profiles, languages, language } = props; + const intl = useIntl(); - renderHeader(languageKey: string, profilesCount: number) { - const language = this.props.languages.find((l) => l.key === languageKey); + const profilesIndex: Dict<Profile[]> = groupBy<Profile>(profiles, (profile) => profile.language); + const profilesToShow = language ? pick(profilesIndex, language) : profilesIndex; - if (!language) { - return null; - } + let languagesToShow = sortBy(languages, ({ name }) => name).map(({ key }) => key); - return ( - <thead> - <tr> - <th> - {language.name} - {', '} - {translateWithParameters('quality_profiles.x_profiles', profilesCount)} - </th> - <th className="text-right nowrap"> - {translate('quality_profiles.list.projects')} - <HelpTooltip - className="table-cell-doc" - overlay={ - <div className="big-padded-top big-padded-bottom"> - {translate('quality_profiles.list.projects.help')} - </div> - } - /> - </th> - <th className="text-right nowrap">{translate('quality_profiles.list.rules')}</th> - <th className="text-right nowrap">{translate('quality_profiles.list.updated')}</th> - <th className="text-right nowrap">{translate('quality_profiles.list.used')}</th> - <th> </th> - </tr> - </thead> - ); + if (language) { + languagesToShow = languagesToShow.find((key) => key === language) ? [language] : []; } - renderLanguage = (languageKey: string, profiles: Profile[] | undefined) => { - return ( - <div className="boxed-group boxed-group-inner quality-profiles-table" key={languageKey}> - <table className="data zebra zebra-hover" data-language={languageKey}> - {profiles !== undefined && this.renderHeader(languageKey, profiles.length)} - <tbody>{profiles !== undefined && this.renderProfiles(profiles)}</tbody> - </table> - </div> - ); - }; - - render() { - const { profiles, languages, language } = this.props; - - const profilesIndex: Dict<Profile[]> = groupBy<Profile>( - profiles, - (profile) => profile.language, - ); + const renderHeader = React.useCallback( + (languageKey: string, count: number) => { + const language = languages.find((l) => l.key === languageKey); - const profilesToShow = language ? pick(profilesIndex, language) : profilesIndex; - - let languagesToShow: string[]; - if (language) { - languagesToShow = languages.find(({ key }) => key === language) ? [language] : []; - } else { - languagesToShow = sortBy(languages, ({ name }) => name).map(({ key }) => key); - } - - return ( - <div> - <ProfilesListHeader currentFilter={language} languages={languages} /> + return ( + <TableRow> + <ContentCell> + {intl.formatMessage( + { id: 'quality_profiles.x_profiles' }, + { count, name: language?.name }, + )} + </ContentCell> + <ContentCell> + {intl.formatMessage({ id: 'quality_profiles.list.projects' })} + <HelpTooltip + className="sw-ml-1" + overlay={intl.formatMessage({ id: 'quality_profiles.list.projects.help' })} + > + <HelperHintIcon /> + </HelpTooltip> + </ContentCell> + <ContentCell>{intl.formatMessage({ id: 'quality_profiles.list.rules' })}</ContentCell> + <ContentCell>{intl.formatMessage({ id: 'quality_profiles.list.updated' })}</ContentCell> + <ContentCell>{intl.formatMessage({ id: 'quality_profiles.list.used' })}</ContentCell> + <ContentCell> </ContentCell> + </TableRow> + ); + }, + [languages, intl], + ); - {Object.keys(profilesToShow).length === 0 && ( - <Alert className="spacer-top" variant="warning"> - {translate('no_results')} - </Alert> - )} + return ( + <div> + {Object.keys(profilesToShow).length === 0 && ( + <FlagMessage className="sw-mt-4 sw-w-full" variant="warning"> + {intl.formatMessage({ id: 'no_results' })} + </FlagMessage> + )} - {languagesToShow.map((languageKey) => - this.renderLanguage(languageKey, profilesToShow[languageKey]), - )} - </div> - ); - } + {languagesToShow.map((languageKey) => ( + <Table + className="sw-mb-12" + noSidePadding + noHeaderTopBorder + key={languageKey} + columnCount={6} + columnWidths={['43%', '14%', '14%', '14%', '14%', '1%']} + header={renderHeader(languageKey, profilesToShow[languageKey].length)} + data-language={languageKey} + > + {profilesToShow[languageKey].map((profile) => ( + <ProfilesListRow + key={profile.key} + profile={profile} + updateProfiles={props.updateProfiles} + isComparable={profilesToShow[languageKey].length > 1} + /> + ))} + </Table> + ))} + </div> + ); } diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/home/ProfilesListHeader.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/home/ProfilesListHeader.tsx deleted file mode 100644 index b0ec8ba0b14..00000000000 --- a/server/sonar-web/src/main/js/apps/quality-profiles/home/ProfilesListHeader.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* - * 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 * as React from 'react'; -import Select from '../../../components/controls/Select'; -import { Router, withRouter } from '../../../components/hoc/withRouter'; -import { translate } from '../../../helpers/l10n'; -import { PROFILE_PATH } from '../constants'; -import { getProfilesForLanguagePath } from '../utils'; - -interface Props { - currentFilter?: string; - languages: Array<{ key: string; name: string }>; - router: Router; -} - -export class ProfilesListHeader extends React.PureComponent<Props> { - handleChange = (option: { value: string } | null) => { - const { router } = this.props; - - router.replace(!option ? PROFILE_PATH : getProfilesForLanguagePath(option.value)); - }; - - render() { - const { currentFilter, languages } = this.props; - if (languages.length < 2) { - return null; - } - - const options = languages.map((language) => ({ - label: language.name, - value: language.key, - })); - - return ( - <div className="quality-profiles-list-header clearfix"> - <label htmlFor="quality-profiles-filter-input" className="spacer-right"> - {translate('quality_profiles.filter_by')}: - </label> - <Select - className="input-medium" - autoFocus - id="quality-profiles-filter" - inputId="quality-profiles-filter-input" - isClearable - onChange={this.handleChange} - options={options} - isSearchable - value={options.filter((o) => o.value === currentFilter)} - /> - </div> - ); - } -} - -export default withRouter(ProfilesListHeader); diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/home/ProfilesListRow.tsx b/server/sonar-web/src/main/js/apps/quality-profiles/home/ProfilesListRow.tsx index 40a19f0bfed..1e87c2231c5 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/home/ProfilesListRow.tsx +++ b/server/sonar-web/src/main/js/apps/quality-profiles/home/ProfilesListRow.tsx @@ -17,11 +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 { ActionCell, Badge, BaseLink, ContentCell, Link, Note, TableRow } from 'design-system'; import * as React from 'react'; -import Link from '../../../components/common/Link'; +import { useIntl } from 'react-intl'; import Tooltip from '../../../components/controls/Tooltip'; import DateFromNow from '../../../components/intl/DateFromNow'; -import { translate } from '../../../helpers/l10n'; import { getRulesUrl } from '../../../helpers/urls'; import BuiltInQualityProfileBadge from '../components/BuiltInQualityProfileBadge'; import ProfileActions from '../components/ProfileActions'; @@ -34,10 +34,11 @@ export interface ProfilesListRowProps { isComparable: boolean; } -export function ProfilesListRow(props: ProfilesListRowProps) { +export function ProfilesListRow(props: Readonly<ProfilesListRowProps>) { const { profile, isComparable } = props; + const intl = useIntl(); - const offset = 25 * (profile.depth - 1); + const offset = 24 * (profile.depth - 1); const activeRulesUrl = getRulesUrl({ qprofile: profile.key, activation: 'true', @@ -49,64 +50,66 @@ export function ProfilesListRow(props: ProfilesListRowProps) { }); return ( - <tr - className="quality-profiles-table-row text-middle" + <TableRow + className="quality-profiles-table-row" data-key={profile.key} data-name={profile.name} > - <td className="quality-profiles-table-name text-middle"> - <div className="display-flex-center" style={{ paddingLeft: offset }}> - <div> - <ProfileLink language={profile.language} name={profile.name}> - {profile.name} - </ProfileLink> - </div> - {profile.isBuiltIn && <BuiltInQualityProfileBadge className="spacer-left" />} + <ContentCell> + <div className="sw-flex sw-items-center" style={{ paddingLeft: offset }}> + <ProfileLink language={profile.language} name={profile.name}> + {profile.name} + </ProfileLink> + {profile.isBuiltIn && <BuiltInQualityProfileBadge className="sw-ml-2" />} </div> - </td> + </ContentCell> - <td className="quality-profiles-table-projects thin nowrap text-middle text-right"> + <ContentCell> {profile.isDefault ? ( - <Tooltip overlay={translate('quality_profiles.list.default.help')}> - <span className="badge">{translate('default')}</span> + <Tooltip overlay={intl.formatMessage({ id: 'quality_profiles.list.default.help' })}> + <Badge>{intl.formatMessage({ id: 'default' })}</Badge> </Tooltip> ) : ( - <span>{profile.projectCount}</span> + <Note>{profile.projectCount}</Note> )} - </td> + </ContentCell> - <td className="quality-profiles-table-rules thin nowrap text-middle text-right"> + <ContentCell> <div> + <Link to={activeRulesUrl}>{profile.activeRuleCount}</Link> + {profile.activeDeprecatedRuleCount > 0 && ( - <span className="spacer-right"> - <Tooltip overlay={translate('quality_profiles.deprecated_rules')}> - <Link className="badge badge-error" to={deprecatedRulesUrl}> - {profile.activeDeprecatedRuleCount} - </Link> + <span className="sw-ml-2"> + <Tooltip overlay={intl.formatMessage({ id: 'quality_profiles.deprecated_rules' })}> + <BaseLink to={deprecatedRulesUrl} className="sw-border-0"> + <Badge variant="deleted">{profile.activeDeprecatedRuleCount}</Badge> + </BaseLink> </Tooltip> </span> )} - - <Link to={activeRulesUrl}>{profile.activeRuleCount}</Link> </div> - </td> + </ContentCell> - <td className="quality-profiles-table-date thin nowrap text-middle text-right"> - <DateFromNow date={profile.rulesUpdatedAt} /> - </td> + <ContentCell> + <Note> + <DateFromNow date={profile.rulesUpdatedAt} /> + </Note> + </ContentCell> - <td className="quality-profiles-table-date thin nowrap text-middle text-right"> - <DateFromNow date={profile.lastUsed} /> - </td> + <ContentCell> + <Note> + <DateFromNow date={profile.lastUsed} /> + </Note> + </ContentCell> - <td className="quality-profiles-table-actions thin nowrap text-middle text-right"> + <ActionCell> <ProfileActions isComparable={isComparable} profile={profile} updateProfiles={props.updateProfiles} /> - </td> - </tr> + </ActionCell> + </TableRow> ); } diff --git a/server/sonar-web/src/main/js/apps/quality-profiles/styles.css b/server/sonar-web/src/main/js/apps/quality-profiles/styles.css index 31b8aa0e061..291324828b8 100644 --- a/server/sonar-web/src/main/js/apps/quality-profiles/styles.css +++ b/server/sonar-web/src/main/js/apps/quality-profiles/styles.css @@ -17,78 +17,6 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -.quality-profiles-table { - padding-top: 7px; -} - -.quality-profiles-table-inheritance { - width: 280px; -} - -.quality-profiles-table-projects, -.quality-profiles-table-rules, -.quality-profiles-table-date { - min-width: 80px; -} - -.quality-profiles-list-header { - line-height: var(--controlHeight); - margin-bottom: 20px; - padding: 5px 10px; - border-bottom: 1px solid var(--barBorderColor); -} - -.quality-profile-grid { - display: flex; - justify-content: space-between; - align-items: flex-start; -} - -.quality-profile-grid-left { - width: 340px; - flex-shrink: 0; -} - -.quality-profile-grid-right { - flex-grow: 1; - margin-left: 20px; -} - -.quality-profile-rules-distribution { - margin-bottom: 15px; - padding: 7px 20px 0; -} - -.quality-profile-rules-deprecated { - margin-top: 20px; - padding: 15px 20px; - background-color: var(--alertBackgroundError); -} - -.quality-profile-not-found { - padding-top: 100px; - text-align: center; -} - -.quality-profiles-evolution { - padding-top: 55px; -} - -.quality-profiles-evolution-deprecated { - border-color: var(--alertBorderError); - background-color: var(--alertBackgroundError); -} - -.quality-profiles-evolution-stagnant { - border-color: var(--alertBorderWarning); - background-color: var(--alertBackgroundWarning); -} - -.quality-profiles-evolution-deprecated h2, -.quality-profiles-evolution-stagnant h2, -.quality-profiles-evolution-rules h2 { - padding: 0; -} .quality-profile-changelog-rule-cell { line-height: 1.5; @@ -99,12 +27,6 @@ word-break: break-word; } -.quality-profile-compare-right-table:not(.has-first-column) td, -.quality-profile-compare-right-table:not(.has-first-column) th { - /* Aligns the first column with the second one (50%) and add usual cell padding */ - padding-left: calc(50% + 10px); -} - #create-profile-form .radio-card { width: 244px; background-color: var(--neutral50); diff --git a/server/sonar-web/src/main/js/components/hoc/withRouter.tsx b/server/sonar-web/src/main/js/components/hoc/withRouter.tsx index 90b9a5fdfdb..329c601e0ec 100644 --- a/server/sonar-web/src/main/js/components/hoc/withRouter.tsx +++ b/server/sonar-web/src/main/js/components/hoc/withRouter.tsx @@ -24,7 +24,6 @@ import { useLocation as useLocationRouter, useNavigate, useParams, - useSearchParams, } from 'react-router-dom'; import { queryToSearch, searchParamsToQuery } from '../../helpers/urls'; import { RawQuery } from '../../types/types'; @@ -49,33 +48,9 @@ export function withRouter<P extends Partial<WithRouterProps>>( WrappedComponent: React.ComponentType<P>, ): React.ComponentType<Omit<P, keyof WithRouterProps>> { function ComponentWithRouterProp(props: P) { - const locationRouter = useLocationRouter(); - const navigate = useNavigate(); + const router = useRouter(); const params = useParams(); - const [searchParams] = useSearchParams(); - - const router = React.useMemo( - () => ({ - replace: (path: string | Partial<Location>) => { - if ((path as Location).query) { - path.search = queryToSearch((path as Location).query); - } - navigate(path, { replace: true }); - }, - push: (path: string | Partial<Location>) => { - if ((path as Location).query) { - path.search = queryToSearch((path as Location).query); - } - navigate(path); - }, - }), - [navigate], - ); - - const location = { - ...locationRouter, - query: searchParamsToQuery(searchParams), - }; + const location = useLocation(); return <WrappedComponent {...props} location={location} params={params} router={router} />; } @@ -88,6 +63,30 @@ export function withRouter<P extends Partial<WithRouterProps>>( return ComponentWithRouterProp; } +export function useRouter() { + const navigate = useNavigate(); + + const router = React.useMemo( + () => ({ + replace: (path: string | Partial<Location>) => { + if ((path as Location).query) { + path.search = queryToSearch((path as Location).query); + } + navigate(path, { replace: true }); + }, + push: (path: string | Partial<Location>) => { + if ((path as Location).query) { + path.search = queryToSearch((path as Location).query); + } + navigate(path); + }, + }), + [navigate], + ); + + return router; +} + export function useLocation() { const location = useLocationRouter(); 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 5ea2c62b47b..cc85b2a30a4 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -1979,7 +1979,8 @@ quality_profiles.page_title_changelog_x={0} Changelog quality_profiles.page_title_compare_x={0} Comparison quality_profiles.new_profile=New Quality Profile quality_profiles.compare_with=Compare with -quality_profiles.filter_by=Filter profiles by +quality_profiles.filter_by=Filter by +quality_profiles.select_lang=Select language quality_profiles.restore_profile=Restore Profile quality_profiles.restore_profile.success={1} rule(s) restored in profile "{0}" quality_profiles.restore_profile.warning={1} rule(s) restored, {2} rule(s) ignored in profile "{0}" @@ -2012,14 +2013,14 @@ quality_profiles.changelog.UPDATED=Updated quality_profiles.changelog.parameter_reset_to_default_value=Parameter {0} reset to default value quality_profiles.deleted_profile=The profile {0} doesn't exist anymore quality_profiles.projects_for_default=Every project not specifically associated with a quality profile will be associated to this one by default. -quality_profile.x_rules={0} rule(s) -quality_profile.lang_deprecated_x_rules={0}, {1} deprecated rule(s) +quality_profile.x_rules={count} rule(s) +quality_profile.lang_deprecated_x_rules={name}, {count} deprecated rule(s) quality_profile.x_active_rules={0} active rules quality_profiles.x_overridden_rules={0} overridden rules quality_profiles.change_parent=Change Parent quality_profiles.change_parent_warning=By changing the parent of this profile, any information on inherited rules that were manually disabled will be lost. This means some previously disabled rules might be re-enabled. quality_profiles.all_profiles=All Profiles -quality_profiles.x_profiles={0} profile(s) +quality_profiles.x_profiles={name}, {count} profile(s) quality_profiles.projects.select_hint=Click to associate this project with the quality profile quality_profiles.projects.deselect_hint=Click to remove association between this project and the quality profile quality_profile.empty_comparison=The quality profiles are equal. @@ -2027,24 +2028,25 @@ quality_profiles.activate_more=Activate More quality_profiles.activate_more.help.built_in=This quality profile is built in, and cannot be updated manually. If you want to activate more rules, create a new profile that inherits from this one and add rules there. quality_profiles.activate_more_rules=Activate More Rules quality_profiles.comparison.activate_rule=Activate rule for profile "{profile}" -quality_profiles.intro1=Quality profiles are collections of rules to apply during an analysis. -quality_profiles.intro2=For each language there is a default profile. All projects not explicitly assigned to some other profile will be analyzed with the default. Ideally, all projects will use the same profile for a language. +quality_profiles.intro=Quality profiles are collections of rules to apply during an analysis. For each language there is a default profile. All projects not explicitly assigned to some other profile will be analyzed with the default. Ideally, all projects will use the same profile for a language. quality_profiles.list.projects=Projects quality_profiles.list.projects.help=Projects assigned to a profile will always be analyzed with it for that language, regardless of which profile is the default. Quality profile administrators may assign projects to a non-default profile, or always make it follow the system default. Project administrators may choose any profile for each language. quality_profiles.list.rules=Rules quality_profiles.list.updated=Updated quality_profiles.list.used=Used quality_profiles.list.default.help=For each language there is a default profile. All projects not explicitly assigned to some other profile will be analyzed with the default. -quality_profiles.x_updated_on_y={0}, updated on {1} +quality_profiles.x_updated_on_y={name}, updated on {date} quality_profiles.change_projects=Change Projects quality_profiles.not_found=The requested quality profile was not found. +quality_profiles.back_to_list=Go back to the list of Quality Profiles quality_profiles.latest_new_rules=Recently Added Rules quality_profiles.latest_new_rules.activated={0}, activated on {1} profile(s) quality_profiles.latest_new_rules.not_activated={0}, not yet activated +quality_profiles.latest_new_rules.see_all_x=See all {count} recently added rules quality_profiles.deprecated_rules=Deprecated Rules quality_profiles.x_deprecated_rules={linkCount} deprecated {count, plural, one {rule} other {rules}} quality_profiles.deprecated_rules_description=These deprecated rules will eventually disappear. You should proactively investigate replacing them. -quality_profiles.deprecated_rules_are_still_activated=Deprecated rules are still activated on {0} quality profile(s): +quality_profiles.deprecated_rules_are_still_activated=Deprecated rules are still activated on {count} quality profile(s): quality_profiles.sonarway_missing_rules=Sonar way rules not included quality_profiles.sonarway_missing_rules_description=Recommended rules are missing from your profile quality_profiles.x_sonarway_missing_rules={linkCount} Sonar way {count, plural, one {rule} other {rules}} not included @@ -2327,7 +2329,7 @@ coding_rules.filters.inheritance=Inheritance coding_rules.filters.inheritance.inactive=Inheritance criterion is available when an inherited Quality Profile is selected coding_rules.filters.inheritance.none=Not Inherited coding_rules.filters.inheritance.inherited=Inherited -coding_rules.filters.inheritance.x_inherited_from_y={0} inherited from "{1}" +coding_rules.filters.inheritance.x_inherited_from_y={count} inherited from "{name}" coding_rules.filters.inheritance.overrides=Overridden coding_rules.filters.key=Key coding_rules.filters.language=Language |