diff options
17 files changed, 352 insertions, 377 deletions
diff --git a/server/sonar-web/design-system/src/components/Dropdown.tsx b/server/sonar-web/design-system/src/components/Dropdown.tsx index d79f7d011bc..ab273c08f0c 100644 --- a/server/sonar-web/design-system/src/components/Dropdown.tsx +++ b/server/sonar-web/design-system/src/components/Dropdown.tsx @@ -61,6 +61,9 @@ interface State { open: boolean; } +/** @deprecated Use DropdownMenu.Root and other DropdownMenu.* elements from Echoes instead. + * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3354918914/DropdownMenus | Migration Guide} + */ export class Dropdown extends React.PureComponent<Readonly<Props>, State> { state: State = { open: false }; @@ -142,6 +145,9 @@ interface ActionsDropdownProps extends Omit<Props, 'children' | 'overlay'> { toggleClassName?: string; } +/** @deprecated Use DropdownMenu.Root and other DropdownMenu.* elements from Echoes instead. + * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3354918914/DropdownMenus | Migration Guide} + */ export function ActionsDropdown(props: Readonly<ActionsDropdownProps>) { const { children, buttonSize, ariaLabel, toggleClassName, ...dropdownProps } = props; diff --git a/server/sonar-web/design-system/src/components/DropdownMenu.tsx b/server/sonar-web/design-system/src/components/DropdownMenu.tsx index 335f28f434e..c8027c1fc24 100644 --- a/server/sonar-web/design-system/src/components/DropdownMenu.tsx +++ b/server/sonar-web/design-system/src/components/DropdownMenu.tsx @@ -39,6 +39,9 @@ interface Props extends React.HtmlHTMLAttributes<HTMLMenuElement> { size?: InputSizeKeys; } +/** @deprecated Use DropdownMenu.Root and other DropdownMenu.* elements from Echoes instead. + * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3354918914/DropdownMenus | Migration Guide} + */ export function DropdownMenu({ children, className, @@ -75,6 +78,9 @@ type ItemLinkProps = Omit<ListItemProps, 'innerRef'> & innerRef?: React.Ref<HTMLAnchorElement>; }; +/** @deprecated Use DropdownMenu.Root and other DropdownMenu.* elements from Echoes instead. + * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3354918914/DropdownMenus | Migration Guide} + */ export function ItemLink(props: ItemLinkProps) { const { children, @@ -111,6 +117,9 @@ interface ItemNavLinkProps extends ItemLinkProps { end?: boolean; } +/** @deprecated Use DropdownMenu.Root and other DropdownMenu.* elements from Echoes instead. + * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3354918914/DropdownMenus | Migration Guide} + */ export function ItemNavLink(props: ItemNavLinkProps) { const { children, className, disabled, end, icon, onClick, selected, innerRef, to, ...liProps } = props; @@ -138,6 +147,9 @@ interface ItemButtonProps extends ListItemProps { onClick: React.MouseEventHandler<HTMLButtonElement>; } +/** @deprecated Use DropdownMenu.Root and other DropdownMenu.* elements from Echoes instead. + * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3354918914/DropdownMenus | Migration Guide} + */ export const ItemButton = forwardRef( (props: ItemButtonProps, ref: ForwardedRef<HTMLButtonElement>) => { const { children, className, disabled, icon, innerRef, onClick, selected, ...liProps } = props; @@ -258,6 +270,9 @@ export const ItemHeaderHighlight = styled.span` font-weight: 600; `; +/** @deprecated Use DropdownMenu.Root and other DropdownMenu.* elements from Echoes instead. + * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3354918914/DropdownMenus | Migration Guide} + */ export const ItemHeader = styled.li` background-color: ${themeColor('dropdownMenuHeader')}; color: ${themeContrast('dropdownMenuHeader')}; @@ -266,6 +281,9 @@ export const ItemHeader = styled.li` `; ItemHeader.defaultProps = { className: 'dropdown-menu-header', role: 'menuitem' }; +/** @deprecated Use DropdownMenu.Root and other DropdownMenu.* elements from Echoes instead. + * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3354918914/DropdownMenus | Migration Guide} + */ export const ItemDivider = styled.li` height: 1px; background-color: ${themeColor('popupBorder')}; diff --git a/server/sonar-web/design-system/src/components/DropdownToggler.tsx b/server/sonar-web/design-system/src/components/DropdownToggler.tsx index 10d959b7128..c41ea91ab8b 100644 --- a/server/sonar-web/design-system/src/components/DropdownToggler.tsx +++ b/server/sonar-web/design-system/src/components/DropdownToggler.tsx @@ -31,6 +31,9 @@ interface Props extends PopupProps { withFocusOutHandler?: boolean; } +/** @deprecated Use DropdownMenu.Root and other DropdownMenu.* elements from Echoes instead. + * See the {@link https://xtranet-sonarsource.atlassian.net/wiki/spaces/Platform/pages/3354918914/DropdownMenus | Migration Guide} + */ export function DropdownToggler(props: Props) { const { children, diff --git a/server/sonar-web/design-system/src/components/MainMenuItem.tsx b/server/sonar-web/design-system/src/components/MainMenuItem.tsx index cef7db8ed9e..c2f33f554bf 100644 --- a/server/sonar-web/design-system/src/components/MainMenuItem.tsx +++ b/server/sonar-web/design-system/src/components/MainMenuItem.tsx @@ -49,10 +49,14 @@ export const MainMenuItem = styled.li` } &:hover, - &.hover, - &[aria-expanded='true'] { + &.hover { border-bottom: ${themeBorder('active', 'menuBorder', 1)}; color: ${themeContrast('mainBarHover')}; } } + + &[aria-expanded='true'] a { + border-bottom: ${themeBorder('active', 'menuBorder', 1)}; + color: ${themeContrast('mainBarHover')}; + } `; diff --git a/server/sonar-web/design-system/src/components/NavBarTabs.tsx b/server/sonar-web/design-system/src/components/NavBarTabs.tsx index 590cb913605..7d2aecf4447 100644 --- a/server/sonar-web/design-system/src/components/NavBarTabs.tsx +++ b/server/sonar-web/design-system/src/components/NavBarTabs.tsx @@ -17,9 +17,10 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import styled from '@emotion/styled'; import classNames from 'classnames'; -import React from 'react'; +import React, { forwardRef } from 'react'; import tw, { theme } from 'twin.macro'; import { themeBorder, themeColor, themeContrast } from '../helpers/theme'; import { isDefined } from '../helpers/types'; @@ -48,29 +49,36 @@ interface NavBarTabLinkProps extends Omit<NavLinkProps, 'children'> { withChevron?: boolean; } -export function NavBarTabLink(props: NavBarTabLinkProps) { - const { active, children, className, text, withChevron = false, ...linkProps } = props; - return ( - <NavBarTabLinkWrapper> - <NavLink - className={({ isActive }) => - classNames( - 'sw-flex sw-items-center', - { active: isDefined(active) ? active : isActive }, - className, - ) - } - {...linkProps} - > - <span className="sw-inline-block sw-text-center" data-text={text}> - {text} - </span> - {children} - {withChevron && <ChevronDownIcon className="sw-ml-1" />} - </NavLink> - </NavBarTabLinkWrapper> - ); -} +export const NavBarTabLink = forwardRef<HTMLAnchorElement, NavBarTabLinkProps>( + (props: NavBarTabLinkProps, ref) => { + const { active, children, className, text, withChevron = false, ...linkProps } = props; + return ( + <NavBarTabLinkWrapper> + <NavLink + className={({ isActive }) => + classNames( + 'sw-flex sw-items-center', + { active: isDefined(active) ? active : isActive }, + className, + ) + } + ref={ref} + {...linkProps} + > + <span className="sw-inline-block sw-text-center" data-text={text}> + {text} + </span> + + {children} + + {withChevron && <ChevronDownIcon className="sw-ml-1" />} + </NavLink> + </NavBarTabLinkWrapper> + ); + }, +); + +NavBarTabLink.displayName = 'NavBarTabLink'; export function DisabledTabLink(props: { label: string; overlay: React.ReactNode }) { return ( @@ -102,11 +110,13 @@ const NavBarTabLinkWrapper = styled.li` & > a.active, & > a:active, & > a:hover, - & > a:focus { + & > a:focus, + & > a[aria-expanded='true'] { border-bottom-color: ${themeColor('tabBorder')}; } & > a.active > span[data-text], + & > a[aria-expanded='true'] > span[data-text], & > a:active > span { ${tw`sw-body-md-highlight`}; } diff --git a/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx b/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx index bc8bd1e1bde..55cc488429d 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx @@ -17,14 +17,9 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { - DisabledTabLink, - Dropdown, - ItemNavLink, - NavBarTabLink, - NavBarTabs, - PopupZLevel, -} from 'design-system'; + +import { DropdownMenu } from '@sonarsource/echoes-react'; +import { DisabledTabLink, NavBarTabLink, NavBarTabs } from 'design-system'; import * as React from 'react'; import { useLocation } from '~sonar-aligned/components/hoc/withRouter'; import { getBranchLikeQuery, isPullRequest } from '~sonar-aligned/helpers/branch-like'; @@ -245,33 +240,28 @@ export function Menu(props: Readonly<Props>) { isApplication(qualifier), isPortfolioLike(qualifier), ); + if (!adminLinks.some((link) => link != null)) { return null; } return ( - <Dropdown + <DropdownMenu.Root data-test="administration" id="component-navigation-admin" - size="auto" - zLevel={PopupZLevel.Global} - overlay={adminLinks} + items={adminLinks} > - {({ onToggleClick, open, a11yAttrs }) => ( - <NavBarTabLink - active={isSettingsActive || open} - onClick={onToggleClick} - text={ - hasMessage('layout.settings', component.qualifier) - ? translate('layout.settings', component.qualifier) - : translate('layout.settings') - } - withChevron - to={{}} - {...a11yAttrs} - /> - )} - </Dropdown> + <NavBarTabLink + active={isSettingsActive} + text={ + hasMessage('layout.settings', component.qualifier) + ? translate('layout.settings', component.qualifier) + : translate('layout.settings') + } + withChevron + to={{}} + /> + </DropdownMenu.Root> ); }; @@ -325,12 +315,12 @@ export function Menu(props: Readonly<Props>) { return null; } return ( - <ItemNavLink + <DropdownMenu.ItemLink key="settings" to={{ pathname: '/project/settings', search: new URLSearchParams(query).toString() }} > {translate('project_settings.page')} - </ItemNavLink> + </DropdownMenu.ItemLink> ); }; @@ -340,12 +330,12 @@ export function Menu(props: Readonly<Props>) { } return ( - <ItemNavLink + <DropdownMenu.ItemLink key="branches" to={{ pathname: '/project/branches', search: new URLSearchParams(query).toString() }} > {translate('project_branch_pull_request.page')} - </ItemNavLink> + </DropdownMenu.ItemLink> ); }; @@ -354,12 +344,12 @@ export function Menu(props: Readonly<Props>) { return null; } return ( - <ItemNavLink + <DropdownMenu.ItemLink key="baseline" to={{ pathname: '/project/baseline', search: new URLSearchParams(query).toString() }} > {translate('project_baseline.page')} - </ItemNavLink> + </DropdownMenu.ItemLink> ); }; @@ -368,7 +358,7 @@ export function Menu(props: Readonly<Props>) { return null; } return ( - <ItemNavLink + <DropdownMenu.ItemLink key="import-export" to={{ pathname: '/project/import_export', @@ -376,7 +366,7 @@ export function Menu(props: Readonly<Props>) { }} > {translate('project_dump.page')} - </ItemNavLink> + </DropdownMenu.ItemLink> ); }; @@ -385,7 +375,7 @@ export function Menu(props: Readonly<Props>) { return null; } return ( - <ItemNavLink + <DropdownMenu.ItemLink key="profiles" to={{ pathname: '/project/quality_profiles', @@ -393,7 +383,7 @@ export function Menu(props: Readonly<Props>) { }} > {translate('project_quality_profiles.page')} - </ItemNavLink> + </DropdownMenu.ItemLink> ); }; @@ -402,12 +392,12 @@ export function Menu(props: Readonly<Props>) { return null; } return ( - <ItemNavLink + <DropdownMenu.ItemLink key="quality_gate" to={{ pathname: '/project/quality_gate', search: new URLSearchParams(query).toString() }} > {translate('project_quality_gate.page')} - </ItemNavLink> + </DropdownMenu.ItemLink> ); }; @@ -416,12 +406,12 @@ export function Menu(props: Readonly<Props>) { return null; } return ( - <ItemNavLink + <DropdownMenu.ItemLink key="links" to={{ pathname: '/project/links', search: new URLSearchParams(query).toString() }} > {translate('project_links.page')} - </ItemNavLink> + </DropdownMenu.ItemLink> ); }; @@ -430,12 +420,12 @@ export function Menu(props: Readonly<Props>) { return null; } return ( - <ItemNavLink + <DropdownMenu.ItemLink key="permissions" to={{ pathname: '/project_roles', search: new URLSearchParams(query).toString() }} > {translate('permissions.page')} - </ItemNavLink> + </DropdownMenu.ItemLink> ); }; @@ -444,7 +434,7 @@ export function Menu(props: Readonly<Props>) { return null; } return ( - <ItemNavLink + <DropdownMenu.ItemLink key="background_tasks" to={{ pathname: '/project/background_tasks', @@ -452,7 +442,7 @@ export function Menu(props: Readonly<Props>) { }} > {translate('background_tasks.page')} - </ItemNavLink> + </DropdownMenu.ItemLink> ); }; @@ -461,12 +451,12 @@ export function Menu(props: Readonly<Props>) { return null; } return ( - <ItemNavLink + <DropdownMenu.ItemLink key="update_key" to={{ pathname: '/project/key', search: new URLSearchParams(query).toString() }} > {translate('update_key.page')} - </ItemNavLink> + </DropdownMenu.ItemLink> ); }; @@ -475,12 +465,12 @@ export function Menu(props: Readonly<Props>) { return null; } return ( - <ItemNavLink + <DropdownMenu.ItemLink key="webhooks" to={{ pathname: '/project/webhooks', search: new URLSearchParams(query).toString() }} > {translate('webhooks.page')} - </ItemNavLink> + </DropdownMenu.ItemLink> ); }; @@ -500,12 +490,12 @@ export function Menu(props: Readonly<Props>) { } return ( - <ItemNavLink + <DropdownMenu.ItemLink key="project_delete" to={{ pathname: '/project/deletion', search: new URLSearchParams(query).toString() }} > {translate('deletion.page')} - </ItemNavLink> + </DropdownMenu.ItemLink> ); }; @@ -513,9 +503,12 @@ export function Menu(props: Readonly<Props>) { const pathname = isAdmin ? `/project/admin/extension/${key}` : `/project/extension/${key}`; const query = { ...baseQuery, qualifier }; return ( - <ItemNavLink key={key} to={{ pathname, search: new URLSearchParams(query).toString() }}> + <DropdownMenu.ItemLink + key={key} + to={{ pathname, search: new URLSearchParams(query).toString() }} + > {name} - </ItemNavLink> + </DropdownMenu.ItemLink> ); }; @@ -538,25 +531,13 @@ export function Menu(props: Readonly<Props>) { } return ( - <Dropdown + <DropdownMenu.Root data-test="extensions" id="component-navigation-more" - size="auto" - zLevel={PopupZLevel.Global} - overlay={withoutSecurityExtension.map((e) => renderExtension(e, false, query))} + items={withoutSecurityExtension.map((e) => renderExtension(e, false, query))} > - {({ onToggleClick, open, a11yAttrs }) => ( - <NavBarTabLink - active={open} - onClick={onToggleClick} - preventDefault - text={translate('more')} - withChevron - to={{}} - {...a11yAttrs} - /> - )} - </Dropdown> + <NavBarTabLink preventDefault text={translate('more')} withChevron to={{}} /> + </DropdownMenu.Root> ); }; diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/Menu-test.tsx b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/Menu-test.tsx index 2d954bb0bf9..a8b1519b245 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/Menu-test.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/Menu-test.tsx @@ -17,7 +17,8 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { screen } from '@testing-library/react'; + +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { ComponentQualifier } from '~sonar-aligned/types/component'; @@ -62,7 +63,7 @@ it('should render correctly', async () => { expect(screen.getByRole('link', { name: 'layout.security_reports' })).toBeInTheDocument(); // Check the dropdown. - const button = screen.getByRole('button', { name: 'more' }); + const button = screen.getByRole('link', { name: 'more' }); expect(button).toBeInTheDocument(); await user.click(button); expect(screen.getByRole('menuitem', { name: 'ComponentFoo' })).toBeInTheDocument(); @@ -101,7 +102,7 @@ it('should render correctly when on a branch', async () => { extensions: [{ key: 'component-foo', name: 'ComponentFoo' }], }, }, - 'branch=normal-branch', + 'id=foo&branch=normal-branch', ); expect(await screen.findByRole('link', { name: 'overview.page' })).toBeInTheDocument(); @@ -119,16 +120,19 @@ it('should render correctly when on a pull request', async () => { extensions: [{ key: 'component-foo', name: 'ComponentFoo' }], }, }, - 'pullRequest=01', + 'id=foo&pullRequest=01', ); expect(await screen.findByRole('link', { name: 'overview.page' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'issues.page' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'layout.measures' })).toBeInTheDocument(); - expect( - screen.queryByRole('link', { name: `layout.settings.${ComponentQualifier.Project}` }), - ).not.toBeInTheDocument(); + await waitFor(() => { + expect( + screen.queryByRole('link', { name: `layout.settings.${ComponentQualifier.Project}` }), + ).not.toBeInTheDocument(); + }); + expect(screen.queryByRole('button', { name: 'project.info.title' })).not.toBeInTheDocument(); }); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMore.tsx b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMore.tsx index eb91e17a2e8..84de3342ce8 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMore.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavMore.tsx @@ -17,7 +17,9 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { Dropdown, ItemNavLink, MainMenuItem, PopupPlacement, PopupZLevel } from 'design-system'; + +import { DropdownMenu } from '@sonarsource/echoes-react'; +import { MainMenuItem } from 'design-system'; import * as React from 'react'; import { translate } from '../../../../helpers/l10n'; import { AppState } from '../../../../types/appstate'; @@ -26,13 +28,13 @@ import withAppStateContext from '../../app-state/withAppStateContext'; const renderGlobalPageLink = ({ key, name }: Extension) => { return ( - <ItemNavLink key={key} to={`/extension/${key}`}> + <DropdownMenu.ItemLink key={key} to={`/extension/${key}`}> {name} - </ItemNavLink> + </DropdownMenu.ItemLink> ); }; -function GlobalNavMore({ appState: { globalPages = [] } }: { appState: AppState }) { +function GlobalNavMore({ appState: { globalPages = [] } }: Readonly<{ appState: AppState }>) { const withoutPortfolios = globalPages.filter((page) => page.key !== 'governance/portfolios'); if (withoutPortfolios.length === 0) { @@ -40,29 +42,17 @@ function GlobalNavMore({ appState: { globalPages = [] } }: { appState: AppState } return ( - <Dropdown + <DropdownMenu.Root + align="start" id="moreMenuDropdown" - overlay={<ul>{withoutPortfolios.map(renderGlobalPageLink)}</ul>} - placement={PopupPlacement.BottomLeft} - zLevel={PopupZLevel.Global} + items={withoutPortfolios.map(renderGlobalPageLink)} > - {({ onToggleClick, open }) => ( - <ul> - <MainMenuItem> - <a - aria-expanded={open} - aria-haspopup="menu" - href="#" - id="global-navigation-more" - onClick={onToggleClick} - role="button" - > - {translate('more')} - </a> - </MainMenuItem> - </ul> - )} - </Dropdown> + <MainMenuItem> + <a aria-haspopup="menu" href="#" id="global-navigation-more" role="button"> + {translate('more')} + </a> + </MainMenuItem> + </DropdownMenu.Root> ); } diff --git a/server/sonar-web/src/main/js/app/components/nav/settings/SettingsNav.tsx b/server/sonar-web/src/main/js/app/components/nav/settings/SettingsNav.tsx index 6b63fecf74f..e562c6016c7 100644 --- a/server/sonar-web/src/main/js/app/components/nav/settings/SettingsNav.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/settings/SettingsNav.tsx @@ -17,15 +17,9 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { - Dropdown, - ItemNavLink, - LightLabel, - NavBarTabLink, - NavBarTabs, - PopupZLevel, - TopBar, -} from 'design-system'; + +import { DropdownMenu } from '@sonarsource/echoes-react'; +import { LightLabel, NavBarTabLink, NavBarTabs, TopBar } from 'design-system'; import * as React from 'react'; import { Location } from 'react-router-dom'; import withLocation from '../../../../components/hoc/withLocation'; @@ -88,9 +82,9 @@ export class SettingsNav extends React.PureComponent<Props> { renderExtension = ({ key, name }: Extension) => { return ( - <ItemNavLink key={key} to={`/admin/extension/${key}`}> + <DropdownMenu.ItemLink isMatchingFullPath key={key} to={`/admin/extension/${key}`}> {name} - </ItemNavLink> + </DropdownMenu.ItemLink> ); }; @@ -98,134 +92,125 @@ export class SettingsNav extends React.PureComponent<Props> { const extensionsWithoutSupport = this.props.extensions.filter( (extension) => extension.key !== 'license/support', ); + return ( - <Dropdown + <DropdownMenu.Root + align="start" id="settings-navigation-configuration-dropdown" - overlay={ + items={ <> - <ItemNavLink end to="/admin/settings"> + <DropdownMenu.ItemLink isMatchingFullPath to="/admin/settings"> {translate('settings.page')} - </ItemNavLink> + </DropdownMenu.ItemLink> - <ItemNavLink end to="/admin/settings/encryption"> + <DropdownMenu.ItemLink isMatchingFullPath to="/admin/settings/encryption"> {translate('property.category.security.encryption')} - </ItemNavLink> + </DropdownMenu.ItemLink> - <ItemNavLink end to="/admin/webhooks"> + <DropdownMenu.ItemLink isMatchingFullPath to="/admin/webhooks"> {translate('webhooks.page')} - </ItemNavLink> + </DropdownMenu.ItemLink> {extensionsWithoutSupport.map(this.renderExtension)} </> } - size="auto" - zLevel={PopupZLevel.Global} > - {({ onToggleClick, open }) => ( - <NavBarTabLink - aria-expanded={open} - aria-haspopup="menu" - active={ - open || - (!this.isSecurityActive() && - !this.isProjectsActive() && - !this.isSystemActive() && - !this.isSomethingActive(['/admin/extension/license/support']) && - !this.isMarketplace() && - !this.isAudit()) - } - to={{}} - id="settings-navigation-configuration" - onClick={onToggleClick} - text={translate('sidebar.project_settings')} - withChevron - /> - )} - </Dropdown> + <NavBarTabLink + aria-haspopup="menu" + active={ + !this.isSecurityActive() && + !this.isProjectsActive() && + !this.isSystemActive() && + !this.isSomethingActive(['/admin/extension/license/support']) && + !this.isMarketplace() && + !this.isAudit() + } + id="settings-navigation-configuration" + text={translate('sidebar.project_settings')} + to={{}} + withChevron + /> + </DropdownMenu.Root> ); } renderProjectsTab() { return ( - <Dropdown + <DropdownMenu.Root id="settings-navigation-projects-dropdown" - overlay={ + items={ <> - <ItemNavLink end to="/admin/projects_management"> + <DropdownMenu.ItemLink isMatchingFullPath to="/admin/projects_management"> {translate('management')} - </ItemNavLink> + </DropdownMenu.ItemLink> - <ItemNavLink end to="/admin/background_tasks"> + <DropdownMenu.ItemLink isMatchingFullPath to="/admin/background_tasks"> {translate('background_tasks.page')} - </ItemNavLink> + </DropdownMenu.ItemLink> </> } - size="auto" - zLevel={PopupZLevel.Global} > - {({ onToggleClick, open }) => ( - <NavBarTabLink - aria-expanded={open} - aria-haspopup="menu" - active={open || this.isProjectsActive()} - to={{}} - onClick={onToggleClick} - text={translate('sidebar.projects')} - withChevron - /> - )} - </Dropdown> + <NavBarTabLink + aria-haspopup="menu" + active={this.isProjectsActive()} + to={{}} + text={translate('sidebar.projects')} + withChevron + /> + </DropdownMenu.Root> ); } renderSecurityTab() { return ( - <Dropdown + <DropdownMenu.Root id="settings-navigation-security-dropdown" - overlay={ + items={ <> - <ItemNavLink to="/admin/users">{translate('users.page')}</ItemNavLink> + <DropdownMenu.ItemLink isMatchingFullPath to="/admin/users"> + {translate('users.page')} + </DropdownMenu.ItemLink> - <ItemNavLink to="/admin/groups">{translate('user_groups.page')}</ItemNavLink> + <DropdownMenu.ItemLink isMatchingFullPath to="/admin/groups"> + {translate('user_groups.page')} + </DropdownMenu.ItemLink> - <ItemNavLink to="/admin/permissions"> + <DropdownMenu.ItemLink isMatchingFullPath to="/admin/permissions"> {translate('global_permissions.page')} - </ItemNavLink> + </DropdownMenu.ItemLink> - <ItemNavLink to="/admin/permission_templates"> + <DropdownMenu.ItemLink isMatchingFullPath to="/admin/permission_templates"> {translate('permission_templates')} - </ItemNavLink> + </DropdownMenu.ItemLink> </> } - size="auto" - zLevel={PopupZLevel.Global} > - {({ onToggleClick, open }) => ( - <NavBarTabLink - aria-expanded={open} - aria-haspopup="menu" - active={open || this.isSecurityActive()} - to={{}} - onClick={onToggleClick} - text={translate('sidebar.security')} - withChevron - /> - )} - </Dropdown> + <NavBarTabLink + aria-haspopup="menu" + active={this.isSecurityActive()} + to={{}} + text={translate('sidebar.security')} + withChevron + /> + </DropdownMenu.Root> ); } render() { const { extensions, pendingPlugins } = this.props; const hasSupportExtension = extensions.find((extension) => extension.key === 'license/support'); + const hasGovernanceExtension = extensions.find( (e) => e.key === AdminPageExtension.GovernanceConsole, ); + const totalPendingPlugins = pendingPlugins.installing.length + pendingPlugins.removing.length + pendingPlugins.updating.length; + let notifComponent; + if (this.props.systemStatus === 'RESTARTING') { notifComponent = <SystemRestartNotif />; } else if (totalPendingPlugins > 0) { @@ -266,6 +251,7 @@ export class SettingsNav extends React.PureComponent<Props> { )} </NavBarTabs> </TopBar> + {notifComponent} </> ); diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssueOpenInIdeButton.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssueOpenInIdeButton.tsx index 63852374650..bfa6befdcdd 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/IssueOpenInIdeButton.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/IssueOpenInIdeButton.tsx @@ -18,16 +18,8 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { - ButtonSecondary, - DropdownMenu, - DropdownToggler, - ItemButton, - PopupPlacement, - PopupZLevel, - addGlobalErrorMessage, - addGlobalSuccessMessage, -} from 'design-system'; +import { DropdownMenu } from '@sonarsource/echoes-react'; +import { addGlobalErrorMessage, addGlobalSuccessMessage, ButtonSecondary } from 'design-system'; import * as React from 'react'; import { FormattedMessage } from 'react-intl'; import DocumentationLink from '../../../components/common/DocumentationLink'; @@ -49,11 +41,6 @@ export interface Props { pullRequestID?: string; } -interface State { - disabled?: boolean; - ides: Ide[]; -} - const showError = () => addGlobalErrorMessage( <FormattedMessage @@ -79,14 +66,19 @@ export function IssueOpenInIdeButton({ projectKey, pullRequestID, }: Readonly<Props>) { - const [state, setState] = React.useState<State>({ disabled: false, ides: [] }); + const [isDisabled, setIsDisabled] = React.useState(false); + const [ides, setIdes] = React.useState<Ide[] | undefined>(undefined); + const ref = React.useRef<HTMLButtonElement>(null); - const cleanState = () => { - setState({ ...state, ides: [] }); - }; + // to give focus back to the trigger button once it is re-rendered as a single button + const focusTriggerButton = React.useCallback(() => { + setTimeout(() => { + ref.current?.focus(); + }); + }, []); const openIssue = async (ide: Ide) => { - setState({ ...state, disabled: true, ides: [] }); // close the dropdown, disable the button + setIsDisabled(true); let token: { name?: string; token?: string } = {}; @@ -112,14 +104,15 @@ export function IssueOpenInIdeButton({ setTimeout( () => { - setState({ ...state, disabled: false }); + setIsDisabled(false); + focusTriggerButton(); }, ide.needsToken ? DELAY_AFTER_TOKEN_CREATION : 0, ); }; - const onClick = async () => { - setState({ ...state, ides: [] }); + const findIDEs = async () => { + setIdes(undefined); const ides = (await probeSonarLintServers()) ?? []; @@ -128,47 +121,51 @@ export function IssueOpenInIdeButton({ } else if (ides.length === 1) { openIssue(ides[0]); } else { - setState({ ...state, ides }); + setIdes(ides); } }; - return ( - <div> - <DropdownToggler - allowResizing - onRequestClose={cleanState} - open={state.ides.length > 1} - overlay={ - <DropdownMenu size="auto"> - {state.ides.map((ide) => { - const { ideName, description } = ide; - - const label = ideName + (description ? ` - ${description}` : ''); - - return ( - <ItemButton - key={ide.port} - onClick={() => { - openIssue(ide); - }} - > - {label} - </ItemButton> - ); - })} - </DropdownMenu> - } - placement={PopupPlacement.BottomLeft} - zLevel={PopupZLevel.Global} - > - <ButtonSecondary - className="sw-whitespace-nowrap" - disabled={state.disabled} - onClick={onClick} - > - {translate('open_in_ide')} - </ButtonSecondary> - </DropdownToggler> - </div> + const onClick = ides === undefined ? findIDEs : undefined; + + const triggerButton = ( + <ButtonSecondary + className="sw-whitespace-nowrap" + disabled={isDisabled} + onClick={onClick} + ref={ref} + > + {translate('open_in_ide')} + </ButtonSecondary> + ); + + return ides === undefined ? ( + triggerButton + ) : ( + <DropdownMenu.Root + isOpenOnMount + items={ides.map((ide) => { + const { ideName, description } = ide; + + const label = ideName + (description ? ` - ${description}` : ''); + + return ( + <DropdownMenu.ItemButton + key={ide.port} + onClick={() => { + openIssue(ide); + }} + > + {label} + </DropdownMenu.ItemButton> + ); + })} + onClose={() => { + setIdes(undefined); + focusTriggerButton(); + }} + onOpen={findIDEs} + > + {triggerButton} + </DropdownMenu.Root> ); } diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx index 2cfa5efd2c7..7a7383db6b9 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx @@ -58,7 +58,7 @@ import { DEFAULT_ISSUES_QUERY } from '../../../components/shared/utils'; import { fillBranchLike, isSameBranchLike } from '../../../helpers/branch-like'; import handleRequiredAuthentication from '../../../helpers/handleRequiredAuthentication'; import { parseIssueFromResponse } from '../../../helpers/issues'; -import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers'; +import { isDropdown, isInput, isShortcut } from '../../../helpers/keyboardEventHelpers'; import { KeyboardKeys } from '../../../helpers/keycodes'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { serializeDate } from '../../../helpers/query'; @@ -280,7 +280,7 @@ export class App extends React.PureComponent<Props, State> { return; } - if (isInput(event) || isShortcut(event)) { + if (isInput(event) || isShortcut(event) || isDropdown(event)) { return; } diff --git a/server/sonar-web/src/main/js/components/activity-graph/GraphsHeader.tsx b/server/sonar-web/src/main/js/components/activity-graph/GraphsHeader.tsx index 2997e847dd8..008c47f2cd3 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/GraphsHeader.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/GraphsHeader.tsx @@ -17,15 +17,9 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { - ButtonSecondary, - ChevronDownIcon, - Dropdown, - ItemButton, - PopupPlacement, - PopupZLevel, - TextMuted, -} from 'design-system'; + +import { DropdownMenu } from '@sonarsource/echoes-react'; +import { ButtonSecondary, ChevronDownIcon, TextMuted } from 'design-system'; import * as React from 'react'; import { translate } from '../../helpers/l10n'; import { GraphType } from '../../types/project-activity'; @@ -73,9 +67,9 @@ export default function GraphsHeader(props: Props) { const label = translate('project_activity.graphs', type); return ( - <ItemButton key={label} onClick={() => handleGraphChange(type)}> + <DropdownMenu.ItemButton key={label} onClick={() => handleGraphChange(type)}> {label} - </ItemButton> + </DropdownMenu.ItemButton> ); }); }, [noCustomGraph, handleGraphChange]); @@ -83,13 +77,7 @@ export default function GraphsHeader(props: Props) { return ( <div className={className}> <div className="sw-flex"> - <Dropdown - id="activity-graph-type" - size="auto" - placement={PopupPlacement.BottomLeft} - zLevel={PopupZLevel.Content} - overlay={options} - > + <DropdownMenu.Root id="activity-graph-type" align="start" items={options}> <ButtonSecondary aria-label={translate('project_activity.graphs.choose_type')} className={ @@ -100,7 +88,7 @@ export default function GraphsHeader(props: Props) { <TextMuted text={translate('project_activity.graphs', graph)} /> <ChevronDownIcon className="sw-ml-1 sw-mr-0 sw-pr-0" /> </ButtonSecondary> - </Dropdown> + </DropdownMenu.Root> {isCustomGraph(graph) && props.onAddCustomMetric !== undefined && diff --git a/server/sonar-web/src/main/js/components/embed-docs-modal/DocItemLink.tsx b/server/sonar-web/src/main/js/components/embed-docs-modal/DocItemLink.tsx index 0ca48ab3ac7..7e795522211 100644 --- a/server/sonar-web/src/main/js/components/embed-docs-modal/DocItemLink.tsx +++ b/server/sonar-web/src/main/js/components/embed-docs-modal/DocItemLink.tsx @@ -18,24 +18,18 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { ItemLink, OpenNewTabIcon } from 'design-system'; +import { DropdownMenu } from '@sonarsource/echoes-react'; import * as React from 'react'; import { DocLink } from '../../helpers/doc-links'; import { useDocUrl } from '../../helpers/docs'; interface Props { children: React.ReactNode; - innerRef?: React.Ref<HTMLAnchorElement>; to: DocLink; } -export function DocItemLink({ to, innerRef, children }: Props) { +export function DocItemLink({ to, children }: Readonly<Props>) { const toStatic = useDocUrl(to); - return ( - <ItemLink innerRef={innerRef} to={toStatic}> - <OpenNewTabIcon /> - {children} - </ItemLink> - ); + return <DropdownMenu.ItemLink to={toStatic}>{children}</DropdownMenu.ItemLink>; } diff --git a/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopup.tsx b/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopup.tsx index 15276793770..2707b60a7c1 100644 --- a/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopup.tsx +++ b/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopup.tsx @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { ItemDivider, ItemHeader, ItemLink, OpenNewTabIcon } from 'design-system'; +import { DropdownMenu } from '@sonarsource/echoes-react'; import * as React from 'react'; import { DocLink } from '../../helpers/doc-links'; import { translate } from '../../helpers/l10n'; @@ -37,40 +37,36 @@ function IconLink({ text: string; }) { return ( - <ItemLink to={link}> - <Image - alt={text} - aria-hidden - className="sw-mr-2" - height="18" - src={`/images/${icon}`} - width="18" - /> + <DropdownMenu.ItemLink + prefix={ + <Image + alt={text} + aria-hidden + className="sw-mr-2" + height="18" + src={`/images/${icon}`} + width="18" + /> + } + to={link} + > {text} - </ItemLink> + </DropdownMenu.ItemLink> ); } -function Suggestions({ - firstItemRef, - suggestions, -}: { - firstItemRef: React.RefObject<HTMLAnchorElement>; - suggestions: SuggestionLink[]; -}) { +function Suggestions({ suggestions }: Readonly<{ suggestions: SuggestionLink[] }>) { return ( <> - <ItemHeader id="suggestion">{translate('docs.suggestion')}</ItemHeader> - {suggestions.map((suggestion, i) => ( - <DocItemLink - innerRef={i === 0 ? firstItemRef : undefined} - key={suggestion.link} - to={suggestion.link} - > + <DropdownMenu.GroupLabel>{translate('docs.suggestion')}</DropdownMenu.GroupLabel> + + {suggestions.map((suggestion) => ( + <DocItemLink key={suggestion.link} to={suggestion.link}> {suggestion.text} </DocItemLink> ))} - <ItemDivider /> + + <DropdownMenu.Separator /> </> ); } @@ -85,29 +81,38 @@ export function EmbedDocsPopup() { return ( <> - {suggestions.length !== 0 && ( - <Suggestions firstItemRef={firstItemRef} suggestions={suggestions} /> - )} - <DocItemLink innerRef={suggestions.length === 0 ? firstItemRef : undefined} to={DocLink.Root}> - {translate('docs.documentation')} - </DocItemLink> - <ItemLink to="/web_api">{translate('api_documentation.page')}</ItemLink> - <ItemLink to="/web_api_v2">{translate('api_documentation.page.v2')}</ItemLink> - <ItemDivider /> - <ItemLink to="https://community.sonarsource.com/"> - <OpenNewTabIcon /> + {suggestions.length !== 0 && <Suggestions suggestions={suggestions} />} + + <DocItemLink to={DocLink.Root}>{translate('docs.documentation')}</DocItemLink> + + <DropdownMenu.ItemLink to="/web_api"> + {translate('api_documentation.page')} + </DropdownMenu.ItemLink> + + <DropdownMenu.ItemLink to="/web_api_v2"> + {translate('api_documentation.page.v2')} + </DropdownMenu.ItemLink> + + <DropdownMenu.Separator /> + + <DropdownMenu.ItemLink to="https://community.sonarsource.com/"> {translate('docs.get_help')} - </ItemLink> - <ItemDivider /> - <ItemHeader id="stay_connected">{translate('docs.stay_connected')}</ItemHeader> + </DropdownMenu.ItemLink> + + <DropdownMenu.Separator /> + + <DropdownMenu.GroupLabel>{translate('docs.stay_connected')}</DropdownMenu.GroupLabel> + <IconLink link="https://www.sonarsource.com/products/sonarqube/whats-new/?referrer=sonarqube" text={translate('docs.news')} /> + <IconLink link="https://www.sonarsource.com/products/sonarqube/roadmap/?referrer=sonarqube" text={translate('docs.roadmap')} /> + <IconLink icon="embed-doc/x-icon-black.svg" link="https://twitter.com/SonarQube" diff --git a/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopupHelper.tsx b/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopupHelper.tsx index 39fd69449af..b929c57fd6c 100644 --- a/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopupHelper.tsx +++ b/server/sonar-web/src/main/js/components/embed-docs-modal/EmbedDocsPopupHelper.tsx @@ -18,40 +18,29 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { IconQuestionMark } from '@sonarsource/echoes-react'; -import { Dropdown, InteractiveIcon, PopupPlacement, PopupZLevel } from 'design-system'; +import { DropdownMenu, IconQuestionMark, Tooltip } from '@sonarsource/echoes-react'; +import { InteractiveIcon } from 'design-system'; import * as React from 'react'; import { translate } from '../../helpers/l10n'; -import Tooltip from '../controls/Tooltip'; import { EmbedDocsPopup } from './EmbedDocsPopup'; export default function EmbedDocsPopupHelper() { return ( <div className="dropdown"> - <Dropdown - id="help-menu-dropdown" - placement={PopupPlacement.BottomRight} - overlay={<EmbedDocsPopup />} - allowResizing - zLevel={PopupZLevel.Global} - > - {({ onToggleClick, open }) => ( - <Tooltip mouseLeaveDelay={0.2} content={!open ? translate('help') : undefined}> - <InteractiveIcon - Icon={IconQuestionMark} - aria-expanded={open} - data-guiding-id="issue-5" - aria-controls="help-menu-dropdown" - aria-haspopup - aria-label={translate('help')} - currentColor - onClick={onToggleClick} - size="medium" - stopPropagation={false} - /> - </Tooltip> - )} - </Dropdown> + <DropdownMenu.Root align="end" id="help-menu-dropdown" items={<EmbedDocsPopup />}> + <Tooltip content={translate('help')}> + <InteractiveIcon + Icon={IconQuestionMark} + data-guiding-id="issue-5" + aria-controls="help-menu-dropdown" + aria-haspopup + aria-label={translate('help')} + currentColor + size="medium" + stopPropagation={false} + /> + </Tooltip> + </DropdownMenu.Root> </div> ); } diff --git a/server/sonar-web/src/main/js/components/embed-docs-modal/__tests__/EmbedDocsPopup-test.tsx b/server/sonar-web/src/main/js/components/embed-docs-modal/__tests__/EmbedDocsPopup-test.tsx index ef09bf7b4b0..95b64a1af0e 100644 --- a/server/sonar-web/src/main/js/components/embed-docs-modal/__tests__/EmbedDocsPopup-test.tsx +++ b/server/sonar-web/src/main/js/components/embed-docs-modal/__tests__/EmbedDocsPopup-test.tsx @@ -35,8 +35,6 @@ it('should render with no suggestions', async () => { expect(screen.getByText('docs.documentation')).toBeInTheDocument(); expect(screen.queryByText('docs.suggestion')).not.toBeInTheDocument(); - - expect(screen.getByText('docs.documentation')).toHaveFocus(); }); it('should be able to render with suggestions and remove them', async () => { @@ -51,13 +49,9 @@ it('should be able to render with suggestions and remove them', async () => { expect(screen.getByText('docs.suggestion')).toBeInTheDocument(); expect(screen.getByText('About Background Tasks')).toBeInTheDocument(); - expect(screen.getByText('About Background Tasks')).toHaveFocus(); - await user.click(screen.getByRole('button', { name: 'remove.suggestion' })); await user.click(screen.getByRole('button', { name: 'help' })); expect(screen.queryByText('docs.suggestion')).not.toBeInTheDocument(); - - expect(screen.getByText('docs.documentation')).toHaveFocus(); }); function renderEmbedDocsPopup() { diff --git a/server/sonar-web/src/main/js/helpers/keyboardEventHelpers.ts b/server/sonar-web/src/main/js/helpers/keyboardEventHelpers.ts index 65ec4ba0fee..6a73fad10a0 100644 --- a/server/sonar-web/src/main/js/helpers/keyboardEventHelpers.ts +++ b/server/sonar-web/src/main/js/helpers/keyboardEventHelpers.ts @@ -36,3 +36,9 @@ export function isInput( event.target instanceof HTMLTextAreaElement ); } + +export function isDropdown(event: KeyboardEvent) { + const role = (event.target as HTMLElement | null)?.role ?? ''; + + return ['menu', 'menuitem'].includes(role); +} |