refactor(web): scope app info modal state to its view (#42882)

This commit is contained in:
yyh authored and GitHub committed 2026-09-24 08:53:50 +00:00
1 parent ef33df5e22
commit 54c2928734
6 files changed
+125 -117

No files matched your search

@@ -54,10 +54,6 @@ vi.mock('../app-info', () => ({
AppInfoView: () => <div />,
}))
vi.mock('../app-info/use-app-info-actions', () => ({
useAppInfoActions: vi.fn(() => ({})),
}))
vi.mock('../nav-link', () => ({
default: ({ name, href }: { name: string; href: string }) => <a href={href}>{name}</a>,
}))
@@ -14,7 +14,6 @@ import { usePathname } from '@/next/navigation'
import { AppModeEnum } from '@/types/app'
import { getAppACLCapabilities } from '@/utils/permission'
import { AppInfoView } from './app-info'
import { useAppInfoActions } from './app-info/use-app-info-actions'
import NavLink from './nav-link'
type AppDetailNavItem = {
@@ -52,9 +51,6 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const isRbacEnabled = systemFeatures.rbac_enabled
const appDetail = useStore((state) => state.appDetail)
const appInfoActions = useAppInfoActions({
resetKey: appDetail?.id,
})
const navigation = useMemo<AppDetailNavItem[]>(() => {
if (!appDetail) return []
@@ -164,7 +160,7 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
</div>
)}
<div className={cn('px-1 py-2', expand && '-mx-2')}>
<AppInfoView expand={expand} actions={appInfoActions} />
<AppInfoView key={appDetail.id} expand={expand} />
</div>
<nav className={cn('flex flex-col gap-y-0.5 py-1', expand ? 'px-1' : 'px-3')}>
{navigation.map((item) => {
@@ -0,0 +1,93 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import AppDetailSection from '../../app-detail-section'
let currentApp = { id: 'app-1', name: 'First app', mode: 'chat' }
const mockConsoleState = vi.hoisted(() => ({
current: {
userProfile: { id: 'user-1' },
workspacePermissionKeys: [] as string[],
},
}))
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({ appDetail: currentApp, setAppDetail: vi.fn() }),
}))
vi.mock('@/context/permission-state', async () => {
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
return createPermissionStateModuleMock(() => mockConsoleState.current)
})
vi.mock('@/next/navigation', () => ({
usePathname: () => '/app/app-1/configuration',
useRouter: () => ({ replace: vi.fn() }),
}))
vi.mock('@/app/components/app/use-export-app-dsl', () => ({
useExportAppDsl: () => ({ exportAppDsl: vi.fn(), isExporting: false }),
useExportWorkflowAppDsl: () => ({ exportWorkflowAppDsl: vi.fn(), isExporting: false }),
}))
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
collaborationManager: { onAppMetaUpdate: () => () => {} },
}))
vi.mock('../app-info-trigger', () => ({
default: ({
appDetail,
openModal,
}: {
appDetail: { name: string }
openModal: (modal: 'edit') => void
}) => <button onClick={() => openModal('edit')}>Open {appDetail.name}</button>,
}))
vi.mock('../app-info-modals', () => ({
default: ({
appDetail,
activeModal,
secretEnvList,
setSecretEnvList,
}: {
appDetail: { name: string }
activeModal: string | null
secretEnvList: unknown[]
setSecretEnvList: (list: unknown[]) => void
}) => (
<div>
<p>{`${appDetail.name}: ${activeModal ?? 'closed'}; secrets: ${secretEnvList.length}`}</p>
<button onClick={() => setSecretEnvList([{}])}>Add secret</button>
</div>
),
}))
describe('AppInfoView identity in the app detail sidebar', () => {
beforeEach(() => {
currentApp = { id: 'app-1', name: 'First app', mode: 'chat' }
})
it('keeps transient state for the same app and clears it when the app changes', async () => {
const user = userEvent.setup()
const view = renderWithConsoleQuery(<AppDetailSection />, {
systemFeatures: { rbac_enabled: false, enable_app_deploy: false },
})
await user.click(screen.getByRole('button', { name: 'Open First app' }))
await user.click(screen.getByRole('button', { name: 'Add secret' }))
expect(screen.getByText('First app: edit; secrets: 1')).toBeInTheDocument()
screen.getByRole('button', { name: 'Open First app' }).focus()
currentApp = { id: 'app-1', name: 'Renamed app', mode: 'chat' }
view.rerender(<AppDetailSection />)
expect(screen.getByText('Renamed app: edit; secrets: 1')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Open Renamed app' })).toHaveFocus()
currentApp = { id: 'app-2', name: 'Second app', mode: 'chat' }
view.rerender(<AppDetailSection />)
expect(screen.getByRole('button', { name: 'Open Second app' })).toBeInTheDocument()
expect(screen.getByText('Second app: closed; secrets: 0')).toBeInTheDocument()
})
})
@@ -144,35 +144,16 @@ describe('useAppInfoActions', () => {
describe('Initial state', () => {
it('should return initial state correctly', () => {
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
expect(result.current.appDetail).toEqual(mockAppDetail)
expect(result.current.activeModal).toBeNull()
expect(result.current.secretEnvList).toEqual([])
})
})
describe('App-scoped state', () => {
it('should reset app-scoped state when resetKey changes', () => {
const { result, rerender } = renderHook(({ resetKey }) => useAppInfoActions({ resetKey }), {
initialProps: { resetKey: 'app-1' },
})
act(() => {
result.current.openModal('delete')
})
expect(result.current.activeModal).toBe('delete')
rerender({ resetKey: 'app-2' })
expect(result.current.activeModal).toBeNull()
expect(result.current.secretEnvList).toEqual([])
})
})
describe('Modal management', () => {
it('should open modal', () => {
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
act(() => {
result.current.openModal('edit')
@@ -182,7 +163,7 @@ describe('useAppInfoActions', () => {
})
it('should close modal', () => {
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
act(() => {
result.current.openModal('delete')
@@ -201,7 +182,7 @@ describe('useAppInfoActions', () => {
const updatedApp = { ...mockAppDetail, name: 'Updated', max_active_requests: null }
mockUpdateAppInfo.mockResolvedValue(updatedApp)
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.onEdit({
@@ -244,7 +225,7 @@ describe('useAppInfoActions', () => {
mockUpdateAppInfo.mockResolvedValue(updatedApp)
mockGetSocket.mockReturnValue(socket)
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.onEdit({
@@ -270,7 +251,7 @@ describe('useAppInfoActions', () => {
it('should notify error on edit failure', async () => {
mockUpdateAppInfo.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.onEdit({
@@ -289,7 +270,7 @@ describe('useAppInfoActions', () => {
it('should not call updateAppInfo when appDetail is undefined', async () => {
mockAppDetail = undefined
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.onEdit({
@@ -321,7 +302,7 @@ describe('useAppInfoActions', () => {
},
)
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.onCopy({
@@ -355,7 +336,7 @@ describe('useAppInfoActions', () => {
it('should notify error on copy failure', async () => {
mockCopyApp.mockRejectedValue(new Error('fail'))
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.onCopy({
@@ -377,7 +358,7 @@ describe('useAppInfoActions', () => {
it('should not call copyApp when appDetail is undefined', async () => {
mockAppDetail = undefined
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.onCopy({
@@ -394,7 +375,7 @@ describe('useAppInfoActions', () => {
describe('onExport', () => {
it('should export the app DSL', async () => {
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.onExport(false)
@@ -410,7 +391,7 @@ describe('useAppInfoActions', () => {
it('preserves the export failure result for the confirmation dialog', async () => {
mockExportAppDsl.mockResolvedValue({ status: 'failed' })
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await expect(result.current.onExport(true)).resolves.toBe(false)
})
@@ -420,7 +401,7 @@ describe('useAppInfoActions', () => {
it('should not export when appDetail is undefined', async () => {
mockAppDetail = undefined
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.onExport()
@@ -432,7 +413,7 @@ describe('useAppInfoActions', () => {
describe('exportCheck', () => {
it('should call onExport directly for non-workflow modes', async () => {
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.exportCheck()
@@ -444,7 +425,7 @@ describe('useAppInfoActions', () => {
it('should open export warning modal for workflow mode', async () => {
mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW }
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.exportCheck()
@@ -456,7 +437,7 @@ describe('useAppInfoActions', () => {
it('should open export warning modal for advanced_chat mode', async () => {
mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.ADVANCED_CHAT }
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.exportCheck()
@@ -470,7 +451,7 @@ describe('useAppInfoActions', () => {
it('should not do anything when appDetail is undefined', async () => {
mockAppDetail = undefined
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.exportCheck()
@@ -486,7 +467,7 @@ describe('useAppInfoActions', () => {
mockExportWorkflowAppDsl
.mockResolvedValueOnce({ status: 'failed' })
.mockResolvedValueOnce({ status: 'downloaded' })
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.exportCheck()
})
@@ -502,7 +483,7 @@ describe('useAppInfoActions', () => {
it('should export directly when no secret env variables', async () => {
mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW }
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.handleConfirmExport()
@@ -522,7 +503,7 @@ describe('useAppInfoActions', () => {
secretEnvList: secretVars,
})
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.handleConfirmExport()
@@ -536,7 +517,7 @@ describe('useAppInfoActions', () => {
it('should not do anything when appDetail is undefined', async () => {
mockAppDetail = undefined
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.handleConfirmExport()
@@ -550,7 +531,7 @@ describe('useAppInfoActions', () => {
it('should delete app and redirect on success', async () => {
mockDeleteApp.mockResolvedValue({})
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.onConfirmDelete()
@@ -568,7 +549,7 @@ describe('useAppInfoActions', () => {
it('should not delete when appDetail is undefined', async () => {
mockAppDetail = undefined
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.onConfirmDelete()
@@ -580,7 +561,7 @@ describe('useAppInfoActions', () => {
it('should notify error on delete failure', async () => {
mockDeleteApp.mockRejectedValue({ message: 'cannot delete' })
const { result } = renderHook(() => useAppInfoActions({}))
const { result } = renderHook(() => useAppInfoActions())
await act(async () => {
await result.current.onConfirmDelete()
@@ -608,7 +589,7 @@ describe('useAppInfoActions', () => {
})
mockFetchAppDetail.mockResolvedValue(updated)
const { unmount } = renderHook(() => useAppInfoActions({}))
const { unmount } = renderHook(() => useAppInfoActions())
await new Promise((resolve) => setTimeout(resolve, 0))
await act(async () => {
@@ -1,13 +1,12 @@
import type { AppInfoActions } from './use-app-info-actions'
import AppInfoModals from './app-info-modals'
import AppInfoTrigger from './app-info-trigger'
import { useAppInfoActions } from './use-app-info-actions'
type AppInfoViewProps = {
expand: boolean
actions: AppInfoActions
}
export const AppInfoView = ({ expand, actions }: AppInfoViewProps) => {
export const AppInfoView = ({ expand }: AppInfoViewProps) => {
const {
appDetail,
activeModal,
@@ -22,7 +21,7 @@ export const AppInfoView = ({ expand, actions }: AppInfoViewProps) => {
exportCheck,
handleConfirmExport,
onConfirmDelete,
} = actions
} = useAppInfoActions()
if (!appDetail) return null
@@ -2,7 +2,6 @@ import type {
AppDetailWithSite,
EnvironmentVariableItemResponse,
} from '@dify/contracts/api/console/apps/types.gen'
import type { Dispatch, SetStateAction } from 'react'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { App } from '@/types/app'
@@ -33,18 +32,6 @@ export type AppInfoModalType =
| 'exportWarning'
| null
type UseAppInfoActionsParams = {
resetKey?: string
}
type AppInfoUiState = {
resetKey?: string
activeModal: AppInfoModalType
secretEnvList: EnvironmentVariableItemResponse[]
}
const emptySecretEnvList: EnvironmentVariableItemResponse[] = []
type AppMetadata = Pick<
App,
| 'description'
@@ -78,21 +65,7 @@ const updateCachedAppMetadata = (cachedApp: AppDetailWithSite | undefined, app:
}
}
const createInitialUiState = (resetKey?: string): AppInfoUiState => ({
resetKey,
activeModal: null,
secretEnvList: [],
})
const resolveStateAction = <T>(value: SetStateAction<T>, previous: T) => {
return typeof value === 'function' ? (value as (previous: T) => T)(previous) : value
}
const getCurrentUiState = (state: AppInfoUiState, resetKey?: string) => {
return state.resetKey === resetKey ? state : createInitialUiState(resetKey)
}
export function useAppInfoActions({ resetKey }: UseAppInfoActionsParams) {
export function useAppInfoActions() {
const { t } = useTranslation(['app'])
const { replace } = useRouter()
const queryClient = useQueryClient()
@@ -108,36 +81,8 @@ export function useAppInfoActions({ resetKey }: UseAppInfoActionsParams) {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isRbacEnabled = systemFeatures.rbac_enabled
const [uiState, setUiState] = useState(() => createInitialUiState(resetKey))
const uiStateMatchesResetKey = uiState.resetKey === resetKey
const activeModal = uiStateMatchesResetKey ? uiState.activeModal : null
const secretEnvList = uiStateMatchesResetKey ? uiState.secretEnvList : emptySecretEnvList
const setActiveModal = useCallback<Dispatch<SetStateAction<AppInfoModalType>>>(
(value) => {
setUiState((state) => {
const current = getCurrentUiState(state, resetKey)
return {
...current,
activeModal: resolveStateAction(value, current.activeModal),
}
})
},
[resetKey],
)
const setSecretEnvList = useCallback<Dispatch<SetStateAction<EnvironmentVariableItemResponse[]>>>(
(value) => {
setUiState((state) => {
const current = getCurrentUiState(state, resetKey)
return {
...current,
secretEnvList: resolveStateAction(value, current.secretEnvList),
}
})
},
[resetKey],
)
const [activeModal, setActiveModal] = useState<AppInfoModalType>(null)
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariableItemResponse[]>([])
const openModal = useCallback(
(modal: Exclude<AppInfoModalType, null>) => {
@@ -353,5 +298,3 @@ export function useAppInfoActions({ resetKey }: UseAppInfoActionsParams) {
onConfirmDelete,
}
}
export type AppInfoActions = ReturnType<typeof useAppInfoActions>