feat(sidebar): implement new design [EE-3447] (#7118)

pull/7139/head
Chaim Lev-Ari 2 years ago committed by GitHub
parent e5e57978af
commit ed8f9b5931
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -2,8 +2,27 @@
@tailwind components;
@tailwind utilities;
@font-face {
font-family: 'Inter';
src: url('../fonts/Inter-VariableFont.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
}
@media screen and (-webkit-min-device-pixel-ratio: 0) {
select {
font-family: Inter, Arial, Helvetica, sans-serif;
}
}
html {
font-size: 16px;
overflow-y: scroll;
}
body {
background: var(--bg-body-color);
font-family: 'Inter';
color: var(--text-body-color) !important;
}
html,

@ -4,16 +4,6 @@
width: 100%;
height: auto;
}
@media only screen and (min-width: 561px) {
#page-wrapper.open {
padding-left: 250px;
}
}
@media only screen and (max-width: 560px) {
#page-wrapper.open {
padding-left: 70px;
}
}
.loading {
width: 40px;
@ -59,31 +49,6 @@
}
}
/* Fonts */
@font-face {
font-family: 'Inter';
src: url('../fonts/Inter-VariableFont.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
@media screen and (-webkit-min-device-pixel-ratio: 0) {
@font-face {
font-family: 'Inter';
src: url('../fonts/Inter-VariableFont.ttf') format('truetype');
}
select {
font-family: Inter, Arial, Helvetica, sans-serif;
}
}
/* Base */
html {
overflow-y: scroll;
}
body {
background: var(--bg-body-color);
font-family: 'Inter';
color: var(--text-body-color) !important;
}
.row {
margin-left: 0 !important;
margin-right: 0 !important;
@ -94,14 +59,7 @@ body {
.alerts-container .alert:last-child {
margin-bottom: 0;
}
#page-wrapper {
padding-left: 70px;
height: 100%;
}
#page-wrapper {
transition: all 0.4s ease 0s;
}
.green {
background: #23ae89 !important;
}

@ -7,7 +7,6 @@
--grey-2: #181818;
--grey-3: #383838;
--grey-4: #585858;
--grey-5: #323c48;
--grey-6: #333333;
--grey-7: #767676;
--grey-8: #aaa;
@ -35,7 +34,6 @@
--grey-30: #444;
--grey-31: #868686;
--grey-32: #65798e;
--grey-34: #314252;
--grey-35: #546477;
--grey-36: #55637d;
--grey-37: #2d3e63;
@ -56,11 +54,9 @@
--grey-53: rgba(255, 255, 255, 0.6);
--grey-54: rgb(54, 54, 54);
--grey-55: rgba(255, 255, 255, 0.8);
--grey-56: #b2bfdc;
--grey-57: #999;
--grey-58: #ebf4f8;
--grey-59: #e6e6e6;
--grey-60: #cacaca;
--grey-61: rgb(231, 231, 231);
--blue-1: #219;

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

17
app/global.d.ts vendored

@ -5,6 +5,13 @@ declare module '*.png' {
export default '' as string;
}
type SvgrComponent = React.StatelessComponent<React.SVGAttributes<SVGElement>>;
declare module '*.svg?c' {
const value: SvgrComponent;
export default value;
}
declare module '*.css';
declare module '@open-amt-cloud-toolkit/ui-toolkit-react/reactjs/src/kvm.bundle';
@ -25,3 +32,13 @@ interface Window {
*/
ddExtension?: boolean;
}
declare module 'process' {
global {
namespace NodeJS {
interface ProcessEnv {
PORTAINER_EDITION: 'BE' | 'CE';
}
}
}
}

@ -1,12 +1,16 @@
import { PropsWithChildren } from 'react';
import { Menu, MenuButton, MenuList, MenuLink } from '@reach/menu-button';
import {
Menu,
MenuButton,
MenuList,
MenuLink as ReachMenuLink,
} from '@reach/menu-button';
import clsx from 'clsx';
import { User, ChevronDown } from 'react-feather';
import { useSref } from '@uirouter/react';
import { useUser } from '@/portainer/hooks/useUser';
import { Link } from '@@/Link';
import { useHeaderContext } from './HeaderContainer';
import styles from './HeaderTitle.module.css';
@ -28,19 +32,34 @@ export function HeaderTitle({ title, children }: PropsWithChildren<Props>) {
{user && <span>{user.Username}</span>}
<ChevronDown className="feather" />
</MenuButton>
<MenuList className={styles.menuList}>
<MenuLink
className={styles.menuLink}
as={Link}
to="portainer.account"
>
My account
</MenuLink>
<MenuLink className={styles.menuLink} as={Link} to="portainer.logout">
Log out
</MenuLink>
{!window.ddExtension && (
<MenuLink to="portainer.account" label="My account" />
)}
<MenuLink to="portainer.logout" label="Log out" />
</MenuList>
</Menu>
</div>
);
}
interface MenuLinkProps {
to: string;
label: string;
}
function MenuLink({ to, label }: MenuLinkProps) {
const anchorProps = useSref(to);
return (
<ReachMenuLink
href={anchorProps.href}
onClick={anchorProps.onClick}
className={styles.menuLink}
>
{label}
</ReachMenuLink>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

@ -0,0 +1,23 @@
<svg width="105" height="95" viewBox="0 0 105 95" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M40.0412 11.1924H62.0164L39.2057 80.2579C38.9712 80.9682 38.5252 81.5844 37.9284 82.0209C37.3338 82.4574 36.6177 82.6928 35.8848 82.6928H18.7841C18.2293 82.6928 17.6807 82.558 17.1865 82.2991C16.6923 82.0402 16.2652 81.6636 15.9406 81.2036C15.6161 80.7435 15.4046 80.2086 15.3229 79.6481C15.2413 79.0875 15.2894 78.514 15.4674 77.9749L36.7224 13.6295C36.9569 12.9191 37.4029 12.3029 37.9996 11.8664C38.5943 11.4299 39.3104 11.1946 40.0433 11.1946L40.0412 11.1924Z" fill="url(#paint0_linear_142_1882)"/>
<path d="M71.9753 57.5178H37.129C36.8044 57.5178 36.4883 57.6163 36.2203 57.8024C35.9522 57.9886 35.7449 58.2539 35.6277 58.5598C35.5083 58.8679 35.4832 59.206 35.5544 59.527C35.6256 59.85 35.791 60.1432 36.0276 60.3678L58.4195 81.7236C59.0707 82.3462 59.9292 82.6907 60.8212 82.6907H80.5518L71.9753 57.5135V57.5178Z" fill="#0078D4"/>
<path d="M40.0424 11.1925C39.3012 11.1903 38.5788 11.43 37.9799 11.875C37.3811 12.3222 36.9372 12.9512 36.7152 13.6744L15.4959 77.9193C15.3053 78.4585 15.2467 79.0383 15.3221 79.6053C15.3974 80.1744 15.6068 80.7158 15.9293 81.1843C16.2538 81.6529 16.6831 82.0359 17.1814 82.2991C17.6798 82.5623 18.2326 82.6992 18.7937 82.6971H36.3362C36.9895 82.5773 37.6009 82.2841 38.1056 81.8434C38.6123 81.4047 38.9934 80.8356 39.2132 80.1958L43.4449 67.4543L58.5607 81.8605C59.193 82.3954 59.9887 82.6928 60.8116 82.6992H80.4689L71.8463 57.5221L46.7135 57.5285L62.0972 11.1925H40.0424Z" fill="url(#paint1_linear_142_1882)"/>
<path d="M68.1089 13.6252C67.8743 12.917 67.4283 12.3008 66.8337 11.8643C66.239 11.4278 65.525 11.1924 64.7942 11.1924H40.3043C41.0371 11.1924 41.749 11.4278 42.3437 11.8643C42.9384 12.3008 43.3844 12.917 43.6189 13.6252L64.8738 77.977C65.0518 78.5141 65.102 79.0875 65.0183 79.6502C64.9366 80.2108 64.7251 80.7457 64.4006 81.2057C64.076 81.6658 63.651 82.0423 63.1547 82.3012C62.6606 82.5601 62.1141 82.6949 61.5571 82.6949H86.0471C86.602 82.6949 87.1506 82.5601 87.6447 82.3012C88.1389 82.0423 88.566 81.6658 88.8885 81.2057C89.213 80.7457 89.4245 80.2108 89.5062 79.6502C89.5878 79.0896 89.5397 78.5162 89.3617 77.977L68.1089 13.6252Z" fill="url(#paint2_linear_142_1882)"/>
<defs>
<linearGradient id="paint0_linear_142_1882" x1="48.0523" y1="16.4923" x2="24.3284" y2="85.0728" gradientUnits="userSpaceOnUse">
<stop stop-color="#114A8B"/>
<stop offset="1" stop-color="#0669BC"/>
</linearGradient>
<linearGradient id="paint1_linear_142_1882" x1="55.179" y1="48.5976" x2="49.8774" y2="50.3511" gradientUnits="userSpaceOnUse">
<stop stop-opacity="0.3"/>
<stop offset="0.07" stop-opacity="0.2"/>
<stop offset="0.32" stop-opacity="0.1"/>
<stop offset="0.62" stop-opacity="0.05"/>
<stop offset="1" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint2_linear_142_1882" x1="52.4404" y1="14.419" x2="78.4565" y2="82.2478" gradientUnits="userSpaceOnUse">
<stop stop-color="#3CCBF4"/>
<stop offset="1" stop-color="#2892DF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@ -0,0 +1,12 @@
<svg width="105" height="95" viewBox="0 0 105 95" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M102.014 39.3373C101.744 39.1083 99.3192 37.2276 94.1117 37.2276C92.7654 37.2276 91.3729 37.3645 90.0266 37.5956C89.0382 30.5754 83.3366 27.1798 83.1126 26.998L81.7201 26.1721L80.8219 27.5029C79.6996 29.2917 78.8473 31.3115 78.3532 33.3762C77.4109 37.3688 77.993 41.1303 79.9697 44.3419C77.5889 45.7177 73.7278 46.0386 72.9196 46.0857H9.16103C7.50059 46.0857 6.15213 47.4615 6.15213 49.1604C6.0621 54.8497 7.00434 60.539 8.9349 65.9073C11.1356 71.7806 14.4125 76.1391 18.6337 78.8008C23.3931 81.7835 31.1614 83.4802 39.9159 83.4802C43.867 83.4802 47.8182 83.1122 51.7254 82.3783C57.159 81.3684 62.3664 79.4427 67.1719 76.644C71.123 74.3033 74.6701 71.3228 77.679 67.8351C82.7524 62.0089 85.7613 55.4937 87.962 49.7124H88.8603C94.3839 49.7124 97.7948 47.4636 99.6814 45.5379C100.938 44.344 101.882 42.8762 102.554 41.2244L102.958 40.0305L102.016 39.3415L102.014 39.3373Z" fill="#0091E2"/>
<path d="M15.0862 44.2006H23.6166C24.0207 44.2006 24.3809 43.8797 24.3809 43.4197V35.6207C24.3809 35.2077 24.0668 34.8419 23.6166 34.8419H15.0862C14.682 34.8419 14.3219 35.1628 14.3219 35.6207V43.4197C14.3659 43.8776 14.682 44.2006 15.0862 44.2006Z" fill="#0091E2"/>
<path d="M26.851 44.2006H35.3814C35.7856 44.2006 36.1436 43.8797 36.1436 43.4197V35.6207C36.1436 35.2077 35.8295 34.8419 35.3814 34.8419H26.851C26.4469 34.8419 26.0867 35.1628 26.0867 35.6207V43.4197C26.1307 43.8776 26.4469 44.2006 26.851 44.2006Z" fill="#0091E2"/>
<path d="M38.842 44.2006H47.3724C47.7765 44.2006 48.1367 43.8797 48.1367 43.4197V35.6207C48.1367 35.2077 47.8226 34.8419 47.3724 34.8419H38.842C38.4378 34.8419 38.0777 35.1628 38.0777 35.6207V43.4197C38.0777 43.8776 38.3918 44.2006 38.842 44.2006Z" fill="#0091E2"/>
<path d="M50.6474 44.2006H59.1778C59.582 44.2006 59.9421 43.8797 59.9421 43.4197V35.6207C59.9421 35.2077 59.628 34.8419 59.1778 34.8419H50.6474C50.2433 34.8419 49.8831 35.1628 49.8831 35.6207V43.4197C49.8831 43.8776 50.2433 44.2006 50.6474 44.2006Z" fill="#0091E2"/>
<path d="M26.851 33.0509H35.3814C35.7856 33.0509 36.1436 32.6829 36.1436 32.27V24.471C36.1436 24.058 35.8295 23.69 35.3814 23.69H26.851C26.4469 23.69 26.0867 24.0109 26.0867 24.471V32.27C26.1307 32.6829 26.4469 33.0509 26.851 33.0509Z" fill="#0091E2"/>
<path d="M38.842 33.0509H47.3724C47.7765 33.0509 48.1367 32.6829 48.1367 32.27V24.471C48.1367 24.058 47.8226 23.69 47.3724 23.69H38.842C38.4378 23.69 38.0777 24.0109 38.0777 24.471V32.27C38.0777 32.6829 38.3918 33.0509 38.842 33.0509Z" fill="#0091E2"/>
<path d="M50.6474 33.0509H59.1778C59.582 33.0509 59.9421 32.6829 59.9421 32.27V24.471C59.9421 24.058 59.582 23.69 59.1778 23.69H50.6474C50.2433 23.69 49.8831 24.0109 49.8831 24.471V32.27C49.8831 32.6829 50.2433 33.0509 50.6474 33.0509Z" fill="#0091E2"/>
<path d="M50.6474 21.8563H59.1778C59.582 21.8563 59.9421 21.5354 59.9421 21.0754V13.2764C59.9421 12.8634 59.582 12.4954 59.1778 12.4954H50.6474C50.2433 12.4954 49.8831 12.8164 49.8831 13.2764V21.0754C49.8831 21.4883 50.2433 21.8563 50.6474 21.8563Z" fill="#0091E2"/>
<path d="M62.5514 44.2006H71.0819C71.486 44.2006 71.8461 43.8797 71.8461 43.4197V35.6207C71.8461 35.2077 71.532 34.8419 71.0819 34.8419H62.5514C62.1473 34.8419 61.7871 35.1628 61.7871 35.6207V43.4197C61.8311 43.8776 62.1473 44.2006 62.5514 44.2006Z" fill="#0091E2"/>
</svg>

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];
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@ -0,0 +1,3 @@
<svg width="105" height="95" viewBox="0 0 105 95" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M52.3597 3.30634L14.061 25.3999V69.6L52.3338 91.6936L90.6325 69.6V25.3999L52.3597 3.30634ZM69.4448 51.9346L59.2468 57.8231L46.9177 51.0805V65.1697L35.3392 72.5162V43.0782L44.5386 37.4529L57.2883 44.1696V29.7915L69.4448 22.4837V51.9368V51.9346Z" fill="#00CA8E"/>
</svg>

After

Width:  |  Height:  |  Size: 376 B

@ -2,6 +2,8 @@ import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { render, within } from '@/react-tools/test-utils';
import { TestSidebarProvider } from '../useSidebarState';
import { AzureSidebar } from './AzureSidebar';
test('dashboard items should render correctly', () => {
@ -11,11 +13,9 @@ test('dashboard items should render correctly', () => {
expect(dashboardItem).toHaveTextContent('Dashboard');
const dashboardItemElements = within(dashboardItem);
expect(dashboardItemElements.getByLabelText('itemIcon')).toBeVisible();
expect(dashboardItemElements.getByLabelText('itemIcon')).toHaveClass(
'fa-tachometer-alt',
'fa-fw'
);
expect(
dashboardItemElements.getByRole('img', { hidden: true })
).toBeVisible();
const containerInstancesItem = getByLabelText(/Container Instances/i);
expect(containerInstancesItem).toBeVisible();
@ -23,12 +23,8 @@ test('dashboard items should render correctly', () => {
const containerInstancesItemElements = within(containerInstancesItem);
expect(
containerInstancesItemElements.getByLabelText('itemIcon')
containerInstancesItemElements.getByRole('img', { hidden: true })
).toBeVisible();
expect(containerInstancesItemElements.getByLabelText('itemIcon')).toHaveClass(
'fa-cubes',
'fa-fw'
);
});
function renderComponent() {
@ -36,7 +32,9 @@ function renderComponent() {
return render(
<UserContext.Provider value={{ user }}>
<AzureSidebar environmentId={1} />
<TestSidebarProvider>
<AzureSidebar environmentId={1} />
</TestSidebarProvider>
</UserContext.Provider>
);
}

@ -1,5 +1,8 @@
import { Box } from 'react-feather';
import { EnvironmentId } from '@/portainer/environments/types';
import { DashboardLink } from '../items/DashboardLink';
import { SidebarItem } from '../SidebarItem';
interface Props {
@ -9,16 +12,11 @@ interface Props {
export function AzureSidebar({ environmentId }: Props) {
return (
<>
<SidebarItem
to="azure.dashboard"
params={{ endpointId: environmentId }}
iconClass="fa-tachometer-alt fa-fw"
label="Dashboard"
/>
<DashboardLink environmentId={environmentId} platformPath="azure" />
<SidebarItem
to="azure.containerinstances"
params={{ endpointId: environmentId }}
iconClass="fa-cubes fa-fw"
icon={Box}
label="Container instances"
/>
</>

@ -1,3 +1,16 @@
import {
Box,
Clock,
Layers,
List,
Lock,
Share2,
Shuffle,
Trello,
Clipboard,
Edit,
} from 'react-feather';
import {
type Environment,
type EnvironmentId,
@ -11,6 +24,8 @@ import {
import { useInfo, useVersion } from '@/docker/services/system.service';
import { SidebarItem } from './SidebarItem';
import { DashboardLink } from './items/DashboardLink';
import { VolumesLink } from './items/VolumesLink';
interface Props {
environmentId: EnvironmentId;
@ -41,44 +56,37 @@ export function DockerSidebar({ environmentId, environment }: Props) {
const setupSubMenuProps = isSwarmManager
? {
label: 'Swarm',
iconClass: 'fa-object-group fa-fw',
icon: Trello,
to: 'docker.swarm',
}
: {
label: 'Host',
iconClass: 'fa-th fa-fw',
icon: Trello,
to: 'docker.host',
};
return (
<>
<DashboardLink environmentId={environmentId} platformPath="docker" />
<SidebarItem
to="docker.dashboard"
label="App Templates"
icon={Edit}
to="docker.templates"
params={{ endpointId: environmentId }}
iconClass="fa-tachometer-alt fa-fw"
label="Dashboard"
/>
{!offlineMode && (
>
<SidebarItem
label="App Templates"
iconClass="fa-rocket fa-fw"
to="docker.templates"
label="Custom Templates"
to="docker.templates.custom"
params={{ endpointId: environmentId }}
>
<SidebarItem
label="Custom Templates"
to="docker.templates.custom"
params={{ endpointId: environmentId }}
/>
</SidebarItem>
)}
/>
</SidebarItem>
{areStacksVisible && (
<SidebarItem
to="docker.stacks"
params={{ endpointId: environmentId }}
iconClass="fa-th-list fa-fw"
icon={Layers}
label="Stacks"
/>
)}
@ -87,7 +95,7 @@ export function DockerSidebar({ environmentId, environment }: Props) {
<SidebarItem
to="docker.services"
params={{ endpointId: environmentId }}
iconClass="fa-list-alt fa-fw"
icon={Shuffle}
label="Services"
/>
)}
@ -95,36 +103,31 @@ export function DockerSidebar({ environmentId, environment }: Props) {
<SidebarItem
to="docker.containers"
params={{ endpointId: environmentId }}
iconClass="fa-cubes fa-fw"
icon={Box}
label="Containers"
/>
<SidebarItem
to="docker.images"
params={{ endpointId: environmentId }}
iconClass="fa-clone fa-fw"
icon={List}
label="Images"
/>
<SidebarItem
to="docker.networks"
params={{ endpointId: environmentId }}
iconClass="fa-sitemap fa-fw"
icon={Share2}
label="Networks"
/>
<SidebarItem
to="docker.volumes"
params={{ endpointId: environmentId }}
iconClass="fa-hdd fa-fw"
label="Volumes"
/>
<VolumesLink environmentId={environmentId} platformPath="docker" />
{apiVersion >= 1.3 && isSwarmManager && (
<SidebarItem
to="docker.configs"
params={{ endpointId: environmentId }}
iconClass="fa-file-code fa-fw"
icon={Clipboard}
label="Configs"
/>
)}
@ -133,7 +136,7 @@ export function DockerSidebar({ environmentId, environment }: Props) {
<SidebarItem
to="docker.secrets"
params={{ endpointId: environmentId }}
iconClass="fa-user-secret fa-fw"
icon={Lock}
label="Secrets"
/>
)}
@ -142,14 +145,14 @@ export function DockerSidebar({ environmentId, environment }: Props) {
<SidebarItem
to="docker.events"
params={{ endpointId: environmentId }}
iconClass="fa-history fa-fw"
icon={Clock}
label="Events"
/>
)}
<SidebarItem
label={setupSubMenuProps.label}
iconClass={setupSubMenuProps.iconClass}
icon={setupSubMenuProps.icon}
to={setupSubMenuProps.to}
params={{ endpointId: environmentId }}
>

@ -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,5 +1,6 @@
import { useCurrentStateAndParams } from '@uirouter/react';
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
import { useEffect, useState } from 'react';
import { X } from 'react-feather';
import {
PlatformType,
@ -9,59 +10,61 @@ import {
import { getPlatformType } from '@/portainer/environments/utils';
import { useEnvironment } from '@/portainer/environments/queries/useEnvironment';
import { getPlatformIcon } from '../portainer/environments/utils/get-platform-icon';
import { AzureSidebar } from './AzureSidebar';
import { DockerSidebar } from './DockerSidebar';
import { KubernetesSidebar } from './KubernetesSidebar';
import { SidebarSection } from './SidebarSection';
import styles from './EnvironmentSidebar.module.css';
import { useSidebarState } from './useSidebarState';
export function EnvironmentSidebar() {
const currentEnvironmentQuery = useCurrentEnvironment();
const { query: currentEnvironmentQuery, clearEnvironment } =
useCurrentEnvironment();
const environment = currentEnvironmentQuery.data;
if (!environment) {
return null;
}
const platform = getPlatformType(environment.Type);
const sidebar = getSidebar(environment);
const Sidebar = getSidebar(platform);
return (
<SidebarSection
title={
<div className={styles.title}>
<i className="fa fa-plug space-right" />
{environment.Name}
</div>
}
label={PlatformType[platform]}
>
{sidebar}
</SidebarSection>
);
function getSidebar(environment: Environment) {
switch (platform) {
case PlatformType.Azure:
return <AzureSidebar environmentId={environment.Id} />;
case PlatformType.Docker:
return (
<DockerSidebar
environmentId={environment.Id}
<div className="rounded border border-dotted py-2 be:bg-gray-10 bg-blue-11 be:border-gray-8 border-blue-9">
<SidebarSection
title={PlatformType[platform]}
renderTitle={(className) => (
<Title
className={className}
environment={environment}
onClear={clearEnvironment}
/>
);
case PlatformType.Kubernetes:
return <KubernetesSidebar environmentId={environment.Id} />;
default:
return null;
}
)}
>
<Sidebar environmentId={environment.Id} environment={environment} />
</SidebarSection>
</div>
);
function getSidebar(platform: PlatformType) {
const sidebar: {
[key in PlatformType]: React.ComponentType<{
environmentId: EnvironmentId;
environment: Environment;
}>;
} = {
[PlatformType.Azure]: AzureSidebar,
[PlatformType.Docker]: DockerSidebar,
[PlatformType.Kubernetes]: KubernetesSidebar,
};
return sidebar[platform];
}
}
function useCurrentEnvironment() {
const { params } = useCurrentStateAndParams();
const router = useRouter();
const [environmentId, setEnvironmentId] = useState<EnvironmentId>();
useEffect(() => {
@ -71,5 +74,50 @@ function useCurrentEnvironment() {
}
}, [params.endpointId]);
return useEnvironment(environmentId);
return { query: useEnvironment(environmentId), clearEnvironment };
function clearEnvironment() {
if (params.endpointId) {
router.stateService.go('portainer.home');
}
setEnvironmentId(undefined);
}
}
interface TitleProps {
className: string;
environment: Environment;
onClear(): void;
}
function Title({ className, environment, onClear }: TitleProps) {
const { isOpen } = useSidebarState();
const EnvironmentIcon = getPlatformIcon(environment.Type);
if (!isOpen) {
return (
<li className="w-full flex justify-center" title={environment.Name}>
<EnvironmentIcon className="text-2xl" />
</li>
);
}
return (
<li className={className}>
<div className="flex items-center gap-2">
<span>Environment</span>
<EnvironmentIcon className="text-2xl" />
<span className="text-white">{environment.Name}</span>
<button
type="button"
onClick={onClear}
className="flex items-center justify-center be:bg-gray-9 bg-blue-10 rounded border-0 text-sm h-5 w-5 p-1 ml-auto mr-2 text-gray-5 be:text-gray-6 hover:text-white"
>
<X />
</button>
</div>
</li>
);
}

@ -1,28 +1,3 @@
:global(#page-wrapper:not(.open)) .root {
display: none;
}
.root {
text-align: center;
}
.logo {
display: inline;
width: 100%;
max-width: 100px;
height: 100%;
max-height: 35px;
margin: 2px 0 2px 20px;
}
.version {
font-size: 11px;
margin: 11px 20px 0 7px;
color: #fff;
}
.edition-version {
font-size: 10px;
margin-bottom: 8px;
color: #fff;
}

@ -1,7 +1,6 @@
import { useQuery } from 'react-query';
import clsx from 'clsx';
import smallLogo from '@/assets/images/logo_small.png';
import { getStatus } from '@/portainer/services/api/status.service';
import { UpdateNotification } from './UpdateNotifications';
@ -17,22 +16,23 @@ export function Footer() {
const { Edition, Version } = statusQuery.data;
return (
<div className={styles.root}>
<div className={clsx(styles.root, 'text-center')}>
{process.env.PORTAINER_EDITION === 'CE' && <UpdateNotification />}
<div>
<img
src={smallLogo}
className={clsx('img-responsive', styles.logo)}
alt="Portainer"
/>
<span
className={styles.version}
data-cy="portainerSidebar-versionNumber"
>
{Version}
</span>
{process.env.PORTAINER_EDITION !== 'CE' && (
<div className={styles.editionVersion}>{Edition}</div>
<div className="text-xs space-x-1 text-gray-5 be:text-gray-6">
<span>&copy;</span>
<span>Portainer {Edition}</span>
<span data-cy="portainerSidebar-versionNumber">{Version}</span>
{process.env.PORTAINER_EDITION === 'CE' && (
<a
href="https://www.portainer.io/install-BE-now"
className="text-blue-6 font-medium"
target="_blank"
rel="noreferrer"
>
Upgrade
</a>
)}
</div>
</div>

@ -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,8 +1,9 @@
import defaultLogo from '@/assets/images/logo.png';
import { ChevronsLeft, ChevronsRight } from 'react-feather';
import defaultLogo from '@/assets/images/logo_small_alt.png';
import { Link } from '@@/Link';
import styles from './Header.module.css';
import { useSidebarState } from './useSidebarState';
interface Props {
@ -10,28 +11,32 @@ interface Props {
}
export function Header({ logo }: Props) {
const { toggle } = useSidebarState();
const { toggle, isOpen } = useSidebarState();
return (
<div className={styles.root}>
<Link to="portainer.home" data-cy="portainerSidebar-homeImage">
<div className="flex justify-between items-center">
<Link
to="portainer.home"
data-cy="portainerSidebar-homeImage"
className="text-2xl text-white no-underline hover:no-underline hover:text-white"
>
<img
src={logo || defaultLogo}
className="img-responsive logo"
alt={!logo ? 'Portainer' : ''}
alt={!logo ? 'portainer.io' : 'Logo'}
/>
{isOpen && 'portainer.io'}
</Link>
{toggle && (
<button
type="button"
onClick={() => toggle()}
className={styles.toggleButton}
aria-label="Toggle Sidebar"
title="Toggle Sidebar"
>
<i className="glyphicon glyphicon-transfer" />
</button>
)}
<button
type="button"
onClick={() => toggle()}
className="w-6 h-6 flex justify-center items-center text-gray-4 be:text-gray-5 border-0 rounded text-sm be:bg-gray-10 bg-blue-11 hover:text-white be:hover:text-white"
aria-label="Toggle Sidebar"
title="Toggle Sidebar"
>
{isOpen ? <ChevronsLeft /> : <ChevronsRight />}
</button>
</div>
);
}

@ -1,7 +1,12 @@
import { Box, Edit, Layers, Loader, Lock, Server } from 'react-feather';
import { EnvironmentId } from '@/portainer/environments/types';
import { Authorized } from '@/portainer/hooks/useUser';
import { DashboardLink } from '../items/DashboardLink';
import { SidebarItem } from '../SidebarItem';
import { VolumesLink } from '../items/VolumesLink';
import { useSidebarState } from '../useSidebarState';
import { KubectlShellButton } from './KubectlShell';
@ -10,28 +15,25 @@ interface Props {
}
export function KubernetesSidebar({ environmentId }: Props) {
const { isOpen } = useSidebarState();
return (
<>
<KubectlShellButton environmentId={environmentId} />
{isOpen && <KubectlShellButton environmentId={environmentId} />}
<SidebarItem
to="kubernetes.dashboard"
params={{ endpointId: environmentId }}
iconClass="fa-tachometer-alt fa-fw"
label="Dashboard"
/>
<DashboardLink environmentId={environmentId} platformPath="kubernetes" />
<SidebarItem
to="kubernetes.templates.custom"
params={{ endpointId: environmentId }}
iconClass="fa-rocket fa-fw"
icon={Edit}
label="Custom Templates"
/>
<SidebarItem
to="kubernetes.resourcePools"
params={{ endpointId: environmentId }}
iconClass="fa-layer-group fa-fw"
icon={Layers}
label="Namespaces"
/>
@ -39,7 +41,7 @@ export function KubernetesSidebar({ environmentId }: Props) {
<SidebarItem
to="kubernetes.templates.helm"
params={{ endpointId: environmentId }}
iconClass="fa-dharmachakra fa-fw"
icon={Loader}
label="Helm"
/>
</Authorized>
@ -47,28 +49,23 @@ export function KubernetesSidebar({ environmentId }: Props) {
<SidebarItem
to="kubernetes.applications"
params={{ endpointId: environmentId }}
iconClass="fa-laptop-code fa-fw"
icon={Box}
label="Applications"
/>
<SidebarItem
to="kubernetes.configurations"
params={{ endpointId: environmentId }}
iconClass="fa-file-code fa-fw"
icon={Lock}
label="ConfigMaps & Secrets"
/>
<SidebarItem
to="kubernetes.volumes"
params={{ endpointId: environmentId }}
iconClass="fa-database fa-fw"
label="Volumes"
/>
<VolumesLink environmentId={environmentId} platformPath="kubernetes" />
<SidebarItem
iconClass="fa-server fa-fw"
label="Cluster"
to="kubernetes.cluster"
icon={Server}
params={{ endpointId: environmentId }}
>
<Authorized authorizations="K8sClusterSetupRW" adminOnlyCE>

@ -1,3 +1,12 @@
import {
Users,
Award,
Settings,
HardDrive,
Radio,
FileText,
} from 'react-feather';
import { usePublicSettings } from '@/portainer/settings/queries';
import { SidebarItem } from './SidebarItem';
@ -18,11 +27,7 @@ export function SettingsSidebar({ isAdmin }: Props) {
return (
<SidebarSection title="Settings">
{showUsersSection && (
<SidebarItem
to="portainer.users"
label="Users"
iconClass="fa-users fa-fw"
>
<SidebarItem to="portainer.users" label="Users" icon={Users}>
<SidebarItem to="portainer.teams" label="Teams" />
{isAdmin && <SidebarItem to="portainer.roles" label="Roles" />}
@ -33,7 +38,7 @@ export function SettingsSidebar({ isAdmin }: Props) {
<SidebarItem
label="Environments"
to="portainer.endpoints"
iconClass="fa-plug fa-fw"
icon={HardDrive}
openOnPaths={['portainer.wizard.endpoints']}
>
<SidebarItem to="portainer.groups" label="Groups" />
@ -43,22 +48,26 @@ export function SettingsSidebar({ isAdmin }: Props) {
<SidebarItem
label="Registries"
to="portainer.registries"
iconClass="fa-database fa-fw"
icon={Radio}
/>
{process.env.PORTAINER_EDITION !== 'CE' && (
<SidebarItem
to="portainer.licenses"
label="Licenses"
icon={Award}
/>
)}
<SidebarItem
label="Authentication logs"
to="portainer.authLogs"
iconClass="fa-history fa-fw"
icon={FileText}
>
<SidebarItem to="portainer.activityLogs" label="Activity Logs" />
</SidebarItem>
<SidebarItem
to="portainer.settings"
label="Settings"
iconClass="fa-cogs fa-fw"
>
<SidebarItem to="portainer.settings" label="Settings" icon={Settings}>
{!window.ddExtension && (
<SidebarItem
to="portainer.settings.authentication"
@ -81,6 +90,7 @@ export function SettingsSidebar({ isAdmin }: Props) {
}
target="_blank"
rel="noreferrer"
className="px-3 rounded flex h-full items-center"
>
Help / About
</a>

@ -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%;
}

@ -1,3 +1,6 @@
import clsx from 'clsx';
import { Home } from 'react-feather';
import { useUser } from '@/portainer/hooks/useUser';
import { useIsTeamLeader } from '@/portainer/users/queries';
import { usePublicSettings } from '@/portainer/settings/queries';
@ -26,15 +29,19 @@ export function Sidebar() {
return (
/* in the future (when we remove r2a) this should wrap the whole app - to change root styles */
<SidebarProvider>
<nav id="sidebar-wrapper" className={styles.root} aria-label="Main">
<nav
className={clsx(
styles.root,
'p-5 flex flex-col be:bg-gray-11 bg-blue-10'
)}
aria-label="Main"
>
<Header logo={LogoURL} />
<div className={styles.sidebarContent}>
<ul className={styles.sidebar}>
<SidebarItem
to="portainer.home"
iconClass="fa-home fa-fw"
label="Home"
/>
{/* negative margin + padding -> scrollbar won't hide the content */}
<div className="mt-6 overflow-y-auto flex-1 -mr-4 pr-4">
<ul className="space-y-9">
<SidebarItem to="portainer.home" icon={Home} label="Home" />
<EnvironmentSidebar />
@ -44,7 +51,9 @@ export function Sidebar() {
</ul>
</div>
<Footer />
<div className="mt-auto pt-8">
<Footer />
</div>
</nav>
</SidebarProvider>
);

@ -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,17 +1,16 @@
import { useCurrentStateAndParams } from '@uirouter/react';
import clsx from 'clsx';
import {
Children,
PropsWithChildren,
ReactElement,
ReactNode,
useMemo,
useReducer,
} from 'react';
import { ChevronDown, ChevronUp } from 'react-feather';
import { useSidebarState } from '../useSidebarState';
import styles from './Menu.module.css';
import { getPaths } from './utils';
interface Props {
head: ReactNode;
@ -26,38 +25,32 @@ export function Menu({
const { isOpen: isSidebarOpen } = useSidebarState();
const paths = useMemo(
() => [
...getPaths(head, []),
...getPathsForChildren(children),
...openOnPaths,
],
[children, openOnPaths, head]
() => [...getPaths(head, []), ...openOnPaths],
[openOnPaths, head]
);
const { isOpen, toggleOpen } = useIsOpen(isSidebarOpen, paths);
const CollapseButtonIcon = isOpen ? ChevronUp : ChevronDown;
return (
<>
<div className={styles.sidebarMenuHead}>
{Children.count(children) > 0 && (
<div className="flex-1">
<div className="flex w-full justify-between items-center relative ">
{head}
{isSidebarOpen && Children.count(children) > 0 && (
<button
className={clsx('small', styles.sidebarMenuIndicator)}
className="bg-transparent border-0 w-6 h-6 flex items-center justify-center absolute right-2 text-gray-5"
onClick={handleClickArrow}
type="button"
aria-label="Collapse button"
>
<i
className={clsx(
'fas',
isOpen ? 'fa-chevron-down' : 'fa-chevron-right'
)}
/>
<CollapseButtonIcon className="w-4 h-4" />
</button>
)}
{head}
</div>
{isOpen && <ul className={styles.items}>{children}</ul>}
</>
{isOpen && <ul className="!pl-8">{children}</ul>}
</div>
);
function handleClickArrow(e: React.MouseEvent<HTMLButtonElement>) {
@ -100,30 +93,3 @@ function useIsOpen(
return isOpenByState;
}
}
function isReactElement(element: ReactNode): element is ReactElement {
return (
!!element &&
typeof element === 'object' &&
'type' in element &&
'props' in element
);
}
function getPathsForChildren(children: ReactNode): string[] {
return Children.map(children, (child) => getPaths(child, []))?.flat() || [];
}
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,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,4 +1,5 @@
import { Meta, Story } from '@storybook/react';
import { Clock, Icon } from 'react-feather';
import { SidebarItem } from '.';
@ -9,33 +10,30 @@ const meta: Meta = {
export default meta;
interface StoryProps {
iconClass?: string;
className: string;
icon?: Icon;
label: string;
}
function Template({ iconClass, className, label: linkName }: StoryProps) {
function Template({ icon, label }: StoryProps) {
return (
<ul className="sidebar">
<SidebarItem.Wrapper className={className}>
<SidebarItem.Link to="example.path" params={{ endpointId: 1 }}>
{linkName}
{iconClass && <SidebarItem.Icon iconClass={iconClass} />}
</SidebarItem.Link>
</SidebarItem.Wrapper>
<SidebarItem
to="example.path"
params={{ endpointId: 1 }}
icon={icon}
label={label}
/>
</ul>
);
}
export const Primary: Story<StoryProps> = Template.bind({});
Primary.args = {
iconClass: 'fa-tachometer-alt fa-fw',
className: 'exampleItemClass',
icon: Clock,
label: 'Item with icon',
};
export const WithoutIcon: Story<StoryProps> = Template.bind({});
WithoutIcon.args = {
className: 'exampleItemClass',
label: 'Item without icon',
};

@ -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>
);
}

@ -1,38 +1,43 @@
import { ReactNode } from 'react';
import { Icon } from 'react-feather';
import { Wrapper } from './Wrapper';
import { Link } from './Link';
import { Menu } from './Menu';
import { Icon } from './Icon';
import { Head } from './Head';
import { getPathsForChildren } from './utils';
type Props = {
iconClass?: string;
interface Props {
icon?: Icon;
to: string;
params?: object;
label: string;
children?: ReactNode;
openOnPaths?: string[];
};
}
export function SidebarItem({
children,
iconClass,
icon,
to,
params,
label,
openOnPaths,
openOnPaths = [],
}: Props) {
const childrenPath = getPathsForChildren(children);
const head = (
<Link to={to} params={params}>
{label}
{iconClass && <Icon iconClass={iconClass} />}
</Link>
<Head
icon={icon}
to={to}
params={params}
label={label}
ignorePaths={childrenPath}
/>
);
return (
<Wrapper label={label}>
{children ? (
<Menu head={head} openOnPaths={openOnPaths}>
<Menu head={head} openOnPaths={[...openOnPaths, ...childrenPath]}>
{children}
</Menu>
) : (

@ -1,8 +1,6 @@
import { PropsWithChildren, AriaAttributes } from 'react';
import clsx from 'clsx';
import styles from './SidebarItem.module.css';
interface Props {
className?: string;
label?: string;
@ -16,7 +14,11 @@ export function Wrapper({
}: PropsWithChildren<Props> & AriaAttributes) {
return (
<li
className={clsx(styles.sidebarMenuItem, className)}
className={clsx(
'flex',
className,
'text-gray-3 min-h-8 [&>a]:text-inherit [&>a]:hover:text-inherit [&>a]:hover:no-underline'
)}
title={label}
aria-label={label}
// eslint-disable-next-line react/jsx-props-no-spreading

@ -1,12 +1,8 @@
import { SidebarItem as MainComponent } from './SidebarItem';
import { Icon } from './Icon';
import { Link } from './Link';
import { Menu } from './Menu';
import { Wrapper } from './Wrapper';
interface SubComponents {
Icon: typeof Icon;
Link: typeof Link;
Menu: typeof Menu;
Wrapper: typeof Wrapper;
}
@ -14,7 +10,5 @@ interface SubComponents {
export const SidebarItem: typeof MainComponent & SubComponents =
MainComponent as typeof MainComponent & SubComponents;
SidebarItem.Link = Link;
SidebarItem.Icon = Icon;
SidebarItem.Menu = Menu;
SidebarItem.Wrapper = 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"
/>
);
}

@ -1,11 +1,13 @@
import {
createContext,
PropsWithChildren,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
useReducer,
} from 'react';
import angular, { IScope } from 'angular';
import _ from 'lodash';
@ -21,7 +23,7 @@ interface State {
toggle(): void;
}
const Context = createContext<State | null>(null);
export const Context = createContext<State | null>(null);
export function useSidebarState() {
const context = useContext(Context);
@ -39,6 +41,17 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) {
return <Context.Provider value={state}> {children} </Context.Provider>;
}
export function TestSidebarProvider({ children }: PropsWithChildren<unknown>) {
const [isOpen, toggle] = useReducer((state) => !state, true);
const state = useMemo(
() => ({ isOpen, toggle: () => toggle() }),
[isOpen, toggle]
);
return <Context.Provider value={state}> {children} </Context.Provider>;
}
/* @ngInject */
export function AngularSidebarService($rootScope: IScope) {
const state = {

@ -137,7 +137,6 @@
"spinkit": "^2.0.1",
"splitargs": "github:deviantony/splitargs#semver:~0.2.0",
"strip-ansi": "^6.0.0",
"tailwindcss": "^3.0.23",
"toastr": "^2.1.4",
"ui-select": "^0.19.8",
"uuid": "^3.3.2",
@ -160,6 +159,7 @@
"@storybook/builder-webpack5": "^6.4.9",
"@storybook/manager-webpack5": "^6.4.9",
"@storybook/react": "^6.4.9",
"@svgr/webpack": "^6.2.1",
"@testing-library/jest-dom": "^5.16.1",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
@ -181,7 +181,7 @@
"@typescript-eslint/eslint-plugin": "^5.7.0",
"@typescript-eslint/parser": "^5.7.0",
"auto-ngtemplate-loader": "^2.0.1",
"autoprefixer": "^10.4.2",
"autoprefixer": "^10.4.7",
"babel-jest": "^27.4.2",
"babel-loader": "^8.2.3",
"babel-plugin-i18next-extract": "^0.8.3",
@ -229,7 +229,7 @@
"msw-storybook-addon": "^1.5.0",
"ngtemplate-loader": "^2.1.0",
"plop": "^2.6.0",
"postcss": "^8.4.6",
"postcss": "^8.4.14",
"postcss-loader": "^6.2.1",
"prettier": "^2.5.1",
"react-test-renderer": "^17.0.2",
@ -238,6 +238,7 @@
"storybook-css-modules-preset": "^1.1.1",
"style-loader": "^3.3.1",
"swagger2openapi": "^7.0.8",
"tailwindcss": "^3.1.4",
"tsconfig-paths-webpack-plugin": "^3.5.2",
"typescript": "^4.5.2",
"webpack": "^5.65.0",

@ -1,4 +1,5 @@
const plugin = require('tailwindcss/plugin');
const defaultTheme = require('tailwindcss/defaultTheme');
const colors = require('./app/assets/css/colors.json');
/** @type {import('tailwindcss').Config} */
@ -18,6 +19,11 @@ module.exports = {
'legacy-blue-2': 'var(--blue-2)',
'legacy-blue-9': 'var(--blue-9)',
},
extend: {
fontFamily: {
sans: ['Inter', ...defaultTheme.fontFamily.sans],
},
},
},
plugins: [

@ -14,6 +14,7 @@ const CopyPlugin = require('copy-webpack-plugin');
const pkg = require('../package.json');
const projectRoot = path.resolve(__dirname, '..');
/** @type {import('webpack').Configuration} */
module.exports = {
entry: {
main: './app',
@ -63,6 +64,21 @@ module.exports = {
test: /.xml$/,
type: 'asset/resource',
},
{
test: /\.(gif|png|jpe?g)$/i,
type: 'asset/resource',
},
{
test: /\.svg$/i,
type: 'asset',
resourceQuery: { not: [/c/] }, // exclude react component if *.svg?url
},
{
test: /\.svg$/i,
issuer: /\.tsx?$/,
resourceQuery: /c/, // *.svg?c
use: [{ loader: '@svgr/webpack', options: { icon: true } }],
},
{
test: /\.css$/,
use: [

@ -7,7 +7,7 @@ module.exports = merge(commonConfig, {
module: {
rules: [
{
test: /\.(woff|woff2|eot|ttf|svg|ico|png|jpg|gif)$/,
test: /\.(woff|woff2|eot|ttf|ico)$/,
type: 'asset/resource',
},
],

@ -10,10 +10,6 @@ module.exports = merge(commonConfig, {
test: /\.(woff|woff2|eot|ttf|ico)$/,
type: 'asset/inline',
},
{
test: /\.(gif|png|jpe?g|svg)$/i,
type: 'asset/resource',
},
],
},
});

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save