mirror of
https://github.com/langgenius/dify.git
synced 2026-06-24 13:01:16 +08:00
refactor(web): sync deployment route state from next route (#37811)
This commit is contained in:
parent
725e4da29d
commit
ac06deba20
@ -1,13 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { DeployDrawer } from '@/features/deployments/deploy-drawer'
|
||||
import { DeploymentsRouteStateHydrator } from '@/features/deployments/route-state-hydrator'
|
||||
|
||||
export default function DeploymentsLayout({ children }: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DeploymentsRouteStateHydrator />
|
||||
{children}
|
||||
<DeployDrawer />
|
||||
</>
|
||||
|
||||
@ -7,6 +7,7 @@ import Zendesk from '@/app/components/base/zendesk'
|
||||
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
|
||||
import { GotoAnything } from '@/app/components/goto-anything'
|
||||
import MainNavLayout from '@/app/components/main-nav/layout'
|
||||
import { NextRouteStateBridge } from '@/app/components/next-route-state'
|
||||
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
|
||||
import ReadmePanel from '@/app/components/plugins/readme-panel'
|
||||
import WorkflowGeneratorMount from '@/app/components/workflow/workflow-generator/mount'
|
||||
@ -30,6 +31,7 @@ export default async function Layout({ children }: { children: ReactNode }) {
|
||||
<EventEmitterContextProvider>
|
||||
<ProviderContextProvider>
|
||||
<ModalContextProvider>
|
||||
<NextRouteStateBridge />
|
||||
<MainNavLayout>
|
||||
<RoleRouteGuard>
|
||||
{children}
|
||||
|
||||
49
web/app/components/next-route-state/atoms.ts
Normal file
49
web/app/components/next-route-state/atoms.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
export type NextRouteParams = Record<string, string | string[]>
|
||||
|
||||
type NextRouteState = {
|
||||
pathname: string
|
||||
params: NextRouteParams
|
||||
}
|
||||
|
||||
const nextRouteStateAtom = atom<NextRouteState>({
|
||||
pathname: '',
|
||||
params: {},
|
||||
})
|
||||
|
||||
function normalizedParamEntries(params: NextRouteParams) {
|
||||
return Object.keys(params)
|
||||
.sort()
|
||||
.map((key) => {
|
||||
const value = params[key]
|
||||
return [key, Array.isArray(value) ? [...value] : value] as const
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeNextRouteParams(params: NextRouteParams): NextRouteParams {
|
||||
return Object.fromEntries(normalizedParamEntries(params)) as NextRouteParams
|
||||
}
|
||||
|
||||
function routeParamsKey(params: NextRouteParams) {
|
||||
return JSON.stringify(normalizedParamEntries(params))
|
||||
}
|
||||
|
||||
export const nextPathnameAtom = atom(get => get(nextRouteStateAtom).pathname)
|
||||
|
||||
export const nextParamsAtom = atom(get => get(nextRouteStateAtom).params)
|
||||
|
||||
export const setNextRouteStateAtom = atom(null, (get, set, routeState: NextRouteState) => {
|
||||
const nextParams = normalizeNextRouteParams(routeState.params)
|
||||
const currentRouteState = get(nextRouteStateAtom)
|
||||
|
||||
if (
|
||||
currentRouteState.pathname !== routeState.pathname
|
||||
|| routeParamsKey(currentRouteState.params) !== routeParamsKey(nextParams)
|
||||
) {
|
||||
set(nextRouteStateAtom, {
|
||||
pathname: routeState.pathname,
|
||||
params: nextParams,
|
||||
})
|
||||
}
|
||||
})
|
||||
24
web/app/components/next-route-state/index.tsx
Normal file
24
web/app/components/next-route-state/index.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import type { NextRouteParams } from './atoms'
|
||||
import { useIsomorphicLayoutEffect } from 'foxact/use-isomorphic-layout-effect'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useParams, usePathname } from '@/next/navigation'
|
||||
import {
|
||||
setNextRouteStateAtom,
|
||||
} from './atoms'
|
||||
|
||||
export function NextRouteStateBridge() {
|
||||
const pathname = usePathname()
|
||||
const params = useParams<NextRouteParams>()
|
||||
const setNextRouteState = useSetAtom(setNextRouteStateAtom)
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
setNextRouteState({
|
||||
pathname,
|
||||
params,
|
||||
})
|
||||
}, [params, pathname, setNextRouteState])
|
||||
|
||||
return null
|
||||
}
|
||||
@ -2,7 +2,7 @@ import type { Getter } from 'jotai'
|
||||
import { skipToken } from '@tanstack/react-query'
|
||||
import { atom, createStore } from 'jotai'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
|
||||
import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
|
||||
|
||||
type QueryOptions = {
|
||||
enabled?: boolean
|
||||
@ -65,6 +65,13 @@ async function loadState() {
|
||||
return await import('../state')
|
||||
}
|
||||
|
||||
function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') {
|
||||
store.set(setNextRouteStateAtom, {
|
||||
pathname: `/deployments/${appInstanceId}/overview`,
|
||||
params: { appInstanceId },
|
||||
})
|
||||
}
|
||||
|
||||
describe('deployment detail state', () => {
|
||||
it('should disable detail queries with skipToken until a route app instance exists', async () => {
|
||||
const state = await loadState()
|
||||
@ -92,7 +99,7 @@ describe('deployment detail state', () => {
|
||||
const state = await loadState()
|
||||
const store = createStore()
|
||||
|
||||
store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1')
|
||||
setDeploymentRoute(store)
|
||||
store.set(state.deploymentSourceAppIdAtom, 'source-app-1')
|
||||
|
||||
expect(store.get(state.deploymentDetailAppInstanceQueryAtom)).toMatchObject({
|
||||
|
||||
@ -2,7 +2,7 @@ import type { Getter } from 'jotai'
|
||||
import { skipToken } from '@tanstack/react-query'
|
||||
import { atom, createStore } from 'jotai'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
|
||||
import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
|
||||
|
||||
type QueryOptions = {
|
||||
enabled?: boolean
|
||||
@ -57,6 +57,13 @@ async function loadState() {
|
||||
return await import('../state')
|
||||
}
|
||||
|
||||
function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') {
|
||||
store.set(setNextRouteStateAtom, {
|
||||
pathname: `/deployments/${appInstanceId}/overview`,
|
||||
params: { appInstanceId },
|
||||
})
|
||||
}
|
||||
|
||||
describe('versions tab state', () => {
|
||||
it('should gate release history and menu queries until route and menu state are ready', async () => {
|
||||
const state = await loadState()
|
||||
@ -79,7 +86,7 @@ describe('versions tab state', () => {
|
||||
it('should build release history input from the current page', async () => {
|
||||
const state = await loadState()
|
||||
const store = createStore()
|
||||
store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1')
|
||||
setDeploymentRoute(store)
|
||||
|
||||
store.set(state.setReleaseHistoryCurrentPageAtom, -1)
|
||||
expect(store.get(state.releaseHistoryCurrentPageAtom)).toBe(0)
|
||||
@ -99,7 +106,7 @@ describe('versions tab state', () => {
|
||||
it('should scope deploy menu queries to the open release id', async () => {
|
||||
const state = await loadState()
|
||||
const store = createStore()
|
||||
store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1')
|
||||
setDeploymentRoute(store)
|
||||
|
||||
store.set(state.setDeployReleaseMenuOpenAtom, {
|
||||
releaseId: 'release-1',
|
||||
|
||||
@ -6,10 +6,7 @@ import type {
|
||||
import type { Getter } from 'jotai'
|
||||
import { atom, createStore } from 'jotai'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
deploymentRouteAppInstanceIdAtom,
|
||||
deploymentsRouteActiveAtom,
|
||||
} from '../../route-state'
|
||||
import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
|
||||
|
||||
type QueryOptions = {
|
||||
enabled?: boolean
|
||||
@ -120,6 +117,13 @@ function setListAppInstances(appInstances: AppInstance[]) {
|
||||
})
|
||||
}
|
||||
|
||||
function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') {
|
||||
store.set(setNextRouteStateAtom, {
|
||||
pathname: `/deployments/${appInstanceId}/overview`,
|
||||
params: { appInstanceId },
|
||||
})
|
||||
}
|
||||
|
||||
describe('deployments nav state', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -137,8 +141,7 @@ describe('deployments nav state', () => {
|
||||
it('should append the current route item when it is missing from the list query', async () => {
|
||||
const state = await loadState()
|
||||
const store = createStore()
|
||||
store.set(deploymentsRouteActiveAtom, true)
|
||||
store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1')
|
||||
setDeploymentRoute(store)
|
||||
setListAppInstances([
|
||||
appInstance({
|
||||
id: 'app-instance-2',
|
||||
@ -172,8 +175,7 @@ describe('deployments nav state', () => {
|
||||
it('should use the route id as a fallback current item name', async () => {
|
||||
const state = await loadState()
|
||||
const store = createStore()
|
||||
store.set(deploymentsRouteActiveAtom, true)
|
||||
store.set(deploymentRouteAppInstanceIdAtom, 'app-instance-1')
|
||||
setDeploymentRoute(store)
|
||||
setListAppInstances([])
|
||||
|
||||
expect(store.get(state.deploymentsNavItemsAtom)).toMatchObject([
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useHydrateAtoms } from 'jotai/react/utils'
|
||||
import { useEffect } from 'react'
|
||||
import { useParams } from '@/next/navigation'
|
||||
import {
|
||||
deploymentRouteAppInstanceIdAtom,
|
||||
deploymentsRouteActiveAtom,
|
||||
} from './route-state'
|
||||
|
||||
function routeAppInstanceId(params?: { appInstanceId?: string | string[] }) {
|
||||
return typeof params?.appInstanceId === 'string' ? params.appInstanceId : undefined
|
||||
}
|
||||
|
||||
export function DeploymentsRouteStateHydrator() {
|
||||
const params = useParams<{ appInstanceId?: string | string[] }>()
|
||||
const appInstanceId = routeAppInstanceId(params)
|
||||
const setDeploymentsRouteActive = useSetAtom(deploymentsRouteActiveAtom)
|
||||
const setRouteAppInstanceId = useSetAtom(deploymentRouteAppInstanceIdAtom)
|
||||
|
||||
useHydrateAtoms([
|
||||
[deploymentsRouteActiveAtom, true],
|
||||
[deploymentRouteAppInstanceIdAtom, appInstanceId],
|
||||
] as const, { dangerouslyForceHydrate: true })
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setDeploymentsRouteActive(false)
|
||||
setRouteAppInstanceId(undefined)
|
||||
}
|
||||
}, [setDeploymentsRouteActive, setRouteAppInstanceId])
|
||||
|
||||
return null
|
||||
}
|
||||
@ -1,6 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { atom } from 'jotai'
|
||||
import {
|
||||
nextParamsAtom,
|
||||
nextPathnameAtom,
|
||||
} from '@/app/components/next-route-state/atoms'
|
||||
|
||||
export const deploymentsRouteActiveAtom = atom(false)
|
||||
export const deploymentRouteAppInstanceIdAtom = atom<string | undefined>(undefined)
|
||||
function isDeploymentsRoute(pathname: string) {
|
||||
return pathname === '/deployments' || pathname.startsWith('/deployments/')
|
||||
}
|
||||
|
||||
export const deploymentsRouteActiveAtom = atom(get => isDeploymentsRoute(get(nextPathnameAtom)))
|
||||
|
||||
export const deploymentRouteAppInstanceIdAtom = atom((get) => {
|
||||
const appInstanceId = get(nextParamsAtom).appInstanceId
|
||||
|
||||
return typeof appInstanceId === 'string' ? appInstanceId : undefined
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user