mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
refactor(web): scope app info modal state to its view (#42882)
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user