Migrate Traefik Proxy dashboard UI to React
This commit is contained in:
parent
4790e4910f
commit
f16fff577a
324 changed files with 28303 additions and 19567 deletions
195
webui/src/hooks/use-fetch-with-pagination.spec.tsx
Normal file
195
webui/src/hooks/use-fetch-with-pagination.spec.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { act, fireEvent, renderHook, waitFor } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { SWRConfig } from 'swr'
|
||||
|
||||
import useFetchWithPagination from './use-fetch-with-pagination'
|
||||
|
||||
import { server } from 'mocks/server'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
const renderRow = (row) => (
|
||||
<li key={row.id} data-testid="listRow">
|
||||
{row.id}
|
||||
</li>
|
||||
)
|
||||
|
||||
const wrapper = ({ children }) => (
|
||||
<SWRConfig
|
||||
value={{
|
||||
revalidateOnFocus: false,
|
||||
fetcher: fetch,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SWRConfig>
|
||||
)
|
||||
|
||||
describe('useFetchWithPagination Hook', () => {
|
||||
it('should fetch 1st page per default', async () => {
|
||||
server.use(
|
||||
http.get('/api/http/routers', () => {
|
||||
return HttpResponse.json([{ id: 1 }], { status: 200 })
|
||||
}),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useFetchWithPagination('/http/routers', { renderRow }), {
|
||||
wrapper,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.pages).not.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should work as expected passing rowsPerPage property', async () => {
|
||||
let perPage
|
||||
|
||||
server.use(
|
||||
http.get('/api/http/routers', ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
perPage = url.searchParams.get('per_page')
|
||||
return HttpResponse.json([{ id: 1 }], { status: 200 })
|
||||
}),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useFetchWithPagination('/http/routers', { renderRow, rowsPerPage: 3 }), {
|
||||
wrapper,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.pages).not.toBeUndefined()
|
||||
})
|
||||
|
||||
expect(perPage).toBe('3')
|
||||
})
|
||||
|
||||
it('should work as expected requesting page 2', async () => {
|
||||
server.use(
|
||||
http.get('/api/http/routers', ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
const page = url.searchParams.get('page')
|
||||
if (page === '2') {
|
||||
return HttpResponse.json([{ id: 3 }], {
|
||||
headers: {
|
||||
'X-Next-Page': '1',
|
||||
},
|
||||
status: 200,
|
||||
})
|
||||
}
|
||||
return HttpResponse.json([{ id: 1 }, { id: 2 }], {
|
||||
headers: {
|
||||
'X-Next-Page': '2',
|
||||
},
|
||||
status: 200,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const TestComponent = () => {
|
||||
const { pages, pageCount, loadMore, isLoadingMore } = useFetchWithPagination('/http/routers', {
|
||||
renderLoader: () => null,
|
||||
renderRow,
|
||||
rowsPerPage: 2,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul>{pages}</ul>
|
||||
{isLoadingMore ? <div data-testid="loading">Loading...</div> : <button onClick={loadMore}>Load More</button>}
|
||||
<div data-testid="pageCount">{pageCount}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const { queryAllByTestId, getByTestId, getByText } = renderWithProviders(<TestComponent />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(() => {
|
||||
getByTestId('loading')
|
||||
}).toThrow('Unable to find an element by: [data-testid="loading"]')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(getByText(/Load More/))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(() => {
|
||||
getByTestId('loading')
|
||||
}).toThrow('Unable to find an element by: [data-testid="loading"]')
|
||||
})
|
||||
|
||||
expect(getByTestId('pageCount').innerHTML).toBe('2')
|
||||
|
||||
const items = await queryAllByTestId('listRow')
|
||||
expect(items).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('should work as expected requesting an empty page', async () => {
|
||||
server.use(
|
||||
http.get('/api/http/routers', ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
const page = url.searchParams.get('page')
|
||||
if (page === '2') {
|
||||
return HttpResponse.json(
|
||||
// Response body should be { message: 'invalid request: page: 2, per_page: 4' }, resulting in a type error.
|
||||
// If I type the response body accordingly, allowing both an array and an object, MSW breaks, so I replaced
|
||||
// the object with an empty array, and that'd be enough for testing purpose.
|
||||
[],
|
||||
{
|
||||
headers: {
|
||||
'X-Next-Page': '1',
|
||||
},
|
||||
status: 200,
|
||||
},
|
||||
)
|
||||
}
|
||||
return HttpResponse.json([{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }], {
|
||||
headers: {
|
||||
'X-Next-Page': '2',
|
||||
},
|
||||
status: 200,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const TestComponent = () => {
|
||||
const { pages, pageCount, loadMore, isLoadingMore } = useFetchWithPagination('/http/routers', {
|
||||
renderLoader: () => null,
|
||||
renderRow,
|
||||
rowsPerPage: 4,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul>{pages}</ul>
|
||||
{isLoadingMore ? <div data-testid="loading">Loading...</div> : <button onClick={loadMore}>Load More</button>}
|
||||
<div data-testid="pageCount">{pageCount}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const { queryAllByTestId, getByTestId, getByText } = renderWithProviders(<TestComponent />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(() => {
|
||||
getByTestId('loading')
|
||||
}).toThrow('Unable to find an element by: [data-testid="loading"]')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(getByText(/Load More/))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(() => {
|
||||
getByTestId('loading')
|
||||
}).toThrow('Unable to find an element by: [data-testid="loading"]')
|
||||
})
|
||||
|
||||
expect(getByTestId('pageCount').innerHTML).toBe('2')
|
||||
|
||||
const items = await queryAllByTestId('listRow')
|
||||
expect(items).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
89
webui/src/hooks/use-fetch-with-pagination.tsx
Normal file
89
webui/src/hooks/use-fetch-with-pagination.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { AriaTd, AriaTr } from '@traefiklabs/faency'
|
||||
import { stringify } from 'query-string'
|
||||
import { ReactNode } from 'react'
|
||||
import useSWRInfinite, { SWRInfiniteConfiguration } from 'swr/infinite'
|
||||
|
||||
import { fetchPage } from 'libs/fetch'
|
||||
|
||||
export type RenderRowType = (row: Record<string, unknown>) => ReactNode
|
||||
|
||||
export type pagesResponseInterface = {
|
||||
pages: ReactNode
|
||||
pageCount: number
|
||||
error?: Error | null
|
||||
isLoadingMore: boolean
|
||||
isReachingEnd: boolean
|
||||
isEmpty: boolean
|
||||
loadMore: () => void
|
||||
}
|
||||
type useFetchWithPaginationType = (
|
||||
path: string,
|
||||
opts: SWRInfiniteConfiguration & {
|
||||
rowsPerPage?: number
|
||||
renderRow: RenderRowType
|
||||
renderLoader?: () => ReactNode
|
||||
listContextKey?: string
|
||||
query?: Record<string, unknown>
|
||||
},
|
||||
) => pagesResponseInterface
|
||||
|
||||
const useFetchWithPagination: useFetchWithPaginationType = (path, opts) => {
|
||||
const defaultLoadingFunction = () => (
|
||||
<AriaTr>
|
||||
<AriaTd>Loading...</AriaTd>
|
||||
</AriaTr>
|
||||
)
|
||||
const { rowsPerPage = 10, renderLoader = defaultLoadingFunction, renderRow, query } = opts
|
||||
|
||||
const getKey = (
|
||||
pageIndex: number,
|
||||
previousPageData: { data?: unknown[]; nextPage?: number } | null,
|
||||
): string | null => {
|
||||
if (previousPageData && (!previousPageData.data?.length || previousPageData.nextPage === 1)) return null
|
||||
|
||||
return `${path}?${stringify({
|
||||
page: pageIndex + 1,
|
||||
per_page: rowsPerPage,
|
||||
...query,
|
||||
})}`
|
||||
}
|
||||
|
||||
const { data: res, error, size, setSize } = useSWRInfinite<{ data?: unknown[]; nextPage?: number }>(getKey, fetchPage)
|
||||
|
||||
const isLoadingInitialData = !res && !error
|
||||
const isEmpty = !res?.[0]?.data || (Array.isArray(res?.[0]?.data) && res?.[0]?.data.length === 0)
|
||||
const isLoadingMore = isLoadingInitialData || (size > 0 && res && typeof res[size - 1] === 'undefined') || false
|
||||
const nextPage = res?.[size - 1]?.nextPage
|
||||
const isReachingEnd = !nextPage || nextPage === 1
|
||||
|
||||
const loadMore = (): void => {
|
||||
if (!isLoadingMore) {
|
||||
setSize(size + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const data = res?.reduce((acc: unknown[], req) => {
|
||||
if (req.data) {
|
||||
acc.push(...req.data)
|
||||
}
|
||||
return acc
|
||||
}, [] as unknown[])
|
||||
|
||||
let pages: ReactNode = null
|
||||
|
||||
if (!error) {
|
||||
pages = !data ? renderLoader() : (data as Record<string, unknown>[]).map(renderRow)
|
||||
}
|
||||
|
||||
return {
|
||||
pages,
|
||||
pageCount: size,
|
||||
isEmpty,
|
||||
error,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
loadMore,
|
||||
}
|
||||
}
|
||||
|
||||
export default useFetchWithPagination
|
||||
36
webui/src/hooks/use-overview-totals.tsx
Normal file
36
webui/src/hooks/use-overview-totals.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import useSWR from 'swr'
|
||||
|
||||
type TotalsResultItem = {
|
||||
routers: number
|
||||
services: number
|
||||
middlewares?: number
|
||||
}
|
||||
|
||||
type TotalsResult = {
|
||||
http: TotalsResultItem
|
||||
tcp: TotalsResultItem
|
||||
udp: TotalsResultItem
|
||||
}
|
||||
|
||||
const useTotals = (): TotalsResult => {
|
||||
const { data } = useSWR('/overview')
|
||||
|
||||
return {
|
||||
http: {
|
||||
routers: data?.http?.routers?.total,
|
||||
services: data?.http?.services?.total,
|
||||
middlewares: data?.http?.middlewares?.total,
|
||||
},
|
||||
tcp: {
|
||||
routers: data?.tcp?.routers?.total,
|
||||
services: data?.tcp?.services?.total,
|
||||
middlewares: data?.tcp?.middlewares?.total,
|
||||
},
|
||||
udp: {
|
||||
routers: data?.udp?.routers?.total,
|
||||
services: data?.udp?.services?.total,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default useTotals
|
||||
13
webui/src/hooks/use-previous.tsx
Normal file
13
webui/src/hooks/use-previous.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
|
||||
function usePrevious<T>(value: T): T | undefined {
|
||||
const ref = useRef<T>()
|
||||
|
||||
useEffect(() => {
|
||||
ref.current = value
|
||||
}, [value])
|
||||
|
||||
return ref.current
|
||||
}
|
||||
|
||||
export default usePrevious
|
||||
60
webui/src/hooks/use-resource-detail.spec.tsx
Normal file
60
webui/src/hooks/use-resource-detail.spec.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { SWRConfig } from 'swr'
|
||||
|
||||
import { useResourceDetail } from './use-resource-detail'
|
||||
|
||||
import fetch from 'libs/fetch'
|
||||
|
||||
describe('useResourceDetail', () => {
|
||||
it('should fetch information about entrypoints and middlewares', async () => {
|
||||
const { result } = renderHook(() => useResourceDetail('server-redirect@docker', 'routers'), {
|
||||
wrapper: ({ children }) => (
|
||||
<SWRConfig
|
||||
value={{
|
||||
revalidateOnFocus: false,
|
||||
fetcher: fetch,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SWRConfig>
|
||||
),
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).not.toBeUndefined()
|
||||
})
|
||||
|
||||
const { data } = result.current
|
||||
expect(data?.name).toBe('server-redirect@docker')
|
||||
expect(data?.service).toBe('api2_v2-example-beta1')
|
||||
expect(data?.status).toBe('enabled')
|
||||
expect(data?.provider).toBe('docker')
|
||||
expect(data?.rule).toBe('Host(`server`)')
|
||||
expect(data?.tls).toBeUndefined()
|
||||
expect(data?.error).toBeUndefined()
|
||||
expect(data?.middlewares?.length).toBe(1)
|
||||
expect(data?.middlewares?.[0]).toEqual({
|
||||
redirectScheme: {
|
||||
scheme: 'https',
|
||||
},
|
||||
status: 'enabled',
|
||||
usedBy: ['server-mtls@docker', 'server-redirect@docker', 'orphan-router@file'],
|
||||
name: 'redirect@file',
|
||||
type: 'redirectscheme',
|
||||
provider: 'file',
|
||||
})
|
||||
expect(data?.hasValidMiddlewares).toBeTrue()
|
||||
expect(data?.entryPointsData?.length).toBe(1)
|
||||
expect(data?.entryPointsData?.[0]).toEqual({
|
||||
address: ':80',
|
||||
transport: {
|
||||
lifeCycle: { graceTimeOut: 10000000000 },
|
||||
respondingTimeouts: { idleTimeout: 180000000000 },
|
||||
},
|
||||
forwardedHeaders: {},
|
||||
name: 'web-redirect',
|
||||
})
|
||||
expect(data?.using?.length).toBe(1)
|
||||
expect(data?.using?.[0]).toEqual('web-redirect')
|
||||
})
|
||||
})
|
||||
180
webui/src/hooks/use-resource-detail.tsx
Normal file
180
webui/src/hooks/use-resource-detail.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import useSWR from 'swr'
|
||||
|
||||
import fetchMany from 'libs/fetchMany'
|
||||
|
||||
export type EntryPoint = {
|
||||
name: string
|
||||
address: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
type JSONObject = {
|
||||
[x: string]: string | number
|
||||
}
|
||||
export type ValuesMapType = {
|
||||
[key: string]: string | number | JSONObject
|
||||
}
|
||||
|
||||
export type MiddlewareProps = {
|
||||
[prop: string]: ValuesMapType
|
||||
}
|
||||
|
||||
export type Middleware = {
|
||||
name: string
|
||||
status: 'enabled' | 'disabled' | 'warning'
|
||||
provider: string
|
||||
type?: string
|
||||
plugin?: Record<string, unknown>
|
||||
error?: string[]
|
||||
routers?: string[]
|
||||
usedBy?: string[]
|
||||
} & MiddlewareProps
|
||||
|
||||
type Router = {
|
||||
name: string
|
||||
service?: string
|
||||
status: 'enabled' | 'disabled' | 'warning'
|
||||
rule?: string
|
||||
priority?: number
|
||||
provider: string
|
||||
tls?: {
|
||||
options: string
|
||||
certResolver: string
|
||||
domains: TlsDomain[]
|
||||
passthrough: boolean
|
||||
}
|
||||
error?: string[]
|
||||
entryPoints?: string[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
type TlsDomain = {
|
||||
main: string
|
||||
sans: string[]
|
||||
}
|
||||
|
||||
export type RouterDetailType = Router & {
|
||||
middlewares?: Middleware[]
|
||||
hasValidMiddlewares?: boolean
|
||||
entryPointsData?: EntryPoint[]
|
||||
using?: string[]
|
||||
}
|
||||
|
||||
type Mirror = {
|
||||
name: string
|
||||
percent: number
|
||||
}
|
||||
|
||||
export type ServiceDetailType = {
|
||||
name: string
|
||||
status: 'enabled' | 'disabled' | 'warning'
|
||||
provider: string
|
||||
type: string
|
||||
usedBy?: string[]
|
||||
routers?: Router[]
|
||||
serverStatus?: {
|
||||
[server: string]: string
|
||||
}
|
||||
mirroring?: {
|
||||
service: string
|
||||
mirrors?: Mirror[]
|
||||
}
|
||||
loadBalancer?: {
|
||||
servers?: { url: string }[]
|
||||
passHostHeader?: boolean
|
||||
terminationDelay?: number
|
||||
healthCheck?: {
|
||||
scheme: string
|
||||
path: string
|
||||
port: number
|
||||
interval: string
|
||||
timeout: string
|
||||
hostname: string
|
||||
headers?: {
|
||||
[header: string]: string
|
||||
}
|
||||
}
|
||||
}
|
||||
weighted?: {
|
||||
services?: {
|
||||
name: string
|
||||
weight: number
|
||||
}[]
|
||||
}
|
||||
}
|
||||
|
||||
export type MiddlewareDetailType = Middleware & {
|
||||
routers?: Router[]
|
||||
}
|
||||
|
||||
export type ResourceDetailDataType = RouterDetailType & ServiceDetailType & MiddlewareDetailType
|
||||
|
||||
type ResourceDetailType = {
|
||||
data?: ResourceDetailDataType
|
||||
error?: Error
|
||||
}
|
||||
|
||||
export const useResourceDetail = (name: string, resource: string, protocol = 'http'): ResourceDetailType => {
|
||||
const { data: routeDetail, error } = useSWR(`/${protocol}/${resource}/${name}`)
|
||||
const { data: entryPoints, error: entryPointsError } = useSWR(() => ['/entrypoints/', routeDetail.using], fetchMany)
|
||||
const { data: middlewares, error: middlewaresError } = useSWR(
|
||||
() => [`/${protocol}/middlewares/`, routeDetail.middlewares],
|
||||
fetchMany,
|
||||
)
|
||||
const { data: routers, error: routersError } = useSWR(() => [`/${protocol}/routers/`, routeDetail.usedBy], fetchMany)
|
||||
|
||||
if (!routeDetail) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
const firstError = error || entryPointsError || middlewaresError || routersError
|
||||
const validMiddlewares = (middlewares as Middleware[] | undefined)?.filter((mw) => !!mw.name)
|
||||
const hasMiddlewares = validMiddlewares
|
||||
? validMiddlewares.length > 0
|
||||
: routeDetail.middlewares && routeDetail.middlewares.length > 0
|
||||
|
||||
if (resource === 'routers') {
|
||||
return {
|
||||
data: {
|
||||
name: routeDetail.name,
|
||||
service: routeDetail.service,
|
||||
status: routeDetail.status,
|
||||
provider: routeDetail.provider,
|
||||
rule: routeDetail.rule,
|
||||
tls: routeDetail.tls,
|
||||
error: routeDetail.error,
|
||||
middlewares: validMiddlewares,
|
||||
hasValidMiddlewares: hasMiddlewares,
|
||||
entryPointsData: entryPoints,
|
||||
using: routeDetail.using,
|
||||
},
|
||||
error: firstError,
|
||||
} as ResourceDetailType
|
||||
}
|
||||
|
||||
if (resource === 'middlewares') {
|
||||
return {
|
||||
data: {
|
||||
...routeDetail,
|
||||
routers,
|
||||
},
|
||||
error: firstError,
|
||||
} as ResourceDetailType
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
name: routeDetail.name,
|
||||
status: routeDetail.status,
|
||||
provider: routeDetail.provider,
|
||||
type: routeDetail.type,
|
||||
loadBalancer: routeDetail.loadBalancer,
|
||||
mirroring: routeDetail.mirroring,
|
||||
serverStatus: routeDetail.serverStatus,
|
||||
usedBy: routeDetail.usedBy,
|
||||
weighted: routeDetail.weighted,
|
||||
routers,
|
||||
},
|
||||
error: firstError,
|
||||
} as ResourceDetailType
|
||||
}
|
||||
49
webui/src/hooks/use-theme.ts
Normal file
49
webui/src/hooks/use-theme.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useMemo } from 'react'
|
||||
import { useLocalStorage } from 'usehooks-ts'
|
||||
|
||||
const SYSTEM = 'system'
|
||||
const DARK = 'dark'
|
||||
const LIGHT = 'light'
|
||||
|
||||
type ThemeOptions = 'system' | 'dark' | 'light'
|
||||
const THEME_OPTIONS: ThemeOptions[] = [SYSTEM, DARK, LIGHT]
|
||||
|
||||
type UseThemeRes = {
|
||||
selectedTheme: ThemeOptions
|
||||
appliedTheme: ThemeOptions
|
||||
setTheme: () => void
|
||||
}
|
||||
|
||||
export const useTheme = (): UseThemeRes => {
|
||||
const [selectedTheme, setSelectedTheme] = useLocalStorage<ThemeOptions>('selected-theme', SYSTEM)
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
const appliedTheme = useMemo(() => {
|
||||
if (selectedTheme === SYSTEM) {
|
||||
if (prefersDark) return DARK
|
||||
return LIGHT
|
||||
}
|
||||
|
||||
return selectedTheme
|
||||
}, [selectedTheme, prefersDark])
|
||||
|
||||
return {
|
||||
selectedTheme,
|
||||
appliedTheme,
|
||||
setTheme: () => {
|
||||
setSelectedTheme((curr: ThemeOptions): ThemeOptions => {
|
||||
const currIdx = THEME_OPTIONS.indexOf(curr)
|
||||
const nextIdx = currIdx + 1
|
||||
if (nextIdx === THEME_OPTIONS.length) return SYSTEM
|
||||
|
||||
return THEME_OPTIONS[nextIdx]
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const useIsDarkMode = () => {
|
||||
const { appliedTheme } = useTheme()
|
||||
|
||||
return appliedTheme === DARK
|
||||
}
|
||||
13
webui/src/hooks/use-version.tsx
Normal file
13
webui/src/hooks/use-version.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { useMemo } from 'react'
|
||||
import useSWR from 'swr'
|
||||
|
||||
export default function useVersion() {
|
||||
const { data: version } = useSWR('/version')
|
||||
|
||||
const showHubButton = useMemo(() => {
|
||||
if (!version) return false
|
||||
return !version?.disableDashboardAd
|
||||
}, [version])
|
||||
|
||||
return { showHubButton, version }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue