mirror of
https://github.com/containous/traefik.git
synced 2025-11-24 08:23:52 +03:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bdcd72042 | ||
|
|
2ad42cd0ec |
@@ -49,7 +49,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.4.2",
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
"@testing-library/react": "^14.2.1",
|
"@testing-library/react": "^14.2.1",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@traefiklabs/faency": "11.1.4",
|
"@traefiklabs/faency": "12.0.4",
|
||||||
"@types/lodash": "^4.17.16",
|
"@types/lodash": "^4.17.16",
|
||||||
"@types/node": "^22.15.18",
|
"@types/node": "^22.15.18",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AriaTr, VariantProps, styled } from '@traefiklabs/faency'
|
import { AriaTr, VariantProps, styled } from '@traefiklabs/faency'
|
||||||
import { ComponentProps, forwardRef, ReactNode } from 'react'
|
import { ComponentProps, forwardRef, ReactNode } from 'react'
|
||||||
import { useHref } from 'react-router-dom'
|
|
||||||
|
import { useHrefWithReturnTo } from 'hooks/use-href-with-return-to'
|
||||||
|
|
||||||
const UnstyledLink = styled('a', {
|
const UnstyledLink = styled('a', {
|
||||||
color: 'inherit',
|
color: 'inherit',
|
||||||
@@ -18,7 +19,7 @@ type ClickableRowProps = ComponentProps<typeof AriaTr> &
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default forwardRef<HTMLTableRowElement | null, ClickableRowProps>(({ children, css, to, ...props }, ref) => {
|
export default forwardRef<HTMLTableRowElement | null, ClickableRowProps>(({ children, css, to, ...props }, ref) => {
|
||||||
const href = useHref(to)
|
const href = useHrefWithReturnTo(to)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AriaTr asChild interactive ref={ref} css={css} {...props}>
|
<AriaTr asChild interactive ref={ref} css={css} {...props}>
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Box, Button, Flex, TextField } from '@traefiklabs/faency'
|
import { Box, Button, Flex, TextField, InputHandle } from '@traefiklabs/faency'
|
||||||
// eslint-disable-next-line import/no-unresolved
|
|
||||||
import { InputHandle } from '@traefiklabs/faency/dist/components/Input'
|
|
||||||
import { isUndefined, omitBy } from 'lodash'
|
import { isUndefined, omitBy } from 'lodash'
|
||||||
import { useCallback, useRef, useState } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import { FiSearch, FiXCircle } from 'react-icons/fi'
|
import { FiSearch, FiXCircle } from 'react-icons/fi'
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { StatusWrapper } from './ResourceStatus'
|
|||||||
import { colorByStatus } from './Status'
|
import { colorByStatus } from './Status'
|
||||||
|
|
||||||
import Tooltip from 'components/Tooltip'
|
import Tooltip from 'components/Tooltip'
|
||||||
|
import { useGetUrlWithReturnTo } from 'hooks/use-href-with-return-to'
|
||||||
|
|
||||||
const CustomHeading = styled(H2, {
|
const CustomHeading = styled(H2, {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -125,9 +126,25 @@ const CardSkeleton = ({ bigDescription }: { bigDescription?: boolean }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CardListSection = ({ icon, title, cards, isLast, bigDescription }: SectionType) => {
|
const CardItem = ({ card }) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const href = useGetUrlWithReturnTo(card.link)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpacedCard key={card.description} css={{ border: card.focus ? `2px solid $primary` : '', p: '$3' }}>
|
||||||
|
<FlexLink
|
||||||
|
data-testid={card.link}
|
||||||
|
onClick={(): false | void => !!card.link && navigate(href)}
|
||||||
|
css={{ cursor: card.link ? 'pointer' : 'inherit' }}
|
||||||
|
>
|
||||||
|
<ItemTitle>{card.title}</ItemTitle>
|
||||||
|
<CardDescription>{card.description}</CardDescription>
|
||||||
|
</FlexLink>
|
||||||
|
</SpacedCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CardListSection = ({ icon, title, cards, isLast, bigDescription }: SectionType) => {
|
||||||
return (
|
return (
|
||||||
<Flex css={{ flexDirection: 'column', flexGrow: 1 }}>
|
<Flex css={{ flexDirection: 'column', flexGrow: 1 }}>
|
||||||
<SectionHeader icon={icon} title={title} />
|
<SectionHeader icon={icon} title={title} />
|
||||||
@@ -135,20 +152,7 @@ export const CardListSection = ({ icon, title, cards, isLast, bigDescription }:
|
|||||||
<CardListColumn>
|
<CardListColumn>
|
||||||
<Flex css={{ flexDirection: 'column', flexGrow: 1, marginRight: '$3' }}>
|
<Flex css={{ flexDirection: 'column', flexGrow: 1, marginRight: '$3' }}>
|
||||||
{!cards && <CardSkeleton bigDescription={bigDescription} />}
|
{!cards && <CardSkeleton bigDescription={bigDescription} />}
|
||||||
{cards
|
{cards?.filter((c) => !!c.description).map((card, idx) => <CardItem key={`card-${idx}`} card={card} />)}
|
||||||
?.filter((c) => !!c.description)
|
|
||||||
.map((card) => (
|
|
||||||
<SpacedCard key={card.description} css={{ border: card.focus ? `2px solid $primary` : '', p: '$3' }}>
|
|
||||||
<FlexLink
|
|
||||||
data-testid={card.link}
|
|
||||||
onClick={(): false | void => !!card.link && navigate(card.link)}
|
|
||||||
css={{ cursor: card.link ? 'pointer' : 'inherit' }}
|
|
||||||
>
|
|
||||||
<ItemTitle>{card.title}</ItemTitle>
|
|
||||||
<CardDescription>{card.description}</CardDescription>
|
|
||||||
</FlexLink>
|
|
||||||
</SpacedCard>
|
|
||||||
))}
|
|
||||||
<Box css={{ height: '16px' }}> </Box>
|
<Box css={{ height: '16px' }}> </Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
</CardListColumn>
|
</CardListColumn>
|
||||||
|
|||||||
238
webui/src/hooks/use-href-with-return-to.spec.tsx
Normal file
238
webui/src/hooks/use-href-with-return-to.spec.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import { renderHook } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { useGetUrlWithReturnTo, useHrefWithReturnTo, useRouterReturnTo } from './use-href-with-return-to'
|
||||||
|
|
||||||
|
describe('useGetUrlWithReturnTo', () => {
|
||||||
|
const createWrapper = (initialPath = '/') => {
|
||||||
|
return ({ children }) => <MemoryRouter initialEntries={[initialPath]}>{children}</MemoryRouter>
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should append current path as returnTo query param', () => {
|
||||||
|
const { result } = renderHook(() => useGetUrlWithReturnTo('/target'), {
|
||||||
|
wrapper: createWrapper('/current/path'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current).toBe('/target?returnTo=%2Fcurrent%2Fpath')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should append current path with search params as returnTo', () => {
|
||||||
|
const { result } = renderHook(() => useGetUrlWithReturnTo('/target'), {
|
||||||
|
wrapper: createWrapper('/current/path?foo=bar'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current).toBe('/target?returnTo=%2Fcurrent%2Fpath%3Ffoo%3Dbar')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use initialReturnTo when provided', () => {
|
||||||
|
const { result } = renderHook(() => useGetUrlWithReturnTo('/target', '/custom/return'), {
|
||||||
|
wrapper: createWrapper('/current/path'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current).toBe('/target?returnTo=%2Fcustom%2Freturn')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return the href as-is when href is empty string', () => {
|
||||||
|
const { result } = renderHook(() => useGetUrlWithReturnTo(''), {
|
||||||
|
wrapper: createWrapper('/current/path'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle href with existing query params', () => {
|
||||||
|
const { result } = renderHook(() => useGetUrlWithReturnTo('/target?existing=param'), {
|
||||||
|
wrapper: createWrapper('/current/path'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current).toBe('/target?existing=param&returnTo=%2Fcurrent%2Fpath')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useHrefWithReturnTo', () => {
|
||||||
|
const createWrapper = (initialPath = '/') => {
|
||||||
|
return ({ children }) => <MemoryRouter initialEntries={[initialPath]}>{children}</MemoryRouter>
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should return resolved href with returnTo param containing current path', () => {
|
||||||
|
const { result } = renderHook(() => useHrefWithReturnTo('/target'), {
|
||||||
|
wrapper: createWrapper('/current'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current).toBe('/target?returnTo=%2Fcurrent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include current search params in returnTo', () => {
|
||||||
|
const { result } = renderHook(() => useHrefWithReturnTo('/target'), {
|
||||||
|
wrapper: createWrapper('/current?foo=bar&baz=qux'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current).toBe('/target?returnTo=%2Fcurrent%3Ffoo%3Dbar%26baz%3Dqux')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use custom returnTo when provided instead of current path', () => {
|
||||||
|
const { result } = renderHook(() => useHrefWithReturnTo('/target', '/custom/return'), {
|
||||||
|
wrapper: createWrapper('/current'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current).toBe('/target?returnTo=%2Fcustom%2Freturn')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle absolute paths correctly', () => {
|
||||||
|
const { result } = renderHook(() => useHrefWithReturnTo('/http/routers'), {
|
||||||
|
wrapper: createWrapper('/tcp/services'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current).toBe('/http/routers?returnTo=%2Ftcp%2Fservices')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should preserve existing query params in target href', () => {
|
||||||
|
const { result } = renderHook(() => useHrefWithReturnTo('/target?existing=param'), {
|
||||||
|
wrapper: createWrapper('/current'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current).toBe('/target?existing=param&returnTo=%2Fcurrent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return root path when href is empty', () => {
|
||||||
|
const { result } = renderHook(() => useHrefWithReturnTo(''), {
|
||||||
|
wrapper: createWrapper('/current'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// useHref converts empty string to root path
|
||||||
|
expect(result.current).toBe('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle complex nested paths in returnTo', () => {
|
||||||
|
const { result } = renderHook(() => useHrefWithReturnTo('/target'), {
|
||||||
|
wrapper: createWrapper('/http/routers/my-router-123'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current).toBe('/target?returnTo=%2Fhttp%2Frouters%2Fmy-router-123')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useRouterReturnTo', () => {
|
||||||
|
const createWrapper = (initialPath = '/') => {
|
||||||
|
return ({ children }) => <MemoryRouter initialEntries={[initialPath]}>{children}</MemoryRouter>
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should return null when no returnTo query param exists', () => {
|
||||||
|
const { result } = renderHook(() => useRouterReturnTo(), {
|
||||||
|
wrapper: createWrapper('/current'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.returnTo).toBeNull()
|
||||||
|
expect(result.current.returnToLabel).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract returnTo from query params', () => {
|
||||||
|
const { result } = renderHook(() => useRouterReturnTo(), {
|
||||||
|
wrapper: createWrapper('/current?returnTo=/http/routers'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.returnTo).toBe('/http/routers')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate correct label for HTTP routers (plural)', () => {
|
||||||
|
const { result } = renderHook(() => useRouterReturnTo(), {
|
||||||
|
wrapper: createWrapper('/current?returnTo=/http/routers'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.returnToLabel).toBe('HTTP routers')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate correct label for HTTP router (singular)', () => {
|
||||||
|
const { result } = renderHook(() => useRouterReturnTo(), {
|
||||||
|
wrapper: createWrapper('/current?returnTo=/http/routers/router-1'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.returnToLabel).toBe('HTTP router')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate fallback label for unknown routes (plural)', () => {
|
||||||
|
const { result } = renderHook(() => useRouterReturnTo(), {
|
||||||
|
wrapper: createWrapper('/current?returnTo=/custom/resources'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.returnToLabel).toBe('Custom resources')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle malformed returnTo paths gracefully', () => {
|
||||||
|
const { result } = renderHook(() => useRouterReturnTo(), {
|
||||||
|
wrapper: createWrapper('/current?returnTo=/'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.returnTo).toBe('/')
|
||||||
|
expect(result.current.returnToLabel).toBe('Back')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle returnTo with query params', () => {
|
||||||
|
const { result } = renderHook(() => useRouterReturnTo(), {
|
||||||
|
wrapper: createWrapper('/current?returnTo=/http/routers?filter=test'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.returnTo).toContain('/http/routers')
|
||||||
|
expect(result.current.returnToLabel).toBe('HTTP routers')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should strip query params from path when generating label', () => {
|
||||||
|
const { result } = renderHook(() => useRouterReturnTo(), {
|
||||||
|
wrapper: createWrapper('/current?returnTo=/http/routers?filter=test&status=active'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.returnToLabel).toBe('HTTP routers')
|
||||||
|
expect(result.current.returnToLabel).not.toContain('filter')
|
||||||
|
expect(result.current.returnToLabel).not.toContain('status')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should strip query params from subpath when generating label', () => {
|
||||||
|
const { result } = renderHook(() => useRouterReturnTo(), {
|
||||||
|
wrapper: createWrapper('/current?returnTo=/tcp/services?page=2'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.returnToLabel).toBe('TCP services')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle query params with multiple question marks gracefully', () => {
|
||||||
|
const { result } = renderHook(() => useRouterReturnTo(), {
|
||||||
|
wrapper: createWrapper('/current?returnTo=/http/routers?filter=test?extra=param'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should handle edge case with multiple question marks (invalid URL but should not crash)
|
||||||
|
expect(result.current.returnToLabel).toBe('HTTP routers')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle path with query params but no subpath', () => {
|
||||||
|
const { result } = renderHook(() => useRouterReturnTo(), {
|
||||||
|
wrapper: createWrapper('/current?returnTo=/http?foo=bar'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.returnToLabel).toBe('Http')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty query string (path ending with ?)', () => {
|
||||||
|
const { result } = renderHook(() => useRouterReturnTo(), {
|
||||||
|
wrapper: createWrapper('/current?returnTo=/tcp/middlewares?'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.returnToLabel).toBe('TCP middlewares')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle complex query strings with special characters', () => {
|
||||||
|
const { result } = renderHook(() => useRouterReturnTo(), {
|
||||||
|
wrapper: createWrapper('/current?returnTo=/http/services?filter=%40test%23special'),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.current.returnToLabel).toBe('HTTP services')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should capitalize first letter of label override', () => {
|
||||||
|
const { result } = renderHook(() => useRouterReturnTo(), {
|
||||||
|
wrapper: createWrapper('/current?returnTo=/resource/routers/router-1'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify the label starts with uppercase
|
||||||
|
expect(result.current.returnToLabel?.charAt(0)).toBe('R')
|
||||||
|
})
|
||||||
|
})
|
||||||
119
webui/src/hooks/use-href-with-return-to.ts
Normal file
119
webui/src/hooks/use-href-with-return-to.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import qs from 'query-string'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useHref, useLocation, useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { capitalizeFirstLetter } from '../utils/string'
|
||||||
|
|
||||||
|
type UseGetUrlWithReturnTo = (href: string, initialReturnTo?: string) => string
|
||||||
|
|
||||||
|
export const useGetUrlWithReturnTo: UseGetUrlWithReturnTo = (href, initialReturnTo) => {
|
||||||
|
const location = useLocation()
|
||||||
|
const currentPath = location.pathname + location.search
|
||||||
|
|
||||||
|
const url = useMemo(() => {
|
||||||
|
if (href) {
|
||||||
|
return qs.stringifyUrl({ url: href, query: { returnTo: initialReturnTo ?? currentPath } })
|
||||||
|
}
|
||||||
|
return href
|
||||||
|
}, [currentPath, href, initialReturnTo])
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useHrefWithReturnTo = (href: string, returnTo?: string): string => {
|
||||||
|
const urlWithReturnTo = useGetUrlWithReturnTo(href, returnTo)
|
||||||
|
|
||||||
|
return useHref(urlWithReturnTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
const RETURN_TO_LABEL_OVERRIDES_SINGULAR: Record<string, Record<string, string>> = {
|
||||||
|
http: {
|
||||||
|
routers: 'HTTP router',
|
||||||
|
services: 'HTTP service',
|
||||||
|
middlewares: 'HTTP middleware',
|
||||||
|
},
|
||||||
|
tcp: {
|
||||||
|
routers: 'TCP router',
|
||||||
|
services: 'TCP service',
|
||||||
|
middlewares: 'TCP middleware',
|
||||||
|
},
|
||||||
|
udp: {
|
||||||
|
routers: 'UDP router',
|
||||||
|
services: 'TCP service',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const RETURN_TO_LABEL_OVERRIDES_PLURAL: Record<string, Record<string, string>> = {
|
||||||
|
http: {
|
||||||
|
routers: 'HTTP routers',
|
||||||
|
services: 'HTTP services',
|
||||||
|
middlewares: 'HTTP middlewares',
|
||||||
|
},
|
||||||
|
tcp: {
|
||||||
|
routers: 'TCP routers',
|
||||||
|
services: 'TCP services',
|
||||||
|
middlewares: 'TCP middlewares',
|
||||||
|
},
|
||||||
|
udp: {
|
||||||
|
routers: 'UDP routers',
|
||||||
|
services: 'TCP services',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseRouterReturnTo = (initialReturnTo?: string) => {
|
||||||
|
returnTo: string | null
|
||||||
|
returnToLabel: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCleanPath = (path: string) => {
|
||||||
|
if (!path) return ''
|
||||||
|
return path.split('?')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRouterReturnTo: UseRouterReturnTo = () => {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
|
const returnTo = useMemo(() => {
|
||||||
|
const queryReturnTo = searchParams.get('returnTo')
|
||||||
|
return queryReturnTo || null
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
const returnToHref = useHref(returnTo || '')
|
||||||
|
|
||||||
|
const returnToLabel = useMemo(() => {
|
||||||
|
if (!returnTo) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const returnToArr = returnTo.split('/')
|
||||||
|
|
||||||
|
const [, path, subpath, id] = returnToArr
|
||||||
|
|
||||||
|
// Strip query params from path, if any
|
||||||
|
const cleanPath = getCleanPath(path)
|
||||||
|
const cleanSubpath = getCleanPath(subpath)
|
||||||
|
|
||||||
|
// Malformed returnTo (e.g., just '/' or empty path)
|
||||||
|
if (!cleanPath) {
|
||||||
|
return 'Back'
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackLabel = `${capitalizeFirstLetter(cleanPath)}${cleanSubpath ? ` ${cleanSubpath}` : ''}`
|
||||||
|
|
||||||
|
const labelArray = id ? RETURN_TO_LABEL_OVERRIDES_SINGULAR : RETURN_TO_LABEL_OVERRIDES_PLURAL
|
||||||
|
|
||||||
|
const labelOverride =
|
||||||
|
labelArray[cleanPath]?.[cleanSubpath] ??
|
||||||
|
(typeof labelArray[cleanPath] === 'string' ? labelArray[cleanPath] : fallbackLabel)
|
||||||
|
|
||||||
|
return capitalizeFirstLetter(labelOverride)
|
||||||
|
}, [returnTo])
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
returnTo: returnTo ? returnToHref : null,
|
||||||
|
returnToLabel,
|
||||||
|
}),
|
||||||
|
[returnTo, returnToHref, returnToLabel],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,10 +4,10 @@ import { Helmet } from 'react-helmet-async'
|
|||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import Container from './Container'
|
import Container from './Container'
|
||||||
import { LAPTOP_BP, SideBarPanel, SideNav, TopNav } from './Navigation'
|
|
||||||
|
|
||||||
import { ToastPool } from 'components/ToastPool'
|
import { ToastPool } from 'components/ToastPool'
|
||||||
import { ToastProvider } from 'contexts/toasts'
|
import { ToastProvider } from 'contexts/toasts'
|
||||||
|
import { LAPTOP_BP, SideBarPanel, SideNav, TopNav } from 'layout/navigation'
|
||||||
|
|
||||||
export const LIGHT_PRIMARY_COLOR = '#217F97'
|
export const LIGHT_PRIMARY_COLOR = '#217F97'
|
||||||
export const DARK_PRIMARY_COLOR = '#2AA2C1'
|
export const DARK_PRIMARY_COLOR = '#2AA2C1'
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { SideNav, TopNav } from './Navigation'
|
import { SideNav, TopNav } from '.'
|
||||||
|
|
||||||
import { renderWithProviders } from 'utils/test'
|
import { renderWithProviders } from 'utils/test'
|
||||||
|
|
||||||
describe('Navigation', () => {
|
describe('Navigation', () => {
|
||||||
it('should render the side navigation bar', async () => {
|
it('should render the side navigation bar', async () => {
|
||||||
const { container } = renderWithProviders(<SideNav isExpanded={false} onSidePanelToggle={() => {}} />)
|
const { container } = renderWithProviders(<SideNav isExpanded onSidePanelToggle={() => {}} />)
|
||||||
|
|
||||||
expect(container.innerHTML).toContain('HTTP')
|
expect(container.innerHTML).toContain('HTTP')
|
||||||
expect(container.innerHTML).toContain('TCP')
|
expect(container.innerHTML).toContain('TCP')
|
||||||
@@ -1,18 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
|
||||||
CSS,
|
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
elevationVariants,
|
elevationVariants,
|
||||||
Flex,
|
Flex,
|
||||||
Link,
|
|
||||||
NavigationLink,
|
NavigationLink,
|
||||||
SidePanel,
|
SidePanel,
|
||||||
styled,
|
styled,
|
||||||
@@ -22,27 +13,23 @@ import {
|
|||||||
} from '@traefiklabs/faency'
|
} from '@traefiklabs/faency'
|
||||||
import { useContext, useEffect, useMemo, useState } from 'react'
|
import { useContext, useEffect, useMemo, useState } from 'react'
|
||||||
import { BsChevronDoubleRight, BsChevronDoubleLeft } from 'react-icons/bs'
|
import { BsChevronDoubleRight, BsChevronDoubleLeft } from 'react-icons/bs'
|
||||||
import { FiBookOpen, FiGithub, FiHelpCircle } from 'react-icons/fi'
|
|
||||||
import { matchPath, useHref } from 'react-router'
|
import { matchPath, useHref } from 'react-router'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { useWindowSize } from 'usehooks-ts'
|
import { useWindowSize } from 'usehooks-ts'
|
||||||
|
|
||||||
import Container from './Container'
|
import Container from '../Container'
|
||||||
import { DARK_PRIMARY_COLOR, LIGHT_PRIMARY_COLOR } from './Page'
|
|
||||||
|
import { LAPTOP_BP } from '.'
|
||||||
|
|
||||||
import IconButton from 'components/buttons/IconButton'
|
import IconButton from 'components/buttons/IconButton'
|
||||||
import Logo from 'components/icons/Logo'
|
import Logo from 'components/icons/Logo'
|
||||||
import { PluginsIcon } from 'components/icons/PluginsIcon'
|
import { PluginsIcon } from 'components/icons/PluginsIcon'
|
||||||
import ThemeSwitcher from 'components/ThemeSwitcher'
|
|
||||||
import TooltipText from 'components/TooltipText'
|
import TooltipText from 'components/TooltipText'
|
||||||
import { VersionContext } from 'contexts/version'
|
import { VersionContext } from 'contexts/version'
|
||||||
import useTotals from 'hooks/use-overview-totals'
|
import useTotals from 'hooks/use-overview-totals'
|
||||||
import { useIsDarkMode } from 'hooks/use-theme'
|
|
||||||
import ApimDemoNavMenu from 'pages/hub-demo/HubDemoNav'
|
import ApimDemoNavMenu from 'pages/hub-demo/HubDemoNav'
|
||||||
import { Route, ROUTES } from 'routes'
|
import { Route, ROUTES } from 'routes'
|
||||||
|
|
||||||
export const LAPTOP_BP = 1025
|
|
||||||
|
|
||||||
const NavigationDrawer = styled(Flex, {
|
const NavigationDrawer = styled(Flex, {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
@@ -61,11 +48,13 @@ export const BasicNavigationItem = ({
|
|||||||
count,
|
count,
|
||||||
isSmallScreen,
|
isSmallScreen,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
|
onSidePanelToggle,
|
||||||
}: {
|
}: {
|
||||||
route: Route
|
route: Route
|
||||||
count?: number
|
count?: number
|
||||||
isSmallScreen: boolean
|
isSmallScreen: boolean
|
||||||
isExpanded: boolean
|
isExpanded: boolean
|
||||||
|
onSidePanelToggle: (isOpen: boolean) => void
|
||||||
}) => {
|
}) => {
|
||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const href = useHref(route.path)
|
const href = useHref(route.path)
|
||||||
@@ -91,7 +80,13 @@ export const BasicNavigationItem = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationLink active={isActiveRoute} startAdornment={route?.icon} css={{ whiteSpace: 'nowrap' }} href={href}>
|
<NavigationLink
|
||||||
|
onClick={isSmallScreen ? () => onSidePanelToggle(false) : undefined}
|
||||||
|
active={isActiveRoute}
|
||||||
|
startAdornment={route?.icon}
|
||||||
|
css={{ whiteSpace: 'nowrap' }}
|
||||||
|
href={href}
|
||||||
|
>
|
||||||
{route.label}
|
{route.label}
|
||||||
{!!count && (
|
{!!count && (
|
||||||
<Badge variant={isActiveRoute ? 'green' : undefined} css={{ ml: '$2' }}>
|
<Badge variant={isActiveRoute ? 'green' : undefined} css={{ ml: '$2' }}>
|
||||||
@@ -113,7 +108,7 @@ export const SideBarPanel = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SidePanel
|
<SidePanel
|
||||||
open={isOpen && windowSize.width < LAPTOP_BP}
|
open={isOpen && windowSize.width <= LAPTOP_BP}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
side="left"
|
side="left"
|
||||||
css={{ width: 264, p: 0 }}
|
css={{ width: 264, p: 0 }}
|
||||||
@@ -145,8 +140,10 @@ export const SideNav = ({
|
|||||||
const [isSmallScreen, setIsSmallScreen] = useState(false)
|
const [isSmallScreen, setIsSmallScreen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsSmallScreen(isResponsive && windowSize.width < LAPTOP_BP)
|
setIsSmallScreen(windowSize.width <= LAPTOP_BP)
|
||||||
}, [isExpanded, isResponsive, windowSize.width])
|
}, [isExpanded, windowSize.width])
|
||||||
|
|
||||||
|
const isSmallAndResponsive = useMemo(() => isSmallScreen && isResponsive, [isResponsive, isSmallScreen])
|
||||||
|
|
||||||
const totalValueByPath = useMemo<{ [key: string]: number }>(
|
const totalValueByPath = useMemo<{ [key: string]: number }>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -164,7 +161,7 @@ export const SideNav = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationDrawer
|
<NavigationDrawer
|
||||||
data-collapsed={isExpanded && isResponsive && isSmallScreen}
|
data-collapsed={isExpanded && isSmallAndResponsive}
|
||||||
css={{
|
css={{
|
||||||
width: 264,
|
width: 264,
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
@@ -224,12 +221,11 @@ export const SideNav = ({
|
|||||||
? { mt: '$4', px: 0, justifyContent: 'center' }
|
? { mt: '$4', px: 0, justifyContent: 'center' }
|
||||||
: undefined,
|
: undefined,
|
||||||
}}
|
}}
|
||||||
href="https://github.com/traefik/traefik/"
|
href={useHref('/')}
|
||||||
target="_blank"
|
|
||||||
data-testid="proxy-main-nav"
|
data-testid="proxy-main-nav"
|
||||||
>
|
>
|
||||||
<Logo height={isSmallScreen ? 36 : 56} isSmallScreen={isSmallScreen} />
|
<Logo height={isSmallAndResponsive ? 36 : 56} isSmallScreen={isSmallAndResponsive} />
|
||||||
{!!version && !isSmallScreen && (
|
{!!version && !isSmallAndResponsive && (
|
||||||
<TooltipText text={version} css={{ maxWidth: 50, fontWeight: '$semiBold' }} isTruncated />
|
<TooltipText text={version} css={{ maxWidth: 50, fontWeight: '$semiBold' }} isTruncated />
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -268,6 +264,7 @@ export const SideNav = ({
|
|||||||
count={totalValueByPath[item.path]}
|
count={totalValueByPath[item.path]}
|
||||||
isSmallScreen={isSmallScreen}
|
isSmallScreen={isSmallScreen}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
|
onSidePanelToggle={onSidePanelToggle}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
@@ -286,113 +283,13 @@ export const SideNav = ({
|
|||||||
</NavigationLink>
|
</NavigationLink>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<ApimDemoNavMenu isResponsive={isResponsive} isSmallScreen={isSmallScreen} isExpanded={isExpanded} />
|
<ApimDemoNavMenu
|
||||||
|
isResponsive={isResponsive}
|
||||||
|
isSmallScreen={isSmallScreen}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
onSidePanelToggle={onSidePanelToggle}
|
||||||
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
</NavigationDrawer>
|
</NavigationDrawer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TopNav = ({ css, noHubButton = false }: { css?: CSS; noHubButton?: boolean }) => {
|
|
||||||
const [hasHubButtonComponent, setHasHubButtonComponent] = useState(false)
|
|
||||||
const { showHubButton, version } = useContext(VersionContext)
|
|
||||||
const isDarkMode = useIsDarkMode()
|
|
||||||
|
|
||||||
const parsedVersion = useMemo(() => {
|
|
||||||
if (!version) {
|
|
||||||
return 'master'
|
|
||||||
}
|
|
||||||
if (version === 'dev') {
|
|
||||||
return 'master'
|
|
||||||
}
|
|
||||||
const matches = version.match(/^(v?\d+\.\d+)/)
|
|
||||||
return matches ? 'v' + matches[1] : 'master'
|
|
||||||
}, [version])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!showHubButton) {
|
|
||||||
setHasHubButtonComponent(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (customElements.get('hub-button-app')) {
|
|
||||||
setHasHubButtonComponent(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const scripts: HTMLScriptElement[] = []
|
|
||||||
const createScript = (scriptSrc: string): HTMLScriptElement => {
|
|
||||||
const script = document.createElement('script')
|
|
||||||
script.src = scriptSrc
|
|
||||||
script.async = true
|
|
||||||
script.onload = () => {
|
|
||||||
setHasHubButtonComponent(customElements.get('hub-button-app') !== undefined)
|
|
||||||
}
|
|
||||||
scripts.push(script)
|
|
||||||
return script
|
|
||||||
}
|
|
||||||
|
|
||||||
// Source: https://github.com/traefik/traefiklabs-hub-button-app
|
|
||||||
document.head.appendChild(createScript('traefiklabs-hub-button-app/main-v1.js'))
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Remove the scripts on unmount.
|
|
||||||
scripts.forEach((script) => {
|
|
||||||
if (script.parentNode) {
|
|
||||||
script.parentNode.removeChild(script)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [showHubButton])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex as="nav" role="navigation" justify="end" align="center" css={{ gap: '$2', mb: '$6', ...css }}>
|
|
||||||
{!noHubButton && hasHubButtonComponent && (
|
|
||||||
<Box css={{ fontFamily: '$rubik', fontWeight: '500 !important' }}>
|
|
||||||
<hub-button-app
|
|
||||||
key={`dark-mode-${isDarkMode}`}
|
|
||||||
style={{ backgroundColor: isDarkMode ? DARK_PRIMARY_COLOR : LIGHT_PRIMARY_COLOR, fontWeight: 'inherit' }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<ThemeSwitcher />
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button ghost variant="secondary" css={{ px: '$2', boxShadow: 'none' }} data-testid="help-menu">
|
|
||||||
<FiHelpCircle size={20} />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuPortal>
|
|
||||||
<DropdownMenuContent align="end" css={{ zIndex: 9999 }}>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
|
|
||||||
<Link
|
|
||||||
href={`https://doc.traefik.io/traefik/${parsedVersion}`}
|
|
||||||
target="_blank"
|
|
||||||
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
|
|
||||||
>
|
|
||||||
<Flex align="center" gap={2}>
|
|
||||||
<FiBookOpen size={20} />
|
|
||||||
<Text>Documentation</Text>
|
|
||||||
</Flex>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
|
|
||||||
<Link
|
|
||||||
href="https://github.com/traefik/traefik/"
|
|
||||||
target="_blank"
|
|
||||||
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
|
|
||||||
>
|
|
||||||
<Flex align="center" gap={2}>
|
|
||||||
<FiGithub size={20} />
|
|
||||||
<Text>Github Repository</Text>
|
|
||||||
</Flex>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenuPortal>
|
|
||||||
</DropdownMenu>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
158
webui/src/layout/navigation/TopNavBar.tsx
Normal file
158
webui/src/layout/navigation/TopNavBar.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CSS,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
Flex,
|
||||||
|
Link,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from '@traefiklabs/faency'
|
||||||
|
import { useContext, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { FiBookOpen, FiChevronLeft, FiGithub, FiHeart, FiHelpCircle } from 'react-icons/fi'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { DARK_PRIMARY_COLOR, LIGHT_PRIMARY_COLOR } from '../Page'
|
||||||
|
|
||||||
|
import ThemeSwitcher from 'components/ThemeSwitcher'
|
||||||
|
import { VersionContext } from 'contexts/version'
|
||||||
|
import { useRouterReturnTo } from 'hooks/use-href-with-return-to'
|
||||||
|
import { useIsDarkMode } from 'hooks/use-theme'
|
||||||
|
|
||||||
|
const TopNavBarBackLink = () => {
|
||||||
|
const { returnTo, returnToLabel } = useRouterReturnTo()
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
|
||||||
|
if (!returnTo || pathname.includes('hub-dashboard')) return <Box />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex css={{ alignItems: 'center', gap: '$2' }}>
|
||||||
|
<Link href={returnTo}>
|
||||||
|
<Button as="div" ghost variant="secondary" css={{ boxShadow: 'none', p: 0, pr: '$2' }}>
|
||||||
|
<FiChevronLeft style={{ paddingRight: '4px' }} />
|
||||||
|
{returnToLabel || 'Back'}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TopNav = ({ css, noHubButton = false }: { css?: CSS; noHubButton?: boolean }) => {
|
||||||
|
const [hasHubButtonComponent, setHasHubButtonComponent] = useState(false)
|
||||||
|
const { showHubButton, version } = useContext(VersionContext)
|
||||||
|
const isDarkMode = useIsDarkMode()
|
||||||
|
|
||||||
|
const parsedVersion = useMemo(() => {
|
||||||
|
if (!version) {
|
||||||
|
return 'master'
|
||||||
|
}
|
||||||
|
if (version === 'dev') {
|
||||||
|
return 'master'
|
||||||
|
}
|
||||||
|
const matches = version.match(/^(v?\d+\.\d+)/)
|
||||||
|
return matches ? 'v' + matches[1] : 'master'
|
||||||
|
}, [version])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showHubButton) {
|
||||||
|
setHasHubButtonComponent(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customElements.get('hub-button-app')) {
|
||||||
|
setHasHubButtonComponent(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scripts: HTMLScriptElement[] = []
|
||||||
|
const createScript = (scriptSrc: string): HTMLScriptElement => {
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = scriptSrc
|
||||||
|
script.async = true
|
||||||
|
script.onload = () => {
|
||||||
|
setHasHubButtonComponent(customElements.get('hub-button-app') !== undefined)
|
||||||
|
}
|
||||||
|
scripts.push(script)
|
||||||
|
return script
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source: https://github.com/traefik/traefiklabs-hub-button-app
|
||||||
|
document.head.appendChild(createScript('traefiklabs-hub-button-app/main-v1.js'))
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Remove the scripts on unmount.
|
||||||
|
scripts.forEach((script) => {
|
||||||
|
if (script.parentNode) {
|
||||||
|
script.parentNode.removeChild(script)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [showHubButton])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex as="nav" role="navigation" justify="space-between" align="center" css={{ mb: '$6', ...css }}>
|
||||||
|
<TopNavBarBackLink />
|
||||||
|
<Flex gap={2} align="center">
|
||||||
|
{!noHubButton && hasHubButtonComponent && (
|
||||||
|
<Box css={{ fontFamily: '$rubik', fontWeight: '500 !important' }}>
|
||||||
|
<hub-button-app
|
||||||
|
key={`dark-mode-${isDarkMode}`}
|
||||||
|
style={{ backgroundColor: isDarkMode ? DARK_PRIMARY_COLOR : LIGHT_PRIMARY_COLOR, fontWeight: 'inherit' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Tooltip content="Sponsor" side="bottom">
|
||||||
|
<Link href="https://github.com/sponsors/traefik" target="_blank">
|
||||||
|
<Button as="div" ghost css={{ px: '$2', boxShadow: 'none' }}>
|
||||||
|
<FiHeart size={20} color="#db61a2" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</Tooltip>
|
||||||
|
<ThemeSwitcher />
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button ghost variant="secondary" css={{ px: '$2', boxShadow: 'none' }} data-testid="help-menu">
|
||||||
|
<FiHelpCircle size={20} />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuContent align="end" css={{ zIndex: 9999 }}>
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
|
||||||
|
<Link
|
||||||
|
href={`https://doc.traefik.io/traefik/${parsedVersion}`}
|
||||||
|
target="_blank"
|
||||||
|
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
|
||||||
|
>
|
||||||
|
<Flex align="center" gap={2}>
|
||||||
|
<FiBookOpen size={20} />
|
||||||
|
<Text>Documentation</Text>
|
||||||
|
</Flex>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
|
||||||
|
<Link
|
||||||
|
href="https://github.com/traefik/traefik/"
|
||||||
|
target="_blank"
|
||||||
|
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
|
||||||
|
>
|
||||||
|
<Flex align="center" gap={2}>
|
||||||
|
<FiGithub size={20} />
|
||||||
|
<Text>Github Repository</Text>
|
||||||
|
</Flex>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
webui/src/layout/navigation/index.ts
Normal file
5
webui/src/layout/navigation/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// common breakpoint for large screen, cf. https://www.w3schools.com/howto/howto_css_media_query_breakpoints.asp
|
||||||
|
export const LAPTOP_BP = 1200
|
||||||
|
|
||||||
|
export * from './SideNavBar'
|
||||||
|
export * from './TopNavBar'
|
||||||
@@ -7,7 +7,7 @@ import verifySignature from './workers/scriptVerification'
|
|||||||
|
|
||||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||||
import { useIsDarkMode } from 'hooks/use-theme'
|
import { useIsDarkMode } from 'hooks/use-theme'
|
||||||
import { TopNav } from 'layout/Navigation'
|
import { TopNav } from 'layout/navigation'
|
||||||
|
|
||||||
const SCRIPT_URL = 'https://assets.traefik.io/hub-ui-demo.js'
|
const SCRIPT_URL = 'https://assets.traefik.io/hub-ui-demo.js'
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,18 @@ import { HubDemoContext } from './demoNavContext'
|
|||||||
import { HubIcon } from './icons'
|
import { HubIcon } from './icons'
|
||||||
|
|
||||||
import Tooltip from 'components/Tooltip'
|
import Tooltip from 'components/Tooltip'
|
||||||
import { BasicNavigationItem, LAPTOP_BP } from 'layout/Navigation'
|
import { BasicNavigationItem, LAPTOP_BP } from 'layout/navigation'
|
||||||
|
|
||||||
const ApimDemoNavMenu = ({
|
const ApimDemoNavMenu = ({
|
||||||
isResponsive,
|
isResponsive,
|
||||||
isSmallScreen,
|
isSmallScreen,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
|
onSidePanelToggle,
|
||||||
}: {
|
}: {
|
||||||
isResponsive: boolean
|
isResponsive: boolean
|
||||||
isSmallScreen: boolean
|
isSmallScreen: boolean
|
||||||
isExpanded: boolean
|
isExpanded: boolean
|
||||||
|
onSidePanelToggle: (isOpen: boolean) => void
|
||||||
}) => {
|
}) => {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||||
const { navigationItems: hubDemoNavItems } = useContext(HubDemoContext)
|
const { navigationItems: hubDemoNavItems } = useContext(HubDemoContext)
|
||||||
@@ -38,7 +40,7 @@ const ApimDemoNavMenu = ({
|
|||||||
transition: 'transform 0.3s ease-in-out',
|
transition: 'transform 0.3s ease-in-out',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{isSmallScreen ? (
|
{isSmallScreen && isResponsive ? (
|
||||||
<Tooltip label="Hub demo">
|
<Tooltip label="Hub demo">
|
||||||
<Box css={{ ml: 4, color: '$navButtonText' }}>
|
<Box css={{ ml: 4, color: '$navButtonText' }}>
|
||||||
<HubIcon width={20} />
|
<HubIcon width={20} />
|
||||||
@@ -74,6 +76,7 @@ const ApimDemoNavMenu = ({
|
|||||||
route={route}
|
route={route}
|
||||||
isSmallScreen={isSmallScreen}
|
isSmallScreen={isSmallScreen}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
|
onSidePanelToggle={onSidePanelToggle}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
1445
webui/yarn.lock
1445
webui/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user