未验证 提交 6da5a64d 编写于 作者: P Peter Pan 提交者: GitHub

User interaction improvement (#949)

* feat: show more menu in navbar

* feat: able to drag and change run sidebar width
上级 22b159a1
......@@ -9,8 +9,11 @@
"graph": "Graphs",
"high-dimensional": "High Dimensional",
"histogram": "Histogram",
"hyper-parameter": "Hyper Parameters",
"image": "Image",
"inactive": "Inactive",
"loading": "Please wait while loading data",
"more": "More",
"next-page": "Next Page",
"pr-curve": "PR Curve",
"previous-page": "Prev Page",
......
......@@ -9,8 +9,11 @@
"graph": "网络结构",
"high-dimensional": "高维数据映射",
"histogram": "直方图",
"hyper-parameter": "超参可视化",
"image": "图像",
"inactive": "待使用",
"loading": "数据载入中,请稍等",
"more": "更多",
"next-page": "下一页",
"pr-curve": "PR曲线",
"previous-page": "上一页",
......
......@@ -14,8 +14,8 @@
* limitations under the License.
*/
import React, {FunctionComponent} from 'react';
import {WithStyled, asideWidth, rem, size, transitionProps} from '~/utils/style';
import React, {FunctionComponent, useCallback, useLayoutEffect, useRef, useState} from 'react';
import {WithStyled, asideWidth, rem, transitionProps} from '~/utils/style';
import styled from 'styled-components';
......@@ -30,11 +30,16 @@ export const AsideSection = styled.section`
}
`;
const Wrapper = styled.div<{width?: string | number}>`
${props => size('100%', props.width == null ? asideWidth : props.width)}
const Wrapper = styled.div.attrs<{width: string | number}>(({width}) => ({
style: {
width: 'number' === typeof width ? `${width}px` : width
}
}))<{width: string | number}>`
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
> .aside-top {
flex: auto;
......@@ -55,16 +60,107 @@ const Wrapper = styled.div<{width?: string | number}>`
box-shadow: 0 -${rem(5)} ${rem(16)} 0 rgba(0, 0, 0, 0.03);
padding: ${rem(20)};
}
> .aside-resize-bar-left,
> .aside-resize-bar-right {
position: absolute;
width: ${rem(8)};
height: 100%;
top: 0;
cursor: col-resize;
user-select: none;
&.aside-resize-bar-left {
left: 0;
}
&.aside-resize-bar-right {
right: 0;
}
}
`;
type AsideProps = {
width?: string | number;
bottom?: React.ReactNode;
resizable?: 'left' | 'right';
minWidth?: number;
maxWidth?: number;
onResized?: (width: number) => unknown;
};
const Aside: FunctionComponent<AsideProps & WithStyled> = ({width, bottom, className, children}) => {
const Aside: FunctionComponent<AsideProps & WithStyled> = ({
width,
bottom,
resizable,
minWidth,
maxWidth,
onResized,
className,
children
}) => {
const [sideWidth, setSideWidth] = useState<NonNullable<typeof width>>(width ?? asideWidth);
const ref = useRef<HTMLDivElement>(null);
const resizing = useRef<boolean>(false);
const range = useRef({
min: minWidth ?? null,
max: maxWidth ?? null
});
useLayoutEffect(() => {
range.current.min = minWidth ?? null;
}, [minWidth]);
useLayoutEffect(() => {
range.current.max = maxWidth ?? null;
}, [maxWidth]);
useLayoutEffect(() => {
if (range.current.min == null && ref.current) {
const {width} = ref.current.getBoundingClientRect();
range.current.min = width;
}
}, []);
const mousedown = useCallback(() => {
resizing.current = true;
}, []);
const mousemove = useCallback(
(event: MouseEvent) => {
if (ref.current && resizing.current) {
const clientX = event.clientX;
const {left, right} = ref.current.getBoundingClientRect();
let w = 0;
if (resizable === 'left') {
w = Math.max(range.current.min ?? 0, right - clientX);
} else if (resizable === 'right') {
w = Math.max(range.current.min ?? 0, clientX - left);
}
w = Math.min(range.current.max ?? document.body.clientWidth / 2, w);
setSideWidth(w);
}
},
[resizable]
);
const mouseup = useCallback(() => {
resizing.current = false;
if (ref.current) {
onResized?.(ref.current.getBoundingClientRect().width);
}
}, [onResized]);
useLayoutEffect(() => {
document.addEventListener('mousemove', mousemove);
return () => document.removeEventListener('mousemove', mousemove);
}, [mousemove]);
useLayoutEffect(() => {
document.addEventListener('mouseup', mouseup);
return () => document.removeEventListener('mouseup', mouseup);
}, [mouseup]);
return (
<Wrapper width={width} className={className}>
<Wrapper width={sideWidth} className={className} ref={ref}>
{resizable ? <div className={`aside-resize-bar-${resizable}`} onMouseDown={mousedown}></div> : null}
<div className="aside-top">{children}</div>
{bottom && <div className="aside-bottom">{bottom}</div>}
</Wrapper>
......
......@@ -14,9 +14,11 @@
* limitations under the License.
*/
// cspell:words cimode
import {Link, LinkProps, useLocation} from 'react-router-dom';
import React, {FunctionComponent, useCallback, useEffect, useMemo, useState} from 'react';
import {border, borderRadius, rem, size, transitionProps} from '~/utils/style';
import {border, borderRadius, rem, size, transitionProps, triangle} from '~/utils/style';
import Icon from '~/components/Icon';
import Language from '~/components/Language';
......@@ -28,17 +30,40 @@ import {getApiToken} from '~/utils/fetch';
import logo from '~/assets/images/logo.svg';
import queryString from 'query-string';
import styled from 'styled-components';
import useNavItems from '~/hooks/useNavItems';
import useAvailableComponents from '~/hooks/useAvailableComponents';
import {useTranslation} from 'react-i18next';
const BASE_URI: string = import.meta.env.SNOWPACK_PUBLIC_BASE_URI;
const PUBLIC_PATH: string = import.meta.env.SNOWPACK_PUBLIC_PATH;
const API_TOKEN_KEY: string = import.meta.env.SNOWPACK_PUBLIC_API_TOKEN_KEY;
interface NavbarItemProps extends Route {
const MAX_ITEM_COUNT_IN_NAVBAR = 5;
const flatten = <T extends {children?: T[]}>(routes: T[]) => {
const result: Omit<T, 'children'>[] = [];
routes.forEach(route => {
if (route.children) {
result.push(...flatten(route.children));
} else {
result.push(route);
}
});
return result;
};
interface NavbarItemProps {
active: boolean;
path?: Route['path'];
showDropdownIcon?: boolean;
}
interface NavbarItemType {
id: string;
cid?: string;
name: string;
active: boolean;
children?: ({active: boolean} & NonNullable<Route['children']>[number])[];
path?: Route['path'];
children?: NavbarItemType[];
}
function appendApiToken(url: string) {
......@@ -129,10 +154,27 @@ const NavItem = styled.div<{active?: boolean}>`
${props => border('bottom', rem(3), 'solid', props.active ? 'var(--navbar-highlight-color)' : 'transparent')}
${transitionProps('border-bottom')}
text-transform: uppercase;
&.dropdown-icon {
&::after {
content: '';
display: inline-block;
width: 0;
height: 0;
margin-left: 0.5rem;
vertical-align: middle;
${triangle({
pointingDirection: 'bottom',
width: rem(8),
height: rem(5),
foregroundColor: 'currentColor'
})}
}
}
}
`;
const SubNav = styled.div`
const SubNavWrapper = styled.div`
overflow: hidden;
border-radius: ${borderRadius};
`;
......@@ -156,38 +198,65 @@ const NavItemChild = styled.div<{active?: boolean}>`
}
`;
const NavbarLink: FunctionComponent<{to?: string} & Omit<LinkProps, 'to'>> = ({to, children, ...props}) => {
return (
<Link to={to ? appendApiToken(to) : ''} {...props}>
{children}
</Link>
);
};
const NavbarItem = React.forwardRef<HTMLDivElement, NavbarItemProps>(({id, cid, path, active}, ref) => {
const {t} = useTranslation('common');
const name = useMemo(() => (cid ? `${t(id)} - ${t(cid)}` : t(id)), [t, id, cid]);
const NavbarLink: FunctionComponent<{to?: string} & Omit<LinkProps, 'to'>> = ({to, children, ...props}) => (
<Link to={to ? appendApiToken(to) : ''} {...props}>
{children}
</Link>
);
// FIXME: why we need to add children type here... that's weird...
const NavbarItem = React.forwardRef<HTMLDivElement, NavbarItemProps & {children?: React.ReactNode}>(
({path, active, showDropdownIcon, children}, ref) => {
if (path) {
return (
<NavItem active={active} ref={ref}>
<NavbarLink to={path} className="nav-link">
<span className={`nav-text ${showDropdownIcon ? 'dropdown-icon' : ''}`}>{children}</span>
</NavbarLink>
</NavItem>
);
}
if (path) {
return (
<NavItem active={active} ref={ref}>
<NavbarLink to={path} className="nav-link">
<span className="nav-text">{name}</span>
</NavbarLink>
<span className={`nav-text ${showDropdownIcon ? 'dropdown-icon' : ''}`}>{children}</span>
</NavItem>
);
}
return (
<NavItem active={active} ref={ref}>
<span className="nav-text">{name}</span>
</NavItem>
);
});
);
NavbarItem.displayName = 'NavbarItem';
const SubNav: FunctionComponent<{
menu: Omit<NavbarItemType, 'children' | 'cid'>[];
active?: boolean;
path?: string;
showDropdownIcon?: boolean;
}> = ({menu, active, path, showDropdownIcon, children}) => (
<Tippy
placement="bottom-start"
animation="shift-away-subtle"
interactive
arrow={false}
offset={[0, 0]}
hideOnClick={false}
role="menu"
content={
<SubNavWrapper>
{menu.map(item => (
<NavItemChild active={item.active} key={item.id}>
<NavbarLink to={item.path}>{item.name}</NavbarLink>
</NavItemChild>
))}
</SubNavWrapper>
}
>
<NavbarItem active={active || false} path={path} showDropdownIcon={showDropdownIcon}>
{children}
</NavbarItem>
</Tippy>
);
const Navbar: FunctionComponent = () => {
const {t, i18n} = useTranslation('common');
const {pathname} = useLocation();
......@@ -202,11 +271,32 @@ const Navbar: FunctionComponent = () => {
const currentPath = useMemo(() => pathname.replace(BASE_URI, ''), [pathname]);
const [navItems] = useNavItems();
const [items, setItems] = useState<NavbarItemProps[]>([]);
const [components, inactiveComponents] = useAvailableComponents();
const componentsInNavbar = useMemo(() => components.slice(0, MAX_ITEM_COUNT_IN_NAVBAR), [components]);
const flattenMoreComponents = useMemo(() => flatten(components.slice(MAX_ITEM_COUNT_IN_NAVBAR)), [components]);
const flattenInactiveComponents = useMemo(() => flatten(inactiveComponents), [inactiveComponents]);
const componentsInMoreMenu = useMemo(
() =>
flattenMoreComponents.map(item => ({
...item,
active: currentPath === item.path
})),
[currentPath, flattenMoreComponents]
);
const componentsInInactiveMenu = useMemo(
() =>
flattenInactiveComponents.map(item => ({
...item,
active: currentPath === item.path
})),
[currentPath, flattenInactiveComponents]
);
const [navItemsInNavbar, setNavItemsInNavbar] = useState<NavbarItemType[]>([]);
useEffect(() => {
setItems(oldItems =>
navItems.map(item => {
setNavItemsInNavbar(oldItems =>
componentsInNavbar.map(item => {
const children = item.children?.map(child => ({
...child,
active: child.path === currentPath
......@@ -217,6 +307,7 @@ const Navbar: FunctionComponent = () => {
return {
...item,
cid: child.id,
name: child.name,
path: currentPath,
active: true,
children
......@@ -227,6 +318,7 @@ const Navbar: FunctionComponent = () => {
return {
...item,
...oldItem,
name: item.children?.find(c => c.id === oldItem.cid)?.name ?? item.name,
active: false,
children
};
......@@ -240,7 +332,7 @@ const Navbar: FunctionComponent = () => {
};
})
);
}, [navItems, currentPath]);
}, [componentsInNavbar, currentPath]);
return (
<Nav>
......@@ -249,36 +341,35 @@ const Navbar: FunctionComponent = () => {
<img alt="PaddlePaddle" src={PUBLIC_PATH + logo} />
<span>VisualDL</span>
</Logo>
{items.map(item => {
{navItemsInNavbar.map(item => {
if (item.children) {
return (
<Tippy
placement="bottom-start"
animation="shift-away-subtle"
interactive
arrow={false}
offset={[0, 0]}
hideOnClick={false}
role="menu"
content={
<SubNav>
{item.children.map(child => (
<NavItemChild active={child.active} key={child.id}>
<NavbarLink to={child.path}>
{t(item.id)} - {t(child.id)}
</NavbarLink>
</NavItemChild>
))}
</SubNav>
}
<SubNav
menu={item.children}
active={item.active}
path={item.path}
key={item.active ? `${item.id}-activated` : item.id}
>
<NavbarItem {...item} />
</Tippy>
{item.name}
</SubNav>
);
}
return <NavbarItem {...item} key={item.id} />;
return (
<NavbarItem active={item.active} path={item.path} key={item.id}>
{item.name}
</NavbarItem>
);
})}
{componentsInMoreMenu.length ? (
<SubNav menu={componentsInMoreMenu} showDropdownIcon>
{t('common:more')}
</SubNav>
) : null}
{componentsInInactiveMenu.length ? (
<SubNav menu={componentsInInactiveMenu} showDropdownIcon>
{t('common:inactive')}
</SubNav>
) : null}
</div>
<div className="right">
<Tippy
......@@ -290,9 +381,9 @@ const Navbar: FunctionComponent = () => {
hideOnClick={false}
role="menu"
content={
<SubNav>
<SubNavWrapper>
<ThemeToggle />
</SubNav>
</SubNavWrapper>
}
>
<NavItem className="nav-item">
......
......@@ -16,7 +16,7 @@
import Aside, {AsideSection} from '~/components/Aside';
import React, {FunctionComponent, useCallback, useMemo, useState} from 'react';
import {ellipsis, em, rem, size} from '~/utils/style';
import {asideWidth, ellipsis, em, rem, size} from '~/utils/style';
import Checkbox from '~/components/Checkbox';
import Field from '~/components/Field';
......@@ -26,8 +26,11 @@ import RunningToggle from '~/components/RunningToggle';
import SearchInput from '~/components/SearchInput';
import styled from 'styled-components';
import uniqBy from 'lodash/uniqBy';
import useLocalStorage from '~/hooks/useLocalStorage';
import {useTranslation} from 'react-i18next';
const SIDE_WIDTH_STORAGE_KEY = 'run_aside_width';
const StyledAside = styled(Aside)`
${AsideSection}.run-section {
flex: auto;
......@@ -137,8 +140,16 @@ const RunAside: FunctionComponent<RunAsideProps> = ({
[running, onToggleRunning]
);
const [width, setWidth] = useLocalStorage(SIDE_WIDTH_STORAGE_KEY);
return (
<StyledAside bottom={bottom}>
<StyledAside
bottom={bottom}
resizable="left"
width={width == null ? asideWidth : +width}
minWidth={260}
onResized={width => setWidth(width + '')}
>
{children}
<AsideSection className="run-section">
<Field className="run-select" label={t('common:select-runs')}>
......
......@@ -14,11 +14,18 @@
* limitations under the License.
*/
import routes, {Pages, Route} from '~/routes';
import {useCallback, useEffect, useState} from 'react';
import routes, {Pages} from '~/routes';
import {useCallback, useEffect, useMemo, useState} from 'react';
import type {Route} from '~/routes';
import ee from '~/utils/event';
import useRequest from '~/hooks/useRequest';
import {useTranslation} from 'react-i18next';
interface RouteWithName extends Route {
name: string;
children?: Pick<RouteWithName, 'id' | 'path' | 'component' | 'name'>[];
}
export const navMap = {
scalar: Pages.Scalar,
......@@ -29,11 +36,15 @@ export const navMap = {
graph: Pages.Graph,
embeddings: Pages.HighDimensional,
pr_curve: Pages.PRCurve,
roc_curve: Pages.ROCCurve
roc_curve: Pages.ROCCurve,
hyper_parameters: Pages.HyperParameter
} as const;
const useNavItems = () => {
const [components, setComponents] = useState<Route[]>([]);
const useAvailableComponents = () => {
const {t} = useTranslation('common');
const [components, setComponents] = useState<RouteWithName[]>([]);
const [inactiveComponents, setInactiveComponents] = useState<RouteWithName[]>([]);
const {data, loading, error, mutate} = useRequest<(keyof typeof navMap)[]>('/components', {
refreshInterval: components.length ? 61 * 1000 : 15 * 1000,
......@@ -53,32 +64,55 @@ const useNavItems = () => {
};
}, [mutate]);
const filterPages = useCallback(
(pages: Route[]) => {
const items: string[] = data?.map(item => navMap[item]) ?? [];
return pages.reduce<Route[]>((m, page) => {
if (page.children) {
const children = filterPages(page.children);
if (children.length) {
const filterRoutes = useCallback(
(pages: Route[], filter: (page: Route) => boolean) => {
const iterator = (pages: Route[], parent?: Route) => {
const parentName = parent ? t(parent.id) + ' - ' : '';
return pages.reduce<RouteWithName[]>((m, page) => {
const name = parentName + t(page.id);
if (page.children) {
const children = iterator(page.children, page);
if (children.length) {
m.push({
...page,
name,
children: children as RouteWithName['children']
});
}
} else if (page.visible !== false && filter(page)) {
m.push({
...page,
children: children as Route['children']
name,
children: undefined
});
}
} else if (page.visible !== false && items.includes(page.id)) {
m.push(page);
}
return m;
}, []);
return m;
}, []);
};
return iterator(pages);
},
[data]
[t]
);
const legalAvailableComponentIdArray: string[] = useMemo(() => data?.map(item => navMap[item]) ?? [], [data]);
const findAvailableComponents = useCallback(
(pages: Route[]) => filterRoutes(pages, page => legalAvailableComponentIdArray.includes(page.id)),
[filterRoutes, legalAvailableComponentIdArray]
);
const findInactiveComponents = useCallback(
(pages: Route[]) => filterRoutes(pages, page => !legalAvailableComponentIdArray.includes(page.id)),
[filterRoutes, legalAvailableComponentIdArray]
);
useEffect(() => {
setComponents(findAvailableComponents(routes));
}, [findAvailableComponents]);
useEffect(() => {
setComponents(filterPages(routes));
}, [filterPages]);
setInactiveComponents(findInactiveComponents(routes));
}, [findInactiveComponents]);
return [components, loading, error] as const;
return [components, inactiveComponents, loading, error] as const;
};
export default useNavItems;
export default useAvailableComponents;
......@@ -21,7 +21,7 @@ import {useHistory, useLocation} from 'react-router-dom';
import Error from '~/components/Error';
import HashLoader from 'react-spinners/HashLoader';
import styled from 'styled-components';
import useNavItems from '~/hooks/useNavItems';
import useAvailableComponents from '~/hooks/useAvailableComponents';
import {useTranslation} from 'react-i18next';
const CenterWrapper = styled.div`
......@@ -40,7 +40,7 @@ const Loading = styled.div`
`;
const IndexPage: FunctionComponent = () => {
const [navItems, loading] = useNavItems();
const [components, , loading] = useAvailableComponents();
const history = useHistory();
const {t} = useTranslation('common');
......@@ -48,18 +48,20 @@ const IndexPage: FunctionComponent = () => {
const location = useLocation();
useEffect(() => {
if (navItems.length) {
if (navItems[0].path) {
history.replace(navItems[0].path + location.search);
} else if (navItems[0].children?.length && navItems[0].children[0].path) {
history.replace(navItems[0].children[0].path + location.search);
if (components.length) {
if (components[0].path) {
history.replace(components[0].path + location.search);
} else if (components[0].children?.length && components[0].children[0].path) {
history.replace(components[0].children[0].path + location.search);
}
} else {
// TODO: no component available, add a error tip
}
}, [navItems, history, location.search]);
}, [components, history, location.search]);
return (
<CenterWrapper>
{loading || navItems.length ? (
{loading || components.length ? (
<Loading>
<HashLoader size="60px" color={primaryColor} />
<span>{t('common:loading')}</span>
......
......@@ -14,7 +14,9 @@
* limitations under the License.
*/
import React, {FunctionComponent, LazyExoticComponent} from 'react';
import type {FunctionComponent, LazyExoticComponent} from 'react';
import React from 'react';
export enum Pages {
Scalar = 'scalar',
......@@ -25,7 +27,8 @@ export enum Pages {
Graph = 'graph',
HighDimensional = 'high-dimensional',
PRCurve = 'pr-curve',
ROCCurve = 'roc-curve'
ROCCurve = 'roc-curve',
HyperParameter = 'hyper-parameter'
}
export interface Route {
......
......@@ -14,4 +14,5 @@
* limitations under the License.
*/
export default ['embeddings', 'scalar', 'image', 'audio', 'text', 'graph', 'histogram', 'pr_curve', 'roc_curve'];
export default ['embeddings', 'scalar', 'image', 'text', 'graph', 'pr_curve', 'roc_curve'];
// export default ['embeddings', 'scalar', 'image', 'audio', 'text', 'graph', 'histogram', 'pr_curve', 'roc_curve'];
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册