feat(sidebar): implement new design [EE-3447] (#7118)
After Width: | Height: | Size: 2.9 KiB |
After Width: | Height: | Size: 5.4 KiB |
After Width: | Height: | Size: 2.9 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 3.3 KiB |
@ -0,0 +1,20 @@
|
||||
import { getPlatformType } from '@/portainer/environments/utils';
|
||||
import { EnvironmentType, PlatformType } from '@/portainer/environments/types';
|
||||
|
||||
import Docker from './docker.svg?c';
|
||||
import Azure from './azure.svg?c';
|
||||
import Kubernetes from './kubernetes.svg?c';
|
||||
|
||||
const icons: {
|
||||
[key in PlatformType]: SvgrComponent;
|
||||
} = {
|
||||
[PlatformType.Docker]: Docker,
|
||||
[PlatformType.Kubernetes]: Kubernetes,
|
||||
[PlatformType.Azure]: Azure,
|
||||
};
|
||||
|
||||
export function getPlatformIcon(type: EnvironmentType) {
|
||||
const platform = getPlatformType(type);
|
||||
|
||||
return icons[platform];
|
||||
}
|
After Width: | Height: | Size: 3.5 KiB |
After Width: | Height: | Size: 11 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 376 B |
@ -1,29 +1,15 @@
|
||||
import { Box, Clock, Grid, Layers } from 'react-feather';
|
||||
|
||||
import { SidebarItem } from './SidebarItem';
|
||||
import { SidebarSection } from './SidebarSection';
|
||||
|
||||
export function EdgeComputeSidebar() {
|
||||
return (
|
||||
<SidebarSection title="Edge compute">
|
||||
<SidebarItem
|
||||
to="edge.devices"
|
||||
iconClass="fas fa-laptop-code fa-fw"
|
||||
label="Edge Devices"
|
||||
/>
|
||||
<SidebarItem
|
||||
to="edge.groups"
|
||||
iconClass="fa-object-group fa-fw"
|
||||
label="Edge Groups"
|
||||
/>
|
||||
<SidebarItem
|
||||
to="edge.stacks"
|
||||
iconClass="fa-layer-group fa-fw"
|
||||
label="Edge Stacks"
|
||||
/>
|
||||
<SidebarItem
|
||||
to="edge.jobs"
|
||||
iconClass="fa-clock fa-fw"
|
||||
label="Edge Jobs"
|
||||
/>
|
||||
<SidebarItem to="edge.devices" label="Edge Devices" icon={Box} />
|
||||
<SidebarItem to="edge.groups" label="Edge Groups" icon={Grid} />
|
||||
<SidebarItem to="edge.stacks" label="Edge Stacks" icon={Layers} />
|
||||
<SidebarItem to="edge.jobs" label="Edge Jobs" icon={Clock} />
|
||||
</SidebarSection>
|
||||
);
|
||||
}
|
||||
|
@ -1,5 +0,0 @@
|
||||
.title {
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
text-indent: 0;
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
.root {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: initial;
|
||||
float: right;
|
||||
padding-right: 28px;
|
||||
line-height: 60px;
|
||||
}
|
||||
|
||||
.root {
|
||||
height: 60px;
|
||||
list-style: none;
|
||||
text-indent: 20px;
|
||||
font-size: 18px;
|
||||
background: var(--bg-sidebar-header-color);
|
||||
}
|
||||
|
||||
.root a {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.root a:hover {
|
||||
text-decoration: none;
|
||||
}
|
@ -1,56 +1,43 @@
|
||||
:global(#page-wrapper.open) .root {
|
||||
left: 150px;
|
||||
}
|
||||
|
||||
.root {
|
||||
margin-left: -150px;
|
||||
left: -30px;
|
||||
width: 250px;
|
||||
position: fixed;
|
||||
height: 100%;
|
||||
z-index: 999;
|
||||
:global(#page-wrapper) {
|
||||
padding-left: var(--sidebar-closed-width);
|
||||
transition: all 0.4s ease 0s;
|
||||
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.root {
|
||||
background-color: var(--blue-5);
|
||||
@media only screen and (min-width: 561px) {
|
||||
:global(#page-wrapper.open) {
|
||||
padding-left: var(--sidebar-width);
|
||||
}
|
||||
}
|
||||
:global(:root[theme='dark']) .root {
|
||||
background-color: var(--grey-1);
|
||||
@media only screen and (max-width: 560px) {
|
||||
:global(#page-wrapper.open) {
|
||||
padding-left: var(--sidebar-closed-width);
|
||||
}
|
||||
}
|
||||
:global(:root[theme='highcontrast']) .root {
|
||||
background-color: var(--black-color);
|
||||
}
|
||||
:global(:root[data-edition='BE']) .root {
|
||||
background-color: var(--grey-5);
|
||||
|
||||
:global(#page-wrapper) {
|
||||
--sidebar-width: 300px;
|
||||
--sidebar-closed-width: 75px;
|
||||
}
|
||||
:global(:root[data-edition='BE'][theme='dark']) .root {
|
||||
background-color: var(--grey-1);
|
||||
|
||||
:global(#page-wrapper.open) .root {
|
||||
width: var(--sidebar-width);
|
||||
}
|
||||
:global(:root[data-edition='BE'][theme='highcontrast']) .root {
|
||||
background-color: var(--black-color);
|
||||
|
||||
.root {
|
||||
width: var(--sidebar-closed-width);
|
||||
height: 100%;
|
||||
|
||||
position: fixed;
|
||||
left: 0;
|
||||
|
||||
z-index: 999;
|
||||
transition: all 0.4s ease 0s;
|
||||
}
|
||||
|
||||
ul.sidebar {
|
||||
.root ul {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
text-indent: 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
@ -0,0 +1,85 @@
|
||||
import {
|
||||
TransitionOptions,
|
||||
useCurrentStateAndParams,
|
||||
useSrefActive as useUiRouterSrefActive,
|
||||
} from '@uirouter/react';
|
||||
import clsx from 'clsx';
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { IconProps, Icon } from '@@/Icon';
|
||||
|
||||
import { useSidebarState } from '../useSidebarState';
|
||||
|
||||
interface Props extends IconProps, ComponentProps<typeof Link> {
|
||||
label: string;
|
||||
ignorePaths?: string[];
|
||||
}
|
||||
|
||||
export function Head({
|
||||
to,
|
||||
options,
|
||||
params = {},
|
||||
label,
|
||||
icon,
|
||||
ignorePaths = [],
|
||||
}: Props) {
|
||||
const { isOpen } = useSidebarState();
|
||||
const anchorProps = useSrefActive(
|
||||
to,
|
||||
'bg-blue-8 be:bg-gray-8',
|
||||
params,
|
||||
options,
|
||||
ignorePaths
|
||||
);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={anchorProps.href}
|
||||
onClick={anchorProps.onClick}
|
||||
title={label}
|
||||
className={clsx(
|
||||
anchorProps.className,
|
||||
'text-inherit no-underline hover:no-underline hover:text-inherit focus:no-underline focus:text-inherit w-full flex-1 rounded-md',
|
||||
{ 'px-3': isOpen }
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx('flex items-center h-8 space-x-4 text-sm', {
|
||||
'justify-start w-full': isOpen,
|
||||
'justify-center w-8': !isOpen,
|
||||
})}
|
||||
>
|
||||
{!!icon && (
|
||||
<Icon icon={icon} feather className={clsx('flex [&>svg]:w-4')} />
|
||||
)}
|
||||
{isOpen && <span>{label}</span>}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function useSrefActive(
|
||||
to: string,
|
||||
activeClassName: string,
|
||||
params: Partial<Record<string, string>> = {},
|
||||
options: TransitionOptions = {},
|
||||
ignorePaths: string[] = []
|
||||
) {
|
||||
const { state } = useCurrentStateAndParams();
|
||||
const anchorProps = useUiRouterSrefActive(
|
||||
to,
|
||||
params || {},
|
||||
activeClassName,
|
||||
options
|
||||
);
|
||||
|
||||
const className = ignorePaths.includes(state.name || '')
|
||||
? ''
|
||||
: anchorProps.className;
|
||||
|
||||
return {
|
||||
...anchorProps,
|
||||
className,
|
||||
};
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
.menu-icon {
|
||||
padding-right: 30px;
|
||||
width: 70px;
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
@media (max-height: 785px) {
|
||||
.menu-icon {
|
||||
line-height: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-height: 786px) and (max-height: 924px) {
|
||||
.menu-icon {
|
||||
line-height: 30px;
|
||||
}
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import styles from './Icon.module.css';
|
||||
|
||||
interface Props {
|
||||
iconClass: string;
|
||||
}
|
||||
|
||||
export function Icon({ iconClass }: Props) {
|
||||
return (
|
||||
<i
|
||||
role="img"
|
||||
className={clsx('fa', iconClass, styles.menuIcon)}
|
||||
aria-label="itemIcon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
a.active {
|
||||
color: #fff;
|
||||
border-left-color: var(--border-sidebar-color);
|
||||
}
|
||||
|
||||
a.active {
|
||||
background: var(--grey-37);
|
||||
}
|
||||
|
||||
:global(:root[theme='dark']) a.active {
|
||||
background-color: var(--grey-3);
|
||||
}
|
||||
|
||||
:global(:root[theme='highcontrast']) a.active {
|
||||
background-color: var(--black-color);
|
||||
}
|
||||
|
||||
:global(:root[data-edition='BE']) a.active {
|
||||
background-color: var(--grey-5);
|
||||
}
|
||||
|
||||
:global(:root[data-edition='BE'][theme='dark']) a.active {
|
||||
background-color: var(--grey-1);
|
||||
}
|
||||
|
||||
:global(:root[data-edition='BE'][theme='highcontrast']) a.active {
|
||||
background-color: var(--black-color);
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
import { UISrefActive } from '@uirouter/react';
|
||||
import { Children, ComponentProps, ReactNode } from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { Link as NavLink } from '@@/Link';
|
||||
|
||||
import styles from './Link.module.css';
|
||||
|
||||
interface Props extends ComponentProps<typeof NavLink> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Link({ children, to, options, params, title }: Props) {
|
||||
const label = title || getLabel(children);
|
||||
|
||||
return (
|
||||
<UISrefActive class={styles.active}>
|
||||
<NavLink
|
||||
to={to}
|
||||
params={params}
|
||||
className={styles.link}
|
||||
title={label}
|
||||
options={options}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
</UISrefActive>
|
||||
);
|
||||
}
|
||||
|
||||
function getLabel(children: ReactNode) {
|
||||
return _.first(
|
||||
_.compact(
|
||||
Children.map(children, (child) => {
|
||||
if (typeof child === 'string') {
|
||||
return child;
|
||||
}
|
||||
|
||||
return '';
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
.sidebar-menu-head {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-menu-head .sidebar-menu-indicator {
|
||||
background: none;
|
||||
border: 0;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
color: white;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 25%;
|
||||
}
|
||||
|
||||
.items {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.items > * {
|
||||
text-indent: 35px;
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
.sidebar-menu-item {
|
||||
text-indent: 25px;
|
||||
}
|
||||
|
||||
.sidebar-menu-item a {
|
||||
padding-left: 5px;
|
||||
border-left: 3px solid transparent;
|
||||
font-size: 14px;
|
||||
|
||||
line-height: 36px;
|
||||
letter-spacing: -0.03em;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
text-decoration: none;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.sidebar-menu-item a {
|
||||
color: var(--grey-56);
|
||||
}
|
||||
:global(:root[theme='dark']) .sidebar-menu-item a {
|
||||
color: var(--white-color);
|
||||
}
|
||||
:global(:root[theme='highcontrast']) .sidebar-menu-item a {
|
||||
color: var(--white-color);
|
||||
}
|
||||
:global(:root[data-edition='BE']) .sidebar-menu-item a {
|
||||
color: var(--white-color);
|
||||
}
|
||||
:global(:root[data-edition='BE'][theme='dark']) .sidebar-menu-item a {
|
||||
color: var(--white-color);
|
||||
}
|
||||
:global(:root[data-edition='BE'][theme='highcontrast']) .sidebar-menu-item a {
|
||||
color: var(--white-color);
|
||||
}
|
||||
|
||||
@media (max-height: 785px) {
|
||||
.sidebar-menu-item a {
|
||||
font-size: 12px;
|
||||
line-height: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-height: 786px) and (max-height: 924px) {
|
||||
.sidebar-menu-item a {
|
||||
font-size: 12px;
|
||||
line-height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-menu-item a:hover {
|
||||
color: #fff;
|
||||
border-left-color: #e99d1a;
|
||||
}
|
||||
|
||||
.sidebar-menu-item a:hover {
|
||||
background-color: var(--grey-37);
|
||||
}
|
||||
:global(:root[theme='dark']) .sidebar-menu-item a:hover {
|
||||
background-color: var(--grey-3);
|
||||
}
|
||||
:global(:root[theme='highcontrast']) .sidebar-menu-item a:hover {
|
||||
background-color: var(--black-color);
|
||||
}
|
||||
:global(:root[data-edition='BE']) .sidebar-menu-item a:hover {
|
||||
background-color: var(--grey-34);
|
||||
}
|
||||
:global(:root[data-edition='BE'][theme='dark']) .sidebar-menu-item a:hover {
|
||||
background-color: var(--grey-3);
|
||||
}
|
||||
:global(:root[data-edition='BE'][theme='highcontrast']) .sidebar-menu-item a:hover {
|
||||
background-color: var(--black-color);
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
import { render } from '@/react-tools/test-utils';
|
||||
|
||||
import { SidebarItem } from '.';
|
||||
|
||||
test('should be visible & have expected class', () => {
|
||||
const { getByRole, getByText } = renderComponent('Test', 'testClass');
|
||||
const listItem = getByRole('listitem');
|
||||
expect(listItem).toBeVisible();
|
||||
expect(listItem).toHaveClass('testClass');
|
||||
expect(getByText('Test')).toBeVisible();
|
||||
});
|
||||
|
||||
test('icon should with correct icon if iconClass is provided', () => {
|
||||
const { getByLabelText } = renderComponent('', '', 'testIconClass');
|
||||
const sidebarIcon = getByLabelText('itemIcon');
|
||||
expect(sidebarIcon).toBeVisible();
|
||||
expect(sidebarIcon).toHaveClass('testIconClass');
|
||||
});
|
||||
|
||||
test('icon should not be rendered if iconClass is not provided', () => {
|
||||
const { queryByRole } = renderComponent();
|
||||
expect(queryByRole('img')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
function renderComponent(label = '', className = '', iconClass = '') {
|
||||
return render(
|
||||
<SidebarItem.Wrapper className={className}>
|
||||
<SidebarItem.Link to="" params={{ endpointId: 1 }}>
|
||||
{label}
|
||||
</SidebarItem.Link>
|
||||
<SidebarItem.Icon iconClass={iconClass} />
|
||||
</SidebarItem.Wrapper>
|
||||
);
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import { ReactNode, ReactElement, Children } from 'react';
|
||||
|
||||
function isReactElement(element: ReactNode): element is ReactElement {
|
||||
return (
|
||||
!!element &&
|
||||
typeof element === 'object' &&
|
||||
'type' in element &&
|
||||
'props' in element
|
||||
);
|
||||
}
|
||||
|
||||
export function getPathsForChildren(children: ReactNode): string[] {
|
||||
return Children.map(children, (child) => getPaths(child, []))?.flat() || [];
|
||||
}
|
||||
|
||||
export function getPaths(element: ReactNode, paths: string[]): string[] {
|
||||
if (!isReactElement(element)) {
|
||||
return paths;
|
||||
}
|
||||
|
||||
if (typeof element.props.to === 'undefined') {
|
||||
return Children.map(element.props.children, (child) =>
|
||||
getPaths(child, paths)
|
||||
);
|
||||
}
|
||||
|
||||
return [element.props.to, ...paths];
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
.sidebar-title {
|
||||
color: var(--text-sidebar-title-color);
|
||||
font-size: 12px;
|
||||
height: 35px;
|
||||
line-height: 40px;
|
||||
text-transform: uppercase;
|
||||
transition: all 0.6s ease 0s;
|
||||
}
|
||||
|
||||
#page-wrapper:not(.open) .sidebar-title {
|
||||
display: none;
|
||||
height: 0px;
|
||||
text-indent: -100px;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
@media (max-height: 785px) {
|
||||
.sidebar-title {
|
||||
line-height: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-height: 786px) and (max-height: 924px) {
|
||||
.sidebar-title {
|
||||
line-height: 30px;
|
||||
}
|
||||
}
|
@ -1,26 +1,30 @@
|
||||
import { PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
import styles from './SidebarSection.module.css';
|
||||
import { useSidebarState } from './useSidebarState';
|
||||
|
||||
interface Props {
|
||||
title: ReactNode;
|
||||
label?: string;
|
||||
title: string;
|
||||
renderTitle?: (className: string) => ReactNode;
|
||||
}
|
||||
|
||||
export function SidebarSection({
|
||||
title,
|
||||
label,
|
||||
renderTitle,
|
||||
children,
|
||||
}: PropsWithChildren<Props>) {
|
||||
const labelText = typeof title === 'string' ? title : label;
|
||||
const { isOpen } = useSidebarState();
|
||||
const titleClassName =
|
||||
'ml-3 text-sm text-gray-3 be:text-gray-6 transition-all duration-500 ease-in-out';
|
||||
|
||||
return (
|
||||
<>
|
||||
<li className={styles.sidebarTitle}>{title}</li>
|
||||
<div>
|
||||
{renderTitle
|
||||
? renderTitle(titleClassName)
|
||||
: isOpen && <li className={titleClassName}>{title}</li>}
|
||||
|
||||
<nav aria-label={labelText}>
|
||||
<nav aria-label={title} className="mt-4">
|
||||
<ul>{children}</ul>
|
||||
</nav>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,21 @@
|
||||
import { Layout } from 'react-feather';
|
||||
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
|
||||
import { SidebarItem } from '../SidebarItem';
|
||||
|
||||
interface Props {
|
||||
environmentId: EnvironmentId;
|
||||
platformPath: string;
|
||||
}
|
||||
|
||||
export function DashboardLink({ environmentId, platformPath }: Props) {
|
||||
return (
|
||||
<SidebarItem
|
||||
to={`${platformPath}.dashboard`}
|
||||
params={{ endpointId: environmentId }}
|
||||
icon={Layout}
|
||||
label="Dashboard"
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
import { Database } from 'react-feather';
|
||||
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
|
||||
import { SidebarItem } from '../SidebarItem';
|
||||
|
||||
interface Props {
|
||||
environmentId: EnvironmentId;
|
||||
platformPath: string;
|
||||
}
|
||||
|
||||
export function VolumesLink({ environmentId, platformPath }: Props) {
|
||||
return (
|
||||
<SidebarItem
|
||||
to={`${platformPath}.volumes`}
|
||||
params={{ endpointId: environmentId }}
|
||||
icon={Database}
|
||||
label="Volumes"
|
||||
/>
|
||||
);
|
||||
}
|