refactor(navbar): migrate Bootstrap navbar to AntD menus (#14184)

* initial commit

* more polish

* fix types and remove tests

* fix tests, update menu css, add oetc

* fix lint and precommit

* fix test

* update css, address comments

* fix lint

* update submenu for extra buttons

* remove block and lint

* fix lint

* remove block

* adjust margin

* test round 2

* test round 3

* about section

* src/components/Menu/Menu.test.tsx

* remove redundant test

* fmore pointed test

* fix lint

* remove unused css

* fix dashboard nav view

* update comments

* use suggestion

* lint-fix

* move css, fix dropdown and text

* lint

* rearchitect main nav component

* run lint fix

* nit
This commit is contained in:
Phillip Kelley-Dotson
2021-05-08 14:57:59 -07:00
committed by GitHub
parent e7a4734742
commit e16c4d856e
17 changed files with 699 additions and 539 deletions

View File

@@ -72,6 +72,29 @@ export const MenuItem = styled(AntdMenu.Item)`
&.ant-menu-item {
height: ${({ theme }) => theme.gridUnit * 7}px;
line-height: ${({ theme }) => theme.gridUnit * 7}px;
a {
border-bottom: none;
transition: background-color ${({ theme }) => theme.transitionTiming}s;
&:after {
content: '';
position: absolute;
bottom: -3px;
left: 50%;
width: 0;
height: 3px;
opacity: 0;
transform: translateX(-50%);
transition: all ${({ theme }) => theme.transitionTiming}s;
background-color: ${({ theme }) => theme.colors.primary.base};
}
&:focus {
border-bottom: none;
background-color: transparent;
@media (max-width: 767px) {
background-color: ${({ theme }) => theme.colors.primary.light5};
}
}
}
}
&.ant-menu-item,
@@ -84,10 +107,94 @@ export const MenuItem = styled(AntdMenu.Item)`
}
`;
export const StyledNav = styled(AntdMenu)`
line-height: 51px;
border: none;
& > .ant-menu-item,
& > .ant-menu-submenu {
vertical-align: inherit;
&:hover {
color: ${({ theme }) => theme.colors.grayscale.dark1};
}
}
&:not(.ant-menu-dark) > .ant-menu-submenu,
&:not(.ant-menu-dark) > .ant-menu-item {
margin: 0px;
&:hover {
border-bottom: none;
}
}
& > .ant-menu-item > a {
padding: ${({ theme }) => theme.gridUnit * 4}px;
}
.ant-menu-submenu-open,
.ant-menu-submenu-active {
background-color: ${({ theme }) => theme.colors.primary.light5};
.ant-menu-submenu-title {
color: ${({ theme }) => theme.colors.grayscale.dark1};
background-color: ${({ theme }) => theme.colors.primary.light5};
border-bottom: none;
margin: 0;
&:after {
opacity: 1;
width: calc(100% - 1);
}
}
}
`;
export const StyledSubMenu = styled(AntdMenu.SubMenu)`
color: ${({ theme }) => theme.colors.grayscale.dark1};
border-bottom: none;
.ant-menu-submenu-title {
&:after {
content: '';
position: absolute;
bottom: -3px;
left: 50%;
width: 0;
height: 3px;
opacity: 0;
transform: translateX(-50%);
transition: all ${({ theme }) => theme.transitionTiming}s;
background-color: ${({ theme }) => theme.colors.primary.base};
}
}
& > .ant-menu-submenu-title {
padding: 0 ${({ theme }) => theme.gridUnit * 6}px 0
${({ theme }) => theme.gridUnit * 3}px !important;
svg {
position: absolute;
top: ${({ theme }) => theme.gridUnit * 4}px;
right: ${({ theme }) => theme.gridUnit}px;
width: ${({ theme }) => theme.gridUnit * 6}px;
}
&:hover {
color: ${({ theme }) => theme.colors.grayscale.dark1};
}
}
`;
export declare type MenuMode =
| 'vertical'
| 'vertical-left'
| 'vertical-right'
| 'horizontal'
| 'inline';
export const Menu = Object.assign(AntdMenu, {
Item: MenuItem,
});
export const MainNav = Object.assign(StyledNav, {
Item: MenuItem,
SubMenu: StyledSubMenu,
Divider: AntdMenu.Divider,
ItemGroup: AntdMenu.ItemGroup,
});
export const Input = styled(AntdInput)`
border: 1px solid ${({ theme }) => theme.colors.secondary.light3};
border-radius: ${({ theme }) => theme.borderRadius}px;

View File

@@ -18,7 +18,9 @@
*/
import React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { Menu } from './Menu';
import { dropdownItems } from './MenuRight';
const mockedProps = {
data: {
@@ -157,18 +159,19 @@ test('should render all the top navbar menu items', () => {
} = mockedProps;
render(<Menu {...mockedProps} />);
menu.forEach(item => {
const menuItem = screen.getByText(item.label);
expect(menuItem).toHaveAttribute('href', item.url);
expect(screen.getByText(item.label)).toBeInTheDocument();
});
});
test('should render the top navbar child menu items', () => {
test('should render the top navbar child menu items', async () => {
const {
data: { menu },
} = mockedProps;
render(<Menu {...mockedProps} />);
const datasets = screen.getByText('Datasets');
const databases = screen.getByText('Databases');
const sources = screen.getByText('Sources');
userEvent.hover(sources);
const datasets = await screen.findByText('Datasets');
const databases = await screen.findByText('Databases');
const dataset = menu[1].childs![0] as { url: string };
const database = menu[1].childs![2] as { url: string };
@@ -176,23 +179,53 @@ test('should render the top navbar child menu items', () => {
expect(databases).toHaveAttribute('href', database.url);
});
test('should render the Settings', () => {
render(<Menu {...mockedProps} />);
const settings = screen.getByText('Settings');
expect(settings).toHaveAttribute('href', '#');
test('should render the dropdown items', async () => {
render(<Menu {...notanonProps} />);
const dropdown = screen.getByTestId('new-dropdown-icon');
userEvent.hover(dropdown);
expect(await screen.findByText(dropdownItems[0].label)).toHaveAttribute(
'href',
dropdownItems[0].url,
);
expect(
screen.getByTestId(`menu-item-${dropdownItems[0].label}`),
).toBeInTheDocument();
expect(await screen.findByText(dropdownItems[1].label)).toHaveAttribute(
'href',
dropdownItems[1].url,
);
expect(
screen.getByTestId(`menu-item-${dropdownItems[1].label}`),
).toBeInTheDocument();
expect(await screen.findByText(dropdownItems[2].label)).toHaveAttribute(
'href',
dropdownItems[2].url,
);
expect(
screen.getByTestId(`menu-item-${dropdownItems[2].label}`),
).toBeInTheDocument();
});
test('should render the Settings menu item', () => {
test('should render the Settings', async () => {
render(<Menu {...mockedProps} />);
expect(screen.getByText('Security')).toBeInTheDocument();
const settings = await screen.findByText('Settings');
expect(settings).toBeInTheDocument();
});
test('should render the Settings dropdown child menu items', () => {
test('should render the Settings menu item', async () => {
render(<Menu {...mockedProps} />);
userEvent.hover(screen.getByText('Settings'));
const label = await screen.findByText('Security');
expect(label).toBeInTheDocument();
});
test('should render the Settings dropdown child menu items', async () => {
const {
data: { settings },
} = mockedProps;
render(<Menu {...mockedProps} />);
const listUsers = screen.getByText('List Users');
userEvent.hover(screen.getByText('Settings'));
const listUsers = await screen.findByText('List Users');
expect(listUsers).toHaveAttribute('href', settings[0].childs[0].url);
});
@@ -206,7 +239,7 @@ test('should NOT render the plus menu (+) when user is anonymous', () => {
expect(screen.queryByTestId('new-dropdown')).not.toBeInTheDocument();
});
test('should render the user actions when user is not anonymous', () => {
test('should render the user actions when user is not anonymous', async () => {
const {
data: {
navbar_right: { user_info_url, user_logout_url },
@@ -214,10 +247,12 @@ test('should render the user actions when user is not anonymous', () => {
} = mockedProps;
render(<Menu {...notanonProps} />);
expect(screen.getByText('User')).toBeInTheDocument();
userEvent.hover(screen.getByText('Settings'));
const user = await screen.findByText('User');
expect(user).toBeInTheDocument();
const info = screen.getByText('Info');
const logout = screen.getByText('Logout');
const info = await screen.findByText('Info');
const logout = await screen.findByText('Logout');
expect(info).toHaveAttribute('href', user_info_url);
expect(logout).toHaveAttribute('href', user_logout_url);
@@ -228,7 +263,7 @@ test('should NOT render the user actions when user is anonymous', () => {
expect(screen.queryByText('User')).not.toBeInTheDocument();
});
test('should render the Profile link when available', () => {
test('should render the Profile link when available', async () => {
const {
data: {
navbar_right: { user_profile_url },
@@ -236,11 +271,13 @@ test('should render the Profile link when available', () => {
} = mockedProps;
render(<Menu {...notanonProps} />);
const profile = screen.getByText('Profile');
userEvent.hover(screen.getByText('Settings'));
const profile = await screen.findByText('Profile');
expect(profile).toHaveAttribute('href', user_profile_url);
});
test('should render the About section and version_string or sha when available', () => {
test('should render the About section and version_string or sha when available', async () => {
const {
data: {
navbar_right: { version_sha, version_string },
@@ -248,24 +285,28 @@ test('should render the About section and version_string or sha when available',
} = mockedProps;
render(<Menu {...mockedProps} />);
expect(screen.getByText('About')).toBeInTheDocument();
expect(screen.getByText(`Version: ${version_string}`)).toBeInTheDocument();
expect(screen.getByText(`SHA: ${version_sha}`)).toBeInTheDocument();
userEvent.hover(screen.getByText('Settings'));
const about = await screen.findByText('About');
const version = await screen.findByText(`Version: ${version_string}`);
const sha = await screen.findByText(`SHA: ${version_sha}`);
expect(about).toBeInTheDocument();
expect(version).toBeInTheDocument();
expect(sha).toBeInTheDocument();
});
test('should render the Documentation link when available', () => {
test('should render the Documentation link when available', async () => {
const {
data: {
navbar_right: { documentation_url },
},
} = mockedProps;
render(<Menu {...mockedProps} />);
const doc = screen.getByTitle('Documentation');
expect(doc).toHaveAttribute('href', documentation_url);
userEvent.hover(screen.getByText('Settings'));
const doc = await screen.findByTitle('Documentation');
expect(doc.firstChild).toHaveAttribute('href', documentation_url);
});
test('should render the Bug Report link when available', () => {
test('should render the Bug Report link when available', async () => {
const {
data: {
navbar_right: { bug_report_url },
@@ -273,8 +314,8 @@ test('should render the Bug Report link when available', () => {
} = mockedProps;
render(<Menu {...mockedProps} />);
const bugReport = screen.getByTitle('Report a Bug');
expect(bugReport).toHaveAttribute('href', bug_report_url);
const bugReport = await screen.findByTitle('Report a Bug');
expect(bugReport.firstChild).toHaveAttribute('href', bug_report_url);
});
test('should render the Login link when user is anonymous', () => {

View File

@@ -16,21 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { Nav, Navbar, NavItem } from 'react-bootstrap';
import { t, styled } from '@superset-ui/core';
import { Menu as DropdownMenu } from 'src/common/components';
import NavDropdown from 'src/components/NavDropdown';
import React, { useState, useEffect } from 'react';
import { styled } from '@superset-ui/core';
import { debounce } from 'lodash';
import { getUrlParam } from 'src/utils/urlUtils';
import MenuObject, {
MenuObjectProps,
MenuObjectChildProps,
} from './MenuObject';
import LanguagePicker, { Languages } from './LanguagePicker';
import NewMenu from './NewMenu';
import { MainNav as DropdownMenu, MenuMode } from 'src/common/components';
import { Link } from 'react-router-dom';
import { Row, Col } from 'antd';
import Icon from 'src/components/Icon';
import RightMenu from './MenuRight';
import { Languages } from './LanguagePicker';
interface BrandProps {
path: string;
@@ -39,7 +34,7 @@ interface BrandProps {
width: string | number;
}
interface NavBarProps {
export interface NavBarProps {
bug_report_url?: string;
version_string?: string;
version_sha?: string;
@@ -64,7 +59,24 @@ export interface MenuProps {
isFrontendRoute?: (path?: string) => boolean;
}
interface MenuObjectChildProps {
label: string;
name?: string;
icon: string;
index: number;
url?: string;
isFrontendRoute?: boolean;
}
export interface MenuObjectProps extends MenuObjectChildProps {
childs?: (MenuObjectChildProps | string)[];
isHeader?: boolean;
}
const StyledHeader = styled.header`
background-color: white;
margin-bottom: 2px;
border-bottom: 2px solid ${({ theme }) => theme.colors.grayscale.light4}px;
&:nth-last-of-type(2) nav {
margin-bottom: 2px;
}
@@ -72,59 +84,39 @@ const StyledHeader = styled.header`
.caret {
display: none;
}
.navbar-inverse {
border: none;
}
.version-info {
padding: ${({ theme }) => theme.gridUnit * 1.5}px
${({ theme }) => theme.gridUnit * 4}px
${({ theme }) => theme.gridUnit * 1.5}px
${({ theme }) => theme.gridUnit * 7}px;
color: ${({ theme }) => theme.colors.grayscale.base};
font-size: ${({ theme }) => theme.typography.sizes.xs}px;
div {
white-space: nowrap;
}
}
.navbar-brand {
display: flex;
flex-direction: column;
justify-content: center;
}
.nav > li > a {
@media (max-width: 767px) {
.navbar-brand {
float: none;
}
}
.ant-menu-horizontal .ant-menu-item {
height: 100%;
line-height: inherit;
}
/*.ant-menu > .ant-menu-item > a {
padding: ${({ theme }) => theme.gridUnit * 4}px;
}
.dropdown-header {
text-transform: uppercase;
padding-left: 12px;
}*/
@media (max-width: 767px) {
.ant-menu > .ant-menu-item > a {
padding: 0px;
}
.main-nav .ant-menu-submenu-title > svg:nth-child(1) {
display: none;
}
.ant-menu-item-active > a {
&:hover {
color: ${({ theme }) => theme.colors.primary.base} !important;
background-color: transparent !important;
}
}
}
.navbar-inverse .navbar-nav li a {
color: ${({ theme }) => theme.colors.grayscale.dark1};
border-bottom: none;
transition: background-color ${({ theme }) => theme.transitionTiming}s;
&:after {
content: '';
position: absolute;
bottom: -3px;
left: 50%;
width: 0;
height: 3px;
opacity: 0;
transform: translateX(-50%);
transition: all ${({ theme }) => theme.transitionTiming}s;
background-color: ${({ theme }) => theme.colors.primary.base};
}
&:focus {
border-bottom: none;
background-color: transparent;
/* background-color: ${({ theme }) => theme.colors.primary.light5}; */
}
.ant-menu-item a {
&:hover {
color: ${({ theme }) => theme.colors.grayscale.dark1};
background-color: ${({ theme }) => theme.colors.primary.light5};
@@ -136,169 +128,116 @@ const StyledHeader = styled.header`
}
}
}
.navbar-right {
display: flex;
align-items: center;
}
.ant-menu {
.ant-menu-item-group-title {
padding-bottom: ${({ theme }) => theme.gridUnit}px;
}
.ant-menu-item {
margin-bottom: ${({ theme }) => theme.gridUnit * 2}px;
}
.about-section {
margin: ${({ theme }) => theme.gridUnit}px 0
${({ theme }) => theme.gridUnit * 2}px;
}
}
`;
const { SubMenu } = DropdownMenu;
export function Menu({
data: { menu, brand, navbar_right: navbarRight, settings },
isFrontendRoute = () => false,
}: MenuProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
const [showMenu, setMenu] = useState<MenuMode>('horizontal');
useEffect(() => {
function handleResize() {
if (window.innerWidth <= 767) {
setMenu('inline');
} else setMenu('horizontal');
}
handleResize();
const windowResize = debounce(() => handleResize(), 10);
window.addEventListener('resize', windowResize);
return () => window.removeEventListener('resize', windowResize);
}, []);
// would useQueryParam here but not all apps provide a router context
const standalone = getUrlParam('standalone', 'boolean');
if (standalone) return <></>;
const renderSubMenu = ({
label,
childs,
url,
index,
isFrontendRoute,
}: MenuObjectProps) => {
if (url && isFrontendRoute) {
return (
<DropdownMenu.Item key={label} role="presentation">
<Link role="button" to={url}>
{label}
</Link>
</DropdownMenu.Item>
);
}
if (url) {
return (
<DropdownMenu.Item key={label}>
<a href={url}>{label}</a>
</DropdownMenu.Item>
);
}
return (
<SubMenu key={index} title={label} icon={<Icon name="triangle-down" />}>
{childs?.map((child: MenuObjectChildProps | string, index1: number) => {
if (typeof child === 'string' && child === '-') {
return <DropdownMenu.Divider key={`$${index1}`} />;
}
if (typeof child !== 'string') {
return (
<DropdownMenu.Item key={`${child.label}`}>
{child.isFrontendRoute ? (
<Link to={child.url || ''}>{child.label}</Link>
) : (
<a href={child.url}>{child.label}</a>
)}
</DropdownMenu.Item>
);
}
return null;
})}
</SubMenu>
);
};
return (
<StyledHeader className="top" id="main-menu">
<Navbar inverse fluid staticTop role="navigation">
<Navbar.Header>
<Navbar.Brand>
<a className="navbar-brand" href={brand.path}>
<img width={brand.width} src={brand.icon} alt={brand.alt} />
</a>
</Navbar.Brand>
<Navbar.Toggle />
</Navbar.Header>
<Nav data-test="navbar-top">
{menu.map((item, index) => {
const props = {
...item,
isFrontendRoute: isFrontendRoute(item.url),
childs: item.childs?.map(c => {
if (typeof c === 'string') {
return c;
}
return {
...c,
isFrontendRoute: isFrontendRoute(c.url),
};
}),
};
return <MenuObject {...props} key={item.label} index={index + 1} />;
})}
</Nav>
<Nav className="navbar-right">
{!navbarRight.user_is_anonymous && <NewMenu />}
<NavDropdown
id="settings-dropdown"
title={t('Settings')}
onMouseEnter={() => setDropdownOpen(true)}
onMouseLeave={() => setDropdownOpen(false)}
onToggle={value => setDropdownOpen(value)}
open={dropdownOpen}
<StyledHeader className="top" id="main-menu" role="navigation">
<Row>
<Col lg={19} md={19} sm={24} xs={24}>
<a className="navbar-brand" href={brand.path}>
<img width={brand.width} src={brand.icon} alt={brand.alt} />
</a>
<DropdownMenu
mode={showMenu}
data-test="navbar-top"
className="main-nav"
>
<DropdownMenu>
{settings.map((section, index) => [
<DropdownMenu.ItemGroup
key={`${section.label}`}
title={section.label}
>
{section.childs?.map(child => {
if (typeof child !== 'string') {
return (
<DropdownMenu.Item key={`${child.label}`}>
{isFrontendRoute(child.url) ? (
<Link to={child.url || ''}>{child.label}</Link>
) : (
<a href={child.url}>{child.label}</a>
)}
</DropdownMenu.Item>
);
}
return null;
})}
</DropdownMenu.ItemGroup>,
index < settings.length - 1 && <DropdownMenu.Divider />,
])}
{menu.map((item, index) => {
const props = {
...item,
isFrontendRoute: isFrontendRoute(item.url),
childs: item.childs?.map(c => {
if (typeof c === 'string') {
return c;
}
{!navbarRight.user_is_anonymous && [
<DropdownMenu.Divider key="user-divider" />,
<DropdownMenu.ItemGroup key="user-section" title={t('User')}>
{navbarRight.user_profile_url && (
<DropdownMenu.Item key="profile">
<a href={navbarRight.user_profile_url}>{t('Profile')}</a>
</DropdownMenu.Item>
)}
<DropdownMenu.Item key="info">
<a href={navbarRight.user_info_url}>{t('Info')}</a>
</DropdownMenu.Item>
<DropdownMenu.Item key="logout">
<a href={navbarRight.user_logout_url}>{t('Logout')}</a>
</DropdownMenu.Item>
</DropdownMenu.ItemGroup>,
]}
{(navbarRight.version_string || navbarRight.version_sha) && [
<DropdownMenu.Divider key="version-info-divider" />,
<DropdownMenu.ItemGroup key="about-section" title={t('About')}>
<div className="about-section">
{navbarRight.version_string && (
<li className="version-info">
<span>Version: {navbarRight.version_string}</span>
</li>
)}
{navbarRight.version_sha && (
<li className="version-info">
<span>SHA: {navbarRight.version_sha}</span>
</li>
)}
</div>
</DropdownMenu.ItemGroup>,
]}
</DropdownMenu>
</NavDropdown>
{navbarRight.documentation_url && (
<NavItem
href={navbarRight.documentation_url}
target="_blank"
title="Documentation"
>
<i className="fa fa-question" />
&nbsp;
</NavItem>
)}
{navbarRight.bug_report_url && (
<NavItem
href={navbarRight.bug_report_url}
target="_blank"
title="Report a Bug"
>
<i className="fa fa-bug" />
&nbsp;
</NavItem>
)}
{navbarRight.show_language_picker && (
<LanguagePicker
locale={navbarRight.locale}
languages={navbarRight.languages}
/>
)}
{navbarRight.user_is_anonymous && (
<NavItem href={navbarRight.user_login_url}>
<i className="fa fa-fw fa-sign-in" />
{t('Login')}
</NavItem>
)}
</Nav>
</Navbar>
return {
...c,
isFrontendRoute: isFrontendRoute(c.url),
};
}),
};
return renderSubMenu(props);
})}
</DropdownMenu>
</Col>
<Col lg={5} md={5} sm={24} xs={24}>
<RightMenu
settings={settings}
navbarRight={navbarRight}
isFrontendRoute={isFrontendRoute}
/>
</Col>
</Row>
</StyledHeader>
);
}

View File

@@ -1,94 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { NavItem } from 'react-bootstrap';
import { Menu } from 'src/common/components';
import NavDropdown from '../NavDropdown';
export interface MenuObjectChildProps {
label: string;
name?: string;
icon: string;
index: number;
url?: string;
isFrontendRoute?: boolean;
}
export interface MenuObjectProps extends MenuObjectChildProps {
childs?: (MenuObjectChildProps | string)[];
isHeader?: boolean;
}
export default function MenuObject({
label,
childs,
url,
index,
isFrontendRoute,
}: MenuObjectProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
if (url && isFrontendRoute) {
return (
<li role="presentation">
<Link role="button" to={url}>
{label}
</Link>
</li>
);
}
if (url) {
return (
<NavItem eventKey={index} href={url}>
{label}
</NavItem>
);
}
return (
<NavDropdown
id={`menu-dropdown-${label}`}
title={label}
onMouseEnter={() => setDropdownOpen(true)}
onMouseLeave={() => setDropdownOpen(false)}
onToggle={value => setDropdownOpen(value)}
open={dropdownOpen}
>
<Menu>
{childs?.map((child: MenuObjectChildProps | string, index1: number) => {
if (typeof child === 'string' && child === '-') {
return <Menu.Divider key={`$${index1}`} />;
}
if (typeof child !== 'string') {
return (
<Menu.Item key={`${child.label}`}>
{child.isFrontendRoute ? (
<Link to={child.url || ''}>{child.label}</Link>
) : (
<a href={child.url}>{child.label}</a>
)}
</Menu.Item>
);
}
return null;
})}
</Menu>
</NavDropdown>
);
}

View File

@@ -0,0 +1,181 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { MainNav as Menu } from 'src/common/components';
import { t, styled, css, SupersetTheme } from '@superset-ui/core';
import { Link } from 'react-router-dom';
import Icon from 'src/components/Icon';
import LanguagePicker from './LanguagePicker';
import { NavBarProps, MenuObjectProps } from './Menu';
export const dropdownItems = [
{
label: t('SQL query'),
url: '/superset/sqllab',
icon: 'fa-fw fa-search',
},
{
label: t('Chart'),
url: '/chart/add',
icon: 'fa-fw fa-bar-chart',
},
{
label: t('Dashboard'),
url: '/dashboard/new',
icon: 'fa-fw fa-dashboard',
},
];
const versionInfoStyles = (theme: SupersetTheme) => css`
padding: ${theme.gridUnit * 1.5}px ${theme.gridUnit * 4}px
${theme.gridUnit * 1.5}px ${theme.gridUnit * 7}px;
color: ${theme.colors.grayscale.base};
font-size: ${theme.typography.sizes.xs}px;
white-space: nowrap;
`;
const StyledI = styled.div`
color: ${({ theme }) => theme.colors.primary.dark1};
`;
const { SubMenu } = Menu;
interface RightMenuProps {
settings: MenuObjectProps[];
navbarRight: NavBarProps;
isFrontendRoute: (path?: string) => boolean;
}
const RightMenu = ({
settings,
navbarRight,
isFrontendRoute,
}: RightMenuProps) => (
<Menu className="navbar-right" mode="horizontal">
{!navbarRight.user_is_anonymous && (
<SubMenu
data-test="new-dropdown"
title={<StyledI data-test="new-dropdown-icon" className="fa fa-plus" />}
icon={<Icon name="triangle-down" />}
>
{dropdownItems.map((menu, i) => (
<Menu.Item key={i}>
<a href={menu.url}>
<i
data-test={`menu-item-${menu.label}`}
className={`fa ${menu.icon}`}
/>{' '}
{menu.label}
</a>
</Menu.Item>
))}
</SubMenu>
)}
<SubMenu title="Settings" icon={<Icon name="triangle-down" />}>
{settings.map((section, index) => [
<Menu.ItemGroup key={`${section.label}`} title={section.label}>
{section.childs?.map(child => {
if (typeof child !== 'string') {
return (
<Menu.Item key={`${child.label}`}>
{isFrontendRoute(child.url) ? (
<Link to={child.url || ''}>{child.label}</Link>
) : (
<a href={child.url}>{child.label}</a>
)}
</Menu.Item>
);
}
return null;
})}
</Menu.ItemGroup>,
index < settings.length - 1 && <Menu.Divider />,
])}
{!navbarRight.user_is_anonymous && [
<Menu.Divider key="user-divider" />,
<Menu.ItemGroup key="user-section" title={t('User')}>
{navbarRight.user_profile_url && (
<Menu.Item key="profile">
<a href={navbarRight.user_profile_url}>{t('Profile')}</a>
</Menu.Item>
)}
<Menu.Item key="info">
<a href={navbarRight.user_info_url}>{t('Info')}</a>
</Menu.Item>
<Menu.Item key="logout">
<a href={navbarRight.user_logout_url}>{t('Logout')}</a>
</Menu.Item>
</Menu.ItemGroup>,
]}
{(navbarRight.version_string || navbarRight.version_sha) && [
<Menu.Divider key="version-info-divider" />,
<Menu.ItemGroup key="about-section" title={t('About')}>
<div className="about-section">
{navbarRight.version_string && (
<li css={versionInfoStyles}>
Version: {navbarRight.version_string}
</li>
)}
{navbarRight.version_sha && (
<li css={versionInfoStyles}>SHA: {navbarRight.version_sha}</li>
)}
</div>
</Menu.ItemGroup>,
]}
</SubMenu>
{navbarRight.documentation_url && (
<Menu.Item title="Documentation">
<a
href={navbarRight.documentation_url}
target="_blank"
rel="noreferrer"
>
<i className="fa fa-question" />
&nbsp;
</a>
</Menu.Item>
)}
{navbarRight.bug_report_url && (
<Menu.Item title="Report a Bug">
<a href={navbarRight.bug_report_url} target="_blank" rel="noreferrer">
<i className="fa fa-bug" />
</a>
&nbsp;
</Menu.Item>
)}
{navbarRight.show_language_picker && (
<Menu.Item>
<LanguagePicker
locale={navbarRight.locale}
languages={navbarRight.languages}
/>
</Menu.Item>
)}
{navbarRight.user_is_anonymous && (
<Menu.Item>
<a href={navbarRight.user_login_url}>
<i className="fa fa-fw fa-sign-in" />
{t('Login')}
</a>
</Menu.Item>
)}
</Menu>
);
export default RightMenu;

View File

@@ -1,34 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import NewMenu, { dropdownItems } from './NewMenu';
test('should render', () => {
const { container } = render(<NewMenu />);
expect(container).toBeInTheDocument();
});
test('should render the dropdown items', () => {
render(<NewMenu />);
dropdownItems.forEach(item => {
expect(screen.getByText(item.label)).toHaveAttribute('href', item.url);
expect(screen.getByTestId(`menu-item-${item.label}`)).toBeInTheDocument();
});
});

View File

@@ -1,73 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useState } from 'react';
import { t, styled } from '@superset-ui/core';
import { Menu } from 'src/common/components';
import NavDropdown from 'src/components/NavDropdown';
export const dropdownItems = [
{
label: t('SQL query'),
url: '/superset/sqllab',
icon: 'fa-fw fa-search',
},
{
label: t('Chart'),
url: '/chart/add',
icon: 'fa-fw fa-bar-chart',
},
{
label: t('Dashboard'),
url: '/dashboard/new',
icon: 'fa-fw fa-dashboard',
},
];
const StyledI = styled.div`
color: ${({ theme }) => theme.colors.primary.dark1};
`;
export default function NewMenu() {
const [dropdownOpen, setDropdownOpen] = useState(false);
return (
<NavDropdown
id="new-dropdown"
data-test="new-dropdown"
title={<StyledI className="fa fa-plus" />}
onMouseEnter={() => setDropdownOpen(true)}
onMouseLeave={() => setDropdownOpen(false)}
onToggle={value => setDropdownOpen(value)}
open={dropdownOpen}
>
<Menu>
{dropdownItems.map((menu, i) => (
<Menu.Item key={i}>
<a href={menu.url}>
<i
data-test={`menu-item-${menu.label}`}
className={`fa ${menu.icon}`}
/>{' '}
{menu.label}
</a>
</Menu.Item>
))}
</Menu>
</NavDropdown>
);
}

View File

@@ -16,35 +16,65 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { ReactNode } from 'react';
import React, { ReactNode, useState, useEffect } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { styled } from '@superset-ui/core';
import cx from 'classnames';
import { Nav, Navbar } from 'react-bootstrap';
import { debounce } from 'lodash';
import { Col, Row } from 'antd';
import { Menu, MenuMode } from 'src/common/components';
import Button, { OnClickHandler } from 'src/components/Button';
const StyledHeader = styled.header`
const StyledHeader = styled.div`
margin-bottom: ${({ theme }) => theme.gridUnit * 4}px;
.navbar {
margin-bottom: 0;
}
.navbar-header .navbar-brand {
.header {
font-weight: ${({ theme }) => theme.typography.weights.bold};
margin-right: ${({ theme }) => theme.gridUnit * 3}px;
text-align: left;
font-size: 18px;
padding: ${({ theme }) => theme.gridUnit * 3}px;
display: inline-block;
line-height: ${({ theme }) => theme.gridUnit * 9}px;
}
.navbar-right {
.nav-right {
display: flex;
align-items: center;
padding: 8px 0;
margin-right: 0;
padding: 14px 0;
margin-right: ${({ theme }) => theme.gridUnit * 3}px;
float: right;
}
.navbar-nav {
.nav-right-collapse {
display: flex;
align-items: center;
padding: 14px 0;
margin-right: 0;
float: left;
padding-left: 10px;
}
.menu {
background-color: white;
.ant-menu-horizontal {
line-height: inherit;
.ant-menu-item {
&:hover {
border-bottom: none;
}
}
}
.ant-menu {
padding: ${({ theme }) => theme.gridUnit * 4}px 0px;
}
}
.ant-menu-horizontal:not(.ant-menu-dark) > .ant-menu-item {
margin: 0 ${({ theme }) => theme.gridUnit + 1}px;
}
.menu .ant-menu-item {
li {
a,
div {
font-size: ${({ theme }) => theme.typography.sizes.s}px;
padding: ${({ theme }) => theme.gridUnit * 2}px 0;
margin: ${({ theme }) => theme.gridUnit * 2}px;
color: ${({ theme }) => theme.colors.secondary.dark1};
a {
@@ -68,23 +98,23 @@ const StyledHeader = styled.header`
border-bottom: none;
border-radius: ${({ theme }) => theme.borderRadius}px;
margin-bottom: ${({ theme }) => theme.gridUnit * 2}px;
}
}
.navbar-inverse {
.navbar-nav {
& > .active > a {
background: ${({ theme }) => theme.colors.secondary.light4};
&:hover,
&:focus {
background: ${({ theme }) => theme.colors.secondary.light4};
}
}
text-decoration: none;
}
}
.btn-link {
padding: 10px 0;
}
.ant-menu-horizontal {
border: none;
}
@media (max-width: 767px) {
.header,
.nav-right {
float: left;
padding-left: ${({ theme }) => theme.gridUnit * 2}px;
}
}
`;
type MenuChild = {
@@ -119,9 +149,16 @@ export interface SubMenuProps {
* ONLY set usesRouter to true if SubMenu is wrapped in a react-router <Router>;
* otherwise, a 'You should not use <Link> outside a <Router>' error will be thrown */
usesRouter?: boolean;
color?: string;
headerSize?: number;
}
const SubMenu: React.FunctionComponent<SubMenuProps> = props => {
const SubMenuComponent: React.FunctionComponent<SubMenuProps> = props => {
const [showMenu, setMenu] = useState<MenuMode>('horizontal');
const [navRightStyle, setNavRightStyle] = useState('nav-right');
const [navRightCol, setNavRightCol] = useState(8);
const { headerSize = 2 } = props;
let hasHistory = true;
// If no parent <Router> component exists, useHistory throws an error
try {
@@ -130,64 +167,101 @@ const SubMenu: React.FunctionComponent<SubMenuProps> = props => {
// If error is thrown, we know not to use <Link> in render
hasHistory = false;
}
useEffect(() => {
function handleResize() {
if (window.innerWidth <= 767) setMenu('inline');
else setMenu('horizontal');
if (
props.buttons &&
props.buttons.length >= 3 &&
window.innerWidth >= 795
) {
setNavRightCol(8);
setNavRightStyle('nav-right');
} else if (
props.buttons &&
props.buttons.length >= 3 &&
window.innerWidth <= 795
) {
setNavRightCol(24);
setNavRightStyle('nav-right-collapse');
}
}
handleResize();
const resize = debounce(handleResize, 10);
window.addEventListener('resize', resize);
return () => window.removeEventListener('resize', resize);
}, [props.buttons]);
const offset = props.name ? headerSize : 0;
return (
<StyledHeader>
<Navbar inverse fluid role="navigation">
<Navbar.Header>
<Navbar.Brand>{props.name}</Navbar.Brand>
</Navbar.Header>
<Nav>
{props.tabs &&
props.tabs.map(tab => {
if ((props.usesRouter || hasHistory) && !!tab.usesRouter) {
return (
<React.Fragment key={tab.label}>
<li
role="tab"
data-test={tab['data-test']}
className={tab.name === props.activeChild ? 'active' : ''}
>
<div>
<Link to={tab.url || ''}>{tab.label}</Link>
</div>
</li>
</React.Fragment>
);
}
<Row className="menu" role="navigation">
{props.name && (
<Col md={offset} xs={24}>
<div className="header">{props.name}</div>
</Col>
)}
<Col md={16 - offset} sm={24} xs={24}>
<Menu mode={showMenu} style={{ backgroundColor: 'transparent' }}>
{props.tabs &&
props.tabs.map(tab => {
if ((props.usesRouter || hasHistory) && !!tab.usesRouter) {
return (
<Menu.Item key={tab.label}>
<li
role="tab"
data-test={tab['data-test']}
className={
tab.name === props.activeChild ? 'active' : ''
}
>
<div>
<Link to={tab.url || ''}>{tab.label}</Link>
</div>
</li>
</Menu.Item>
);
}
return (
<React.Fragment key={tab.label}>
<li
className={cx('no-router', {
active: tab.name === props.activeChild,
})}
role="tab"
>
<a href={tab.url} onClick={tab.onClick}>
{tab.label}
</a>
</li>
</React.Fragment>
);
})}
</Nav>
<Nav className="navbar-right">
{props.buttons?.map((btn, i) => (
<React.Fragment key={`${i}`}>
return (
<Menu.Item key={tab.label}>
<li
className={cx('no-router', {
active: tab.name === props.activeChild,
})}
role="tab"
>
<a href={tab.url} onClick={tab.onClick}>
{tab.label}
</a>
</li>
</Menu.Item>
);
})}
</Menu>
</Col>
<Col lg={8} md={navRightCol} sm={24} xs={24}>
<div className={navRightStyle}>
{props.buttons?.map((btn, i) => (
<Button
key={i}
buttonStyle={btn.buttonStyle}
onClick={btn.onClick}
data-test={btn['data-test']}
>
{btn.name}
</Button>
</React.Fragment>
))}
</Nav>
</Navbar>
))}
</div>
</Col>
</Row>
{props.children}
</StyledHeader>
);
};
export default SubMenu;
export default SubMenuComponent;

View File

@@ -18,7 +18,7 @@
*/
/* header has mysterious extra margin */
header.top {
margin-bottom: -20px;
margin-bottom: 2px;
}
body {

View File

@@ -344,7 +344,11 @@ function AnnotationLayersList({
return (
<>
<SubMenu name={t('Annotation layers')} buttons={subMenuButtons} />
<SubMenu
headerSize={8}
name={t('Annotation layers')}
buttons={subMenuButtons}
/>
<AnnotationLayerModal
addDangerToast={addDangerToast}
layer={currentAnnotationLayer}

View File

@@ -303,7 +303,7 @@ function CssTemplatesList({
return (
<>
<SubMenu {...menuData} />
<SubMenu headerSize={8} {...menuData} />
<CssTemplateModal
addDangerToast={addDangerToast}
cssTemplate={currentCssTemplate}

View File

@@ -101,7 +101,7 @@ describe('ActivityTable', () => {
expect(wrapper.find(ActivityTable)).toExist();
});
it('renders tabs with three buttons', () => {
expect(wrapper.find('li')).toHaveLength(3);
expect(wrapper.find('li.no-router')).toHaveLength(3);
});
it('renders ActivityCards', async () => {
expect(wrapper.find('ListViewCard')).toExist();
@@ -119,7 +119,7 @@ describe('ActivityTable', () => {
expect(chartCall).toHaveLength(1);
expect(dashboardCall).toHaveLength(1);
});
it('show empty state if there is data', () => {
it('show empty state if there is no data', () => {
const activityProps = {
activeChild: 'Created',
activityData: {},

View File

@@ -239,14 +239,12 @@ export default function ActivityTable({
// eslint-disable-next-line react/no-children-prop
tabs={tabs}
/>
<>
{activityData[activeChild]?.length > 0 ||
(activeChild === 'Edited' && editedObjs && editedObjs.length > 0) ? (
<ActivityContainer>{renderActivity()}</ActivityContainer>
) : (
<EmptyState tableName="RECENTS" tab={activeChild} />
)}
</>
{activityData[activeChild]?.length > 0 ||
(activeChild === 'Edited' && editedObjs && editedObjs.length > 0) ? (
<ActivityContainer>{renderActivity()}</ActivityContainer>
) : (
<EmptyState tableName="RECENTS" tab={activeChild} />
)}
</>
);
}

View File

@@ -71,7 +71,7 @@ describe('DashboardTable', () => {
it('render a submenu with clickable tabs and buttons', async () => {
expect(wrapper.find('SubMenu')).toExist();
expect(wrapper.find('li')).toHaveLength(2);
expect(wrapper.find('li.no-router')).toHaveLength(2);
expect(wrapper.find('Button')).toHaveLength(4);
act(() => {
const handler = wrapper.find('li.no-router a').at(1).prop('onClick');

View File

@@ -105,7 +105,7 @@ describe('SavedQueries', () => {
it('renders a submenu with clickable tables and buttons', async () => {
expect(wrapper.find(SubMenu)).toExist();
expect(wrapper.find('li')).toHaveLength(1);
expect(wrapper.find('li.no-router')).toHaveLength(1);
expect(wrapper.find('button')).toHaveLength(2);
clickTab(0);
await waitForComponentToPaint(wrapper);

View File

@@ -55,7 +55,7 @@ export interface ActivityData {
const WelcomeContainer = styled.div`
background-color: ${({ theme }) => theme.colors.grayscale.light4};
nav {
.ant-row.menu {
margin-top: -15px;
background-color: ${({ theme }) => theme.colors.grayscale.light4};
&:after {
@@ -64,25 +64,18 @@ const WelcomeContainer = styled.div`
border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
margin: 0px ${({ theme }) => theme.gridUnit * 6}px;
position: relative;
width: 100%;
${[mq[1]]} {
margin-top: 5px;
margin: 0px 2px;
}
}
.nav.navbar-nav {
& > li:nth-of-type(1),
& > li:nth-of-type(2),
& > li:nth-of-type(3) {
margin-top: ${({ theme }) => theme.gridUnit * 2}px;
}
.ant-menu.ant-menu-light.ant-menu-root.ant-menu-horizontal {
padding-left: ${({ theme }) => theme.gridUnit * 10}px;
}
button {
padding: 3px 21px;
}
.navbar-right {
position: relative;
top: 11px;
}
}
.ant-card.ant-card-bordered {
border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
@@ -92,7 +85,6 @@ const WelcomeContainer = styled.div`
const WelcomeNav = styled.div`
height: 50px;
background-color: white;
margin-top: ${({ theme }) => theme.gridUnit * -4 - 1}px;
.navbar-brand {
margin-left: ${({ theme }) => theme.gridUnit * 2}px;
font-weight: ${({ theme }) => theme.typography.weights.bold};

View File

@@ -582,3 +582,28 @@ hr {
background-image: url('../images/icons/error_solid_small_red.svg') !important;
background-position: -2px center !important;
}
// AntD overrides since these are injected as inline styles and can't
// be overriden in emotion
.ant-menu-submenu.ant-menu-submenu-popup.ant-menu.ant-menu-light.ant-menu-submenu-placement-bottomLeft {
top: 51px !important;
margin-left: -2px !important;
@media (max-width: 767px) {
top: 269px !important;
}
& > .ant-menu {
border-radius: 0px !important;
}
}
.ant-menu-submenu.ant-menu-submenu-popup.ant-menu.ant-menu-light {
top: 51px !important;
border-radius: 0px !important;
@media (max-width: 767px) {
top: 269px !important;
}
}
.ant-dropdown.ant-dropdown-placement-bottomRight {
top: 133px !important;
}