refactor(web): consolidate settings state in URL (#39740)

This commit is contained in:
yyh 2026-07-29 14:00:55 +08:00 committed by GitHub
parent 8629b91517
commit d4eab4ae88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
91 changed files with 746 additions and 1547 deletions

View File

@ -4,11 +4,10 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NotionPageSelector from '@/app/components/base/notion-page-selector/base'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
const mockInvalidPreImportNotionPages = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockSetSettingsDestination = vi.fn()
const mockUsePreImportNotionPages = vi.fn()
vi.mock('@tanstack/react-virtual', () => ({
@ -29,16 +28,10 @@ vi.mock('@/service/knowledge/use-import', () => ({
useInvalidPreImportNotionPages: () => mockInvalidPreImportNotionPages,
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
useModalContextSelector: (
selector: (state: {
setShowAccountSettingModal: typeof mockSetShowAccountSettingModal
}) => unknown,
) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
const buildCredential = (
id: string,
@ -200,8 +193,6 @@ describe('Base Notion Page Selector Flow', () => {
await user.click(
screen.getByRole('button', { name: 'common.dataSource.notion.selector.configure' }),
)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
})
})

View File

@ -41,7 +41,6 @@ const render = (ui: ReactElement, options: RenderOptions = {}) => {
}
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockProviderCtx,
@ -64,10 +63,6 @@ vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowPricingModal: mockSetShowPricingModal,
}),
useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) =>
selector({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('@/context/i18n', () => ({

View File

@ -42,10 +42,8 @@ const render = (ui: ReactElement, options: RenderOptions = {}) => {
// ─── Mock state ──────────────────────────────────────────────────────────────
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockRouterPush = vi.fn()
const mockMutateAsync = vi.fn()
const mockSetEducationVerifying = vi.hoisted(() => vi.fn())
// ─── Context mocks ───────────────────────────────────────────────────────────
vi.mock('@/context/provider-context', () => ({
@ -69,10 +67,6 @@ vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowPricingModal: mockSetShowPricingModal,
}),
useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) =>
selector({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
// ─── Service mocks ───────────────────────────────────────────────────────────
@ -102,10 +96,6 @@ vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => vi.fn(),
}))
vi.mock('@/app/education-apply/storage', () => ({
useSetEducationVerifying: () => mockSetEducationVerifying,
}))
// ─── External component mocks ───────────────────────────────────────────────
vi.mock('@/app/education-apply/verify-state-modal', () => ({
default: ({
@ -249,20 +239,6 @@ describe('Education Verification Flow', () => {
expect(mockRouterPush).toHaveBeenCalledWith('/education-apply?token=edu-token-123')
})
})
it('should clear education verifying flag on success', async () => {
mockMutateAsync.mockResolvedValue({ token: 'token-xyz' })
setupContexts({}, { enableEducationPlan: true, isEducationAccount: false })
const user = userEvent.setup()
render(<PlanComp loc="test" />)
await user.click(screen.getByText(/toVerified/i))
await waitFor(() => {
expect(mockSetEducationVerifying).toHaveBeenCalledWith(null)
})
})
})
// ─── 3. Failed Verification Flow ────────────────────────────────────────

View File

@ -1,5 +1,6 @@
'use client'
import { SettingsModal } from '@/app/components/header/account-setting/settings-modal'
import dynamic from '@/next/dynamic'
const InSiteMessageNotification = dynamic(
@ -25,6 +26,7 @@ export function CommonLayoutGlobalMounts() {
<ReadmePanel />
<GotoAnything />
<WorkflowGeneratorMount />
<SettingsModal />
</>
)
}

View File

@ -1,5 +1,4 @@
import type { ReactNode } from 'react'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider'
@ -12,7 +11,6 @@ export async function ConsoleRuntimeProviders({ children }: { children: ReactNod
return (
<>
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<ProfileBootstrapGate>
<ExternalServiceSync />

View File

@ -1,47 +0,0 @@
import { render, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION } from '@/app/education-apply/constants'
import { useSearchParams } from '@/next/navigation'
import { EducationVerifyActionRecorder } from '../education-verify-action-recorder'
const setEducationVerifyingMock = vi.hoisted(() => vi.fn())
vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(),
}))
vi.mock('@/app/education-apply/storage', () => ({
useSetEducationVerifying: () => setEducationVerifyingMock,
}))
const mockUseSearchParams = vi.mocked(useSearchParams)
describe('EducationVerifyActionRecorder', () => {
beforeEach(() => {
vi.clearAllMocks()
window.localStorage.clear()
mockUseSearchParams.mockReturnValue(
new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>,
)
})
it('should store the education verification flag when the callback action is present', async () => {
mockUseSearchParams.mockReturnValue(
new URLSearchParams(
`action=${EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION}`,
) as unknown as ReturnType<typeof useSearchParams>,
)
render(<EducationVerifyActionRecorder />)
await waitFor(() => {
expect(setEducationVerifyingMock).toHaveBeenCalledWith('yes')
})
})
it('should leave localStorage unchanged for unrelated routes', () => {
render(<EducationVerifyActionRecorder />)
expect(setEducationVerifyingMock).not.toHaveBeenCalled()
})
})

View File

@ -5,7 +5,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import {
@ -50,7 +49,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
}))
const mockOnCancel = vi.fn()
const mockOnSave = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockSetSettingsDestination = vi.fn()
const mockUseModelList = vi.fn()
const mockUseModelListAndDefaultModel = vi.fn()
@ -81,11 +80,10 @@ vi.mock('@/service/use-common', async () => ({
useMembers: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs${path}`,
@ -396,9 +394,7 @@ describe('SettingsModal', () => {
)
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.PROVIDER,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
})
})

View File

@ -9,6 +9,7 @@ import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { RiCloseLine } from '@remixicon/react'
import { isEqual } from 'es-toolkit/predicate'
import { useQueryState } from 'nuqs'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
@ -17,11 +18,13 @@ import { IndexingType } from '@/app/components/datasets/create/step-two'
import IndexMethod from '@/app/components/datasets/settings/index-method'
import PermissionSelector from '@/app/components/datasets/settings/permission-selector'
import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useDocLink } from '@/context/i18n'
import { DatasetPermission } from '@/models/datasets'
import { updateDatasetSetting } from '@/service/datasets'
@ -56,7 +59,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
const docLink = useDocLink()
const ref = useRef(null)
const isExternal = currentDataset.provider === 'external'
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const [loading, setLoading] = useState(false)
const [localeCurrentDataset, setLocaleCurrentDataset] = useState({ ...currentDataset })
const [topK, setTopK] = useState(localeCurrentDataset?.external_retrieval_model.top_k ?? 2)
@ -315,7 +318,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={() => openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER })}
onClick={() => setSettingsDestination('provider')}
>
{t(($) => $['form.embeddingModelTipLink'], { ns: 'datasetSettings' })}
</button>

View File

@ -6,7 +6,7 @@ import { AppModeEnum, ModelModeType } from '@/types/app'
import { AppACLPermission } from '@/utils/permission'
import { useConfiguration } from '../use-configuration'
const mockSetShowAccountSettingModal = vi.fn()
const mockSetSettingsDestination = vi.fn()
const mockSetShowAppConfigureFeaturesModal = vi.fn()
const mockSetDetailSidebarMode = vi.fn()
const mockHandleMultipleModelConfigsChange = vi.fn()
@ -73,11 +73,10 @@ vi.mock('@/context/permission-state', async () => {
}))
})
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
@ -493,7 +492,7 @@ describe('useConfiguration', () => {
expect(mockFormattingChangedDispatcher).toHaveBeenCalled()
expect(mockHandleMultipleModelConfigsChange).toHaveBeenCalled()
expect(mockSetDetailSidebarMode).toHaveBeenCalledWith('collapse')
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'provider' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
expect(mockSetConversationHistoriesRole).toHaveBeenCalledWith({
assistant_prefix: 'bot',
user_prefix: 'user',

View File

@ -29,6 +29,7 @@ import { useBoolean, useGetState } from 'ahooks'
import { clone } from 'es-toolkit/object'
import { produce } from 'immer'
import { useAtomValue } from 'jotai'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
@ -39,7 +40,6 @@ import {
import useAdvancedPromptConfig from '@/app/components/app/configuration/hooks/use-advanced-prompt-config'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useSetDetailSidebarMode } from '@/app/components/detail-sidebar/storage'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import {
ModelFeatureEnum,
ModelTypeEnum,
@ -48,7 +48,10 @@ import {
useModelListAndDefaultModelAndCurrentProviderAndModel,
useTextGenerationCurrentProviderAndModelAndModelList,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import {
ANNOTATION_DEFAULT,
DATASET_DEFAULT,
@ -128,7 +131,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const currentUserId = useAtomValue(userProfileIdAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const { appDetail, showAppConfigureFeaturesModal, setShowAppConfigureFeaturesModal } =
useAppStore(
@ -769,7 +772,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
onCloseSelectDataSet: hideSelectDataSet,
onCompletionParamsChange: setCompletionParams,
onConfirmUseGPT4: () => {
openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
setSettingsDestination('provider')
setShowUseGPT4Confirm(false)
},
onEnableMultipleModelDebug: handleDebugWithMultipleModelChange,
@ -777,7 +780,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
onHideDebugPanel: hideDebugPanel,
onModelChange: setModel,
onMultipleModelConfigsChange: handleMultipleModelConfigsChange,
onOpenAccountSettings: () => openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER }),
onOpenAccountSettings: () => setSettingsDestination('provider'),
onOpenDebugPanel: showDebugPanel,
onSaveHistory: (data) => {
setConversationHistoriesRole(data)

View File

@ -3,8 +3,6 @@ import userEvent from '@testing-library/user-event'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { defaultPlan } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render } from '@/test/console/render'
@ -26,16 +24,13 @@ vi.mock('@/context/provider-context', async (importOriginal) => {
}
})
vi.mock('@/context/modal-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/modal-context')>()
return {
...actual,
useModalContextSelector: vi.fn(),
}
const setSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, setSettingsDestination] }
})
const mockUseProviderContext = vi.mocked(useProviderContext)
const mockUseModalContextSelector = vi.mocked(useModalContextSelector)
function mockProviderPlan(planType: Plan) {
mockUseProviderContext.mockReturnValue(
@ -50,7 +45,6 @@ function mockProviderPlan(planType: Plan) {
}
describe('ArchivedLogsNotice', () => {
const setShowAccountSettingModal = vi.fn()
const renderNotice = () => {
const { wrapper } = createConsoleQueryWrapper({
systemFeatures: { deployment_edition: 'CLOUD' },
@ -61,11 +55,6 @@ describe('ArchivedLogsNotice', () => {
beforeEach(() => {
vi.clearAllMocks()
mockProviderPlan(Plan.professional)
mockUseModalContextSelector.mockImplementation((selector) =>
selector({
setShowAccountSettingModal,
} as unknown as Parameters<typeof selector>[0]),
)
})
it('should show an accessible notice for paid workspace managers', async () => {
@ -78,9 +67,7 @@ describe('ArchivedLogsNotice', () => {
expect(within(notice).getByText('appLog.archives.notice.description')).toBeInTheDocument()
await user.click(within(notice).getByRole('button', { name: 'appLog.archives.notice.action' }))
expect(setShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
})
expect(setSettingsDestination).toHaveBeenCalledWith('workflow-log-archives')
})
it('should not show notice for sandbox workspaces', () => {

View File

@ -3,10 +3,13 @@
import { Button } from '@langgenius/dify-ui/button'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useQueryState } from 'nuqs'
import { useTranslation } from 'react-i18next'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContextSelector } from '@/context/modal-context'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useProviderContext } from '@/context/provider-context'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
@ -19,9 +22,7 @@ export function ArchivedLogsNotice() {
})
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const { enableBilling, plan } = useProviderContext()
const setShowAccountSettingModal = useModalContextSelector(
(state) => state.setShowAccountSettingModal,
)
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
if (
deploymentEdition !== 'CLOUD' ||
@ -53,11 +54,7 @@ export function ArchivedLogsNotice() {
<Button
variant="primary"
className="shrink-0"
onClick={() =>
setShowAccountSettingModal({
payload: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
})
}
onClick={() => setSettingsDestination('workflow-log-archives')}
>
{t(($) => $['archives.notice.action'], { ns: 'appLog' })}
</Button>

View File

@ -1,9 +1,8 @@
import { cleanup, screen } from '@testing-library/react'
import {
clearAllMocks,
defaultModalContext,
interactions,
mockUseModalContext,
mockSetSettingsDestination,
scenarios,
setDeploymentEdition,
} from './test-utils'
@ -11,15 +10,9 @@ import {
afterEach(cleanup)
describe('APIKeyInfoPanel - Cloud Edition', () => {
const setShowAccountSettingModal = vi.fn()
beforeEach(() => {
clearAllMocks()
setDeploymentEdition('CLOUD')
mockUseModalContext.mockReturnValue({
...defaultModalContext,
setShowAccountSettingModal,
})
})
it('hides the panel when an API key already exists', () => {
@ -28,9 +21,9 @@ describe('APIKeyInfoPanel - Cloud Edition', () => {
})
it('opens provider settings from the primary action', () => {
scenarios.withMockModal(setShowAccountSettingModal)
scenarios.withAPIKeyNotSet()
interactions.clickMainButton()
expect(setShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'provider' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
})
it('does not show the self-hosted Cloud link', () => {

View File

@ -1,9 +1,8 @@
import { cleanup, screen } from '@testing-library/react'
import {
clearAllMocks,
defaultModalContext,
interactions,
mockUseModalContext,
mockSetSettingsDestination,
scenarios,
setDeploymentEdition,
textKeys,
@ -12,15 +11,9 @@ import {
afterEach(cleanup)
describe('APIKeyInfoPanel - Community Edition', () => {
const setShowAccountSettingModal = vi.fn()
beforeEach(() => {
clearAllMocks()
setDeploymentEdition('COMMUNITY')
mockUseModalContext.mockReturnValue({
...defaultModalContext,
setShowAccountSettingModal,
})
})
it('hides the panel when an API key already exists', () => {
@ -29,9 +22,9 @@ describe('APIKeyInfoPanel - Community Edition', () => {
})
it('opens provider settings from the primary action', () => {
scenarios.withMockModal(setShowAccountSettingModal)
scenarios.withAPIKeyNotSet()
interactions.clickMainButton()
expect(setShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'provider' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
})
it('links self-hosted users to Dify Cloud safely', () => {

View File

@ -1,20 +1,16 @@
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
import type { RenderOptions } from '@testing-library/react'
import type { Mock, MockedFunction } from 'vitest'
import type { ModalContextState } from '@/context/modal-context'
import type { MockedFunction } from 'vitest'
import { fireEvent, screen } from '@testing-library/react'
import { noop } from 'es-toolkit/function'
import { defaultPlan } from '@/app/components/billing/config'
import {
useModalContext as actualUseModalContext,
useModalContextSelector as actualUseModalContextSelector,
} from '@/context/modal-context'
import { useProviderContext as actualUseProviderContext } from '@/context/provider-context'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import APIKeyInfoPanel from '../index'
const { mockRouterPush } = vi.hoisted(() => ({
const { mockRouterPush, mockSetSettingsDestination } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockSetSettingsDestination: vi.fn(),
}))
// Mock the modules before importing the functions
@ -22,10 +18,13 @@ vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
useModalContextSelector: vi.fn(),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return {
...actual,
useQueryState: () => [null, mockSetSettingsDestination],
}
})
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
@ -37,11 +36,6 @@ vi.mock('@/next/navigation', () => ({
const mockUseProviderContext = actualUseProviderContext as MockedFunction<
typeof actualUseProviderContext
>
const mockUseModalContext = actualUseModalContext as MockedFunction<typeof actualUseModalContext>
const mockUseModalContextSelector = actualUseModalContextSelector as MockedFunction<
typeof actualUseModalContextSelector
>
// Default mock data
const defaultProviderContext = {
modelProviders: [],
@ -78,25 +72,8 @@ const defaultProviderContext = {
humanInputEmailDeliveryEnabled: false,
}
const defaultModalContext: ModalContextState = {
hasBlockingModalOpen: false,
setShowAccountSettingModal: noop,
setShowModerationSettingModal: noop,
setShowExternalDataToolModal: noop,
setShowPricingModal: noop,
setShowAnnotationFullModal: noop,
setShowModelModal: noop,
setShowExternalKnowledgeAPIModal: noop,
setShowModelLoadBalancingModal: noop,
setShowOpeningModal: noop,
setShowUpdatePluginModal: noop,
setShowEducationExpireNoticeModal: noop,
setShowTriggerEventsLimitModal: noop,
}
type MockOverrides = {
providerContext?: Partial<typeof defaultProviderContext>
modalContext?: Partial<typeof defaultModalContext>
}
type APIKeyInfoPanelRenderOptions = {
@ -112,18 +89,6 @@ function setupMocks(overrides: MockOverrides = {}) {
...defaultProviderContext,
...overrides.providerContext,
})
mockUseModalContext.mockReturnValue({
...defaultModalContext,
...overrides.modalContext,
})
mockUseModalContextSelector.mockImplementation((selector) =>
selector({
...defaultModalContext,
...overrides.modalContext,
}),
)
}
// Custom render function
@ -157,15 +122,6 @@ export const scenarios = {
...overrides,
},
}),
// Render with mock modal function
withMockModal: (mockSetShowAccountSettingModal: Mock, overrides: MockOverrides = {}) =>
renderAPIKeyInfoPanel({
mockOverrides: {
modalContext: { setShowAccountSettingModal: mockSetShowAccountSettingModal },
...overrides,
},
}),
}
// Common user interactions
@ -211,4 +167,4 @@ export function setDeploymentEdition(value: DeploymentEdition) {
}
// Export mock functions for external access
export { defaultModalContext, mockUseModalContext }
export { mockSetSettingsDestination }

View File

@ -4,12 +4,15 @@ import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiCloseLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
@ -21,7 +24,7 @@ const APIKeyInfoPanel: FC = () => {
const isCloud = deploymentEdition === 'CLOUD'
const { isAPIKeySet } = useProviderContext()
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const { t } = useTranslation()
@ -67,7 +70,7 @@ const APIKeyInfoPanel: FC = () => {
<Button
variant="primary"
className="mt-2 space-x-2"
onClick={() => openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER })}
onClick={() => setSettingsDestination('provider')}
>
<div className="text-sm font-medium">
{t(($) => $['apiKeyInfo.setAPIBtn'], { ns: 'appOverview' })}

View File

@ -59,12 +59,10 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
const mockOnClose = vi.fn()
const mockOnSave = vi.fn()
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockUseProviderContext = vi.fn<() => ProviderContextState>()
const buildModalContext = (): ModalContextState => ({
hasBlockingModalOpen: false,
setShowAccountSettingModal: mockSetShowAccountSettingModal,
setShowModerationSettingModal: vi.fn(),
setShowExternalDataToolModal: vi.fn(),
setShowPricingModal: mockSetShowPricingModal,
@ -135,7 +133,6 @@ describe('SettingsModal', () => {
mockOnClose.mockClear()
mockOnSave.mockClear()
mockSetShowPricingModal.mockClear()
mockSetShowAccountSettingModal.mockClear()
mockUseProviderContext.mockReturnValue({
...baseProviderContextValue,
enableBilling: true,
@ -423,7 +420,6 @@ describe('SettingsModal', () => {
fireEvent.click((await screen.findAllByText('billing.upgradeBtn.encourageShort'))[0]!)
expect(mockSetShowPricingModal).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).not.toHaveBeenCalled()
})
it('should hide the upgrade badge for non-sandbox plans', async () => {

View File

@ -25,7 +25,9 @@ import dayjs from 'dayjs'
import { APP_PAGE_LIMIT } from '@/config'
import { WorkflowRunTriggeredFrom } from '@/models/log'
import * as useLogModule from '@/service/use-log'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render } from '@/test/console/render'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import { TIME_PERIOD_MAPPING } from '../filter'
import Logs from '../index'
@ -124,7 +126,15 @@ const mockedUseWorkflowLogs = useLogModule.useWorkflowLogs as MockedFunction<
// ============================================================================
const renderWithQueryClient = (ui: React.ReactElement) => {
return renderWithConsoleQuery(ui)
const { wrapper: QueryWrapper } = createConsoleQueryWrapper()
const { wrapper: NuqsWrapper } = createNuqsTestWrapper()
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryWrapper>
<NuqsWrapper>{children}</NuqsWrapper>
</QueryWrapper>
)
return render(ui, { wrapper })
}
// ============================================================================

View File

@ -13,12 +13,11 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
},
}))
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
let mockCodeBasedExtensions: { data: { data: Record<string, unknown>[] } } = { data: { data: [] } }
let mockModelProvidersData: {
@ -52,10 +51,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/declaration
CustomConfigurationStatusEnum: { active: 'active' },
}))
vi.mock('@/app/components/header/account-setting/constants', () => ({
ACCOUNT_SETTING_TAB: { PROVIDER: 'provider' },
}))
vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({
ApiBasedExtensionSelector: ({ onChange }: { value: string; onChange: (v: string) => void }) => (
<div data-testid="api-selector">
@ -668,12 +663,7 @@ describe('ModerationSettingModal', () => {
fireEvent.click(screen.getByText(/settings\.provider/))
expect(mockSetShowAccountSettingModal).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
onCancelCallback: expect.any(Function),
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
})
it('should not save when OpenAI type is selected but not configured', async () => {

View File

@ -6,13 +6,16 @@ import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryState } from 'nuqs'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { ApiBasedExtensionSelector } from '@/app/components/header/account-setting/api-based-extension-page/selector'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { CustomConfigurationStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useDocLink, useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import { useCodeBasedExtensions, useModelProviders } from '@/service/use-common'
@ -56,14 +59,10 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ data, onCance
const { t } = useTranslation()
const docLink = useDocLink()
const locale = useLocale()
const {
data: modelProviders,
isPending: isLoading,
refetch: refetchModelProviders,
} = useModelProviders()
const { data: modelProviders, isPending: isLoading } = useModelProviders()
const localeDataRef = useRef<ModerationConfig>(data)
const [localeData, setLocaleData] = useState<ModerationConfig>(data)
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const updateLocaleData = useCallback(
(
update: ModerationConfig | ((current: ModerationConfig) => ModerationConfig),
@ -78,10 +77,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({ data, onCance
[],
)
const handleOpenSettingsModal = () => {
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.PROVIDER,
onCancelCallback: refetchModelProviders,
})
setSettingsDestination('provider')
}
const { data: codeBasedExtensionList } = useCodeBasedExtensions('moderation')
const openaiProvider = modelProviders?.data.find(

View File

@ -3,9 +3,7 @@ import type { DataSourceNotionWorkspace } from '@/models/common'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
import { useModalContext, useModalContextSelector } from '@/context/modal-context'
import {
useInvalidPreImportNotionPages,
usePreImportNotionPages,
@ -19,10 +17,11 @@ vi.mock('@/service/knowledge/use-import', () => ({
useInvalidPreImportNotionPages: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
useModalContextSelector: vi.fn(),
}))
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
const buildCredential = (
id: string,
@ -110,21 +109,10 @@ const createPreImportResult = ({
}) as ReturnType<typeof usePreImportNotionPages>
describe('NotionPageSelector Base', () => {
const mockSetShowAccountSettingModal = vi.fn()
const mockInvalidPreImportNotionPages = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useModalContext).mockReturnValue({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
} as unknown as ReturnType<typeof useModalContext>)
vi.mocked(useModalContextSelector).mockImplementation((selector) => {
// Execute the selector to get branch/func coverage for the inline function
selector({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
} as unknown as Parameters<Parameters<typeof useModalContextSelector>[0]>[0])
return mockSetShowAccountSettingModal
})
vi.mocked(useInvalidPreImportNotionPages).mockReturnValue(mockInvalidPreImportNotionPages)
})
@ -145,9 +133,7 @@ describe('NotionPageSelector Base', () => {
const connectButton = screen.getByRole('button', { name: 'datasetCreation.stepOne.connect' })
await user.click(connectButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
})
it('should render page selector and allow selecting a page tree', async () => {
@ -232,9 +218,7 @@ describe('NotionPageSelector Base', () => {
await user.click(
screen.getByRole('button', { name: 'common.dataSource.notion.selector.configure' }),
)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
})
it('should preview a page and call onPreview when callback is provided', async () => {

View File

@ -5,10 +5,13 @@ import type {
DataSourceNotionWorkspace,
NotionPage,
} from '@/models/common'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import {
useInvalidPreImportNotionPages,
usePreImportNotionPages,
@ -43,7 +46,7 @@ const NotionPageSelector = ({
}: NotionPageSelectorProps) => {
const { t } = useTranslation()
const [searchValue, setSearchValue] = useState('')
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const invalidPreImportNotionPages = useInvalidPreImportNotionPages()
@ -162,8 +165,8 @@ const NotionPageSelector = ({
)
const handleConfigureNotion = useCallback(() => {
openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE })
}, [openIntegrationsSetting])
setSettingsDestination('data-source')
}, [setSettingsDestination])
if (isFetchingNotionPagesError) {
return <NotionConnector onSetting={handleConfigureNotion} />

View File

@ -6,18 +6,15 @@ import { useSuspenseQuery } from '@tanstack/react-query'
import { useUnmountedRef } from 'ahooks'
import { useAtomValue } from 'jotai'
import * as React from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiAggregate, TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
import UsageInfo from '@/app/components/billing/usage-info'
import { useSetEducationVerifying } from '@/app/education-apply/storage'
import VerifyStateModal from '@/app/education-apply/verify-state-modal'
import { userProfileEmailAtom } from '@/context/account-state'
import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { usePathname, useRouter } from '@/next/navigation'
import { useRouter } from '@/next/navigation'
import { useEducationVerify } from '@/service/use-education'
import { getDaysUntilEndOfMonth } from '@/utils/time'
import Loading from '../../base/icons/src/public/thought/Loading'
@ -41,7 +38,6 @@ const PlanComp: FC<Props> = ({ loc }) => {
})
const isCloudEdition = deploymentEdition === 'CLOUD'
const router = useRouter()
const path = usePathname()
const userProfileEmail = useAtomValue(userProfileEmailAtom)
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const { plan, enableEducationPlan, allowRefreshEducationVerify, isEducationAccount } =
@ -65,14 +61,11 @@ const PlanComp: FC<Props> = ({ loc }) => {
const [showModal, setShowModal] = React.useState(false)
const { handleEducationDiscount, isEducationDiscountLoading } = useEducationDiscount()
const { mutateAsync, isPending } = useEducationVerify()
const setShowAccountSettingModal = useModalContextSelector((s) => s.setShowAccountSettingModal)
const setEducationVerifying = useSetEducationVerifying()
const unmountedRef = useUnmountedRef()
const handleVerify = () => {
if (isPending) return
mutateAsync()
.then((res) => {
setEducationVerifying(null)
if (unmountedRef.current) return
router.push(`/education-apply?token=${res.token}`)
})
@ -80,10 +73,6 @@ const PlanComp: FC<Props> = ({ loc }) => {
setShowModal(true)
})
}
useEffect(() => {
// setShowAccountSettingModal would prevent navigation
if (path.startsWith('/education-apply')) setShowAccountSettingModal(null)
}, [path, setShowAccountSettingModal])
return (
<div className="relative rounded-2xl border-[0.5px] border-effects-highlight-lightmode-off bg-background-section-burn">
<div className="p-6 pb-2">

View File

@ -79,23 +79,11 @@ vi.mock('@/context/system-features-state', async () => {
}))
})
// Mock modal context
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
useModalContextSelector: (
selector: (state: {
setShowAccountSettingModal: typeof mockSetShowAccountSettingModal
}) => unknown,
) => {
const state = {
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}
return selector(state)
},
}))
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
// Mock dataset detail context
let mockDatasetDetail: DataSet | undefined
@ -650,7 +638,7 @@ describe('DatasetUpdateForm', () => {
fireEvent.click(screen.getByTestId('step-one-setting'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
})
it('should open provider settings when onSetting is called from StepTwo', () => {
@ -659,7 +647,7 @@ describe('DatasetUpdateForm', () => {
fireEvent.click(screen.getByTestId('step-two-setting'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'provider' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
})
it('should update crawl options when onCrawlOptionsChange is called', () => {

View File

@ -9,13 +9,16 @@ import type {
import type { RETRIEVE_METHOD } from '@/types/app'
import { produce } from 'immer'
import { useAtomValue } from 'jotai'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { userProfileIdAtom } from '@/context/account-state'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import {
@ -51,7 +54,7 @@ const DEFAULT_CRAWL_OPTIONS: CrawlOptions = {
const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => {
const { t } = useTranslation()
const router = useRouter()
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const datasetDetail = useDatasetDetailContextWithSelector((state) => state.dataset)
const currentUserId = useAtomValue(userProfileIdAtom)
const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom)
@ -159,9 +162,7 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => {
{step === 1 && (
<StepOne
authedDataSourceList={dataSourceList?.result || []}
onSetting={() =>
openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE })
}
onSetting={() => setSettingsDestination('data-source')}
datasetId={datasetId}
dataSourceType={dataSourceType}
dataSourceTypeDisable={!!datasetDetail?.data_source_type}
@ -185,7 +186,7 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => {
{step === 2 && (!datasetId || (datasetId && !!datasetDetail)) && (
<StepTwo
isAPIKeySet={!!embeddingsDefaultModel}
onSetting={() => openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER })}
onSetting={() => setSettingsDestination('provider')}
indexingType={datasetDetail?.indexing_technique}
datasetId={datasetId}
dataSourceType={dataSourceType}

View File

@ -5,9 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
import Website from '../index'
const { mockRouterPush, mockSetShowAccountSettingModal } = vi.hoisted(() => ({
const { mockRouterPush, mockSetSettingsDestination } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockSetShowAccountSettingModal: vi.fn(),
mockSetSettingsDestination: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
@ -16,11 +16,10 @@ vi.mock('@/next/navigation', () => ({
}),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
vi.mock('../index.module.css', () => ({
default: {
@ -272,7 +271,7 @@ describe('Website', () => {
const configButton = screen.getByTestId('no-data-config-button')
fireEvent.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
expect(mockRouterPush).not.toHaveBeenCalled()
})
})

View File

@ -10,9 +10,9 @@ import FireCrawl from '../index'
// Mock API service
const mockCreateFirecrawlTask = vi.fn()
const mockCheckFirecrawlTaskStatus = vi.fn()
const { mockRouterPush, mockSetShowAccountSettingModal } = vi.hoisted(() => ({
const { mockRouterPush, mockSetSettingsDestination } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockSetShowAccountSettingModal: vi.fn(),
mockSetSettingsDestination: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
@ -26,12 +26,10 @@ vi.mock('@/service/datasets', () => ({
checkFirecrawlTaskStatus: (...args: unknown[]) => mockCheckFirecrawlTaskStatus(...args),
}))
// Mock modal context
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
// Mock sleep utility to speed up tests
vi.mock('@/utils', () => ({
@ -178,7 +176,7 @@ describe('FireCrawl', () => {
const configButton = screen.getByText(/configureFirecrawl/i)
await user.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
expect(mockRouterPush).not.toHaveBeenCalled()
})
})

View File

@ -2,11 +2,14 @@
import type { FC } from 'react'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { checkFirecrawlTaskStatus, createFirecrawlTask } from '@/service/datasets'
import { sleep } from '@/utils'
import CrawledResult from '../base/crawled-result'
@ -69,12 +72,10 @@ const FireCrawl: FC<Props> = ({
isMountedRef.current = false
}
}, [])
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const handleSetting = useCallback(() => {
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
}, [openIntegrationsSetting])
setSettingsDestination('data-source')
}, [setSettingsDestination])
const checkValid = useCallback(
(url: string) => {
let errorMsg = ''

View File

@ -3,11 +3,14 @@ import type { FC } from 'react'
import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import {
ENABLE_WEBSITE_FIRECRAWL,
ENABLE_WEBSITE_JINAREADER,
@ -42,7 +45,7 @@ const Website: FC<Props> = ({
authedDataSourceList,
}) => {
const { t } = useTranslation()
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const [selectedProvider, setSelectedProvider] = useState<DataSourceProvider>(
DataSourceProvider.jinaReader,
)
@ -62,10 +65,8 @@ const Website: FC<Props> = ({
)
const handleOnConfig = useCallback(() => {
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
}, [openIntegrationsSetting])
setSettingsDestination('data-source')
}, [setSettingsDestination])
const source = availableProviders.find((source) => source.provider === selectedProvider)

View File

@ -6,9 +6,9 @@ import { checkJinaReaderTaskStatus, createJinaReaderTask } from '@/service/datas
import { sleep } from '@/utils'
import JinaReader from '../index'
const { mockRouterPush, mockSetShowAccountSettingModal } = vi.hoisted(() => ({
const { mockRouterPush, mockSetSettingsDestination } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockSetShowAccountSettingModal: vi.fn(),
mockSetSettingsDestination: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
@ -26,12 +26,10 @@ vi.mock('@/utils', () => ({
sleep: vi.fn(() => Promise.resolve()),
}))
// Mock modal context
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
// Mock doc link context
vi.mock('@/context/i18n', () => ({
@ -400,14 +398,14 @@ describe('JinaReader', () => {
const configButton = screen.getByText('datasetCreation.stepOne.website.configureJinaReader')
fireEvent.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledTimes(1)
expect(mockSetSettingsDestination).toHaveBeenCalledTimes(1)
expect(mockRouterPush).not.toHaveBeenCalled()
// Rerender and click again
rerender(<JinaReader {...props} />)
fireEvent.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledTimes(2)
expect(mockSetSettingsDestination).toHaveBeenCalledTimes(2)
expect(mockRouterPush).not.toHaveBeenCalled()
})
@ -446,7 +444,7 @@ describe('JinaReader', () => {
const configButton = screen.getByText('datasetCreation.stepOne.website.configureJinaReader')
await userEvent.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
expect(mockRouterPush).not.toHaveBeenCalled()
})

View File

@ -2,11 +2,14 @@
import type { FC } from 'react'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { checkJinaReaderTaskStatus, createJinaReaderTask } from '@/service/datasets'
import { sleep } from '@/utils'
import CrawledResult from '../base/crawled-result'
@ -44,12 +47,10 @@ const JinaReader: FC<Props> = ({
const { t } = useTranslation()
const [step, setStep] = useState<Step>(Step.init)
const [controlFoldOptions, setControlFoldOptions] = useState<number>(0)
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const handleSetting = useCallback(() => {
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
}, [openIntegrationsSetting])
setSettingsDestination('data-source')
}, [setSettingsDestination])
const checkValid = useCallback(
(url: string) => {
let errorMsg = ''

View File

@ -6,9 +6,9 @@ import { checkWatercrawlTaskStatus, createWatercrawlTask } from '@/service/datas
import { sleep } from '@/utils'
import WaterCrawl from '../index'
const { mockRouterPush, mockSetShowAccountSettingModal } = vi.hoisted(() => ({
const { mockRouterPush, mockSetSettingsDestination } = vi.hoisted(() => ({
mockRouterPush: vi.fn(),
mockSetShowAccountSettingModal: vi.fn(),
mockSetSettingsDestination: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
@ -26,12 +26,10 @@ vi.mock('@/utils', () => ({
sleep: vi.fn(() => Promise.resolve()),
}))
// Mock modal context
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
// Mock i18n context
vi.mock('@/context/i18n', () => ({
@ -396,14 +394,14 @@ describe('WaterCrawl', () => {
const configButton = screen.getByText('datasetCreation.stepOne.website.configureWatercrawl')
fireEvent.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledTimes(1)
expect(mockSetSettingsDestination).toHaveBeenCalledTimes(1)
expect(mockRouterPush).not.toHaveBeenCalled()
// Rerender and click again
rerender(<WaterCrawl {...props} />)
fireEvent.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledTimes(2)
expect(mockSetSettingsDestination).toHaveBeenCalledTimes(2)
expect(mockRouterPush).not.toHaveBeenCalled()
})
@ -448,7 +446,7 @@ describe('WaterCrawl', () => {
const configButton = screen.getByText('datasetCreation.stepOne.website.configureWatercrawl')
await userEvent.click(configButton)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'data-source' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
expect(mockRouterPush).not.toHaveBeenCalled()
})

View File

@ -2,11 +2,14 @@
import type { FC } from 'react'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { checkWatercrawlTaskStatus, createWatercrawlTask } from '@/service/datasets'
import { sleep } from '@/utils'
import CrawledResult from '../base/crawled-result'
@ -49,12 +52,10 @@ const WaterCrawl: FC<Props> = ({
const { t } = useTranslation()
const [step, setStep] = useState<Step>(Step.init)
const controlFoldOptions = STEP_CONTROL_FOLD_OPTIONS[step]
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const handleSetting = useCallback(() => {
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
}, [openIntegrationsSetting])
setSettingsDestination('data-source')
}, [setSettingsDestination])
const checkValid = useCallback(
(url: string) => {
let errorMsg = ''

View File

@ -18,15 +18,11 @@ vi.mock('@/context/dataset-detail', () => ({
selector({ dataset: { pipeline_id: mockPipelineId } }),
}))
// Mock modal context - context provider requires mocking
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
useModalContextSelector: (selector: (s: Record<string, unknown>) => unknown) =>
selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }),
}))
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
// Mock ssePost - API service requires mocking
const { mockSsePost } = vi.hoisted(() => ({
@ -237,7 +233,7 @@ describe('OnlineDocuments', () => {
// Reset context values
mockPipelineId = 'pipeline-123'
mockSetShowAccountSettingModal.mockClear()
mockSetSettingsDestination.mockClear()
// Default mock return values
mockUseGetDataSourceAuth.mockReturnValue({
@ -612,9 +608,7 @@ describe('OnlineDocuments', () => {
fireEvent.click(screen.getByTestId('header-config-btn'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'data-source',
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
})
})
@ -709,9 +703,7 @@ describe('OnlineDocuments', () => {
fireEvent.click(screen.getByTestId('header-config-btn'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'data-source',
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
})
it('should handle credential change', () => {

View File

@ -2,12 +2,15 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-so
import type { DataSourceNotionPageMap, DataSourceNotionWorkspace } from '@/models/common'
import type { DataSourceNodeCompletedResponse, DataSourceNodeErrorResponse } from '@/types/pipeline'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import Loading from '@/app/components/base/loading'
import SearchInput from '@/app/components/base/notion-page-selector/search-input'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDocLink } from '@/context/i18n'
import { DatasourceType } from '@/models/pipeline'
@ -35,7 +38,7 @@ const OnlineDocuments = ({
}: OnlineDocumentsProps) => {
const docLink = useDocLink()
const pipelineId = useDatasetDetailContextWithSelector((s) => s.dataset?.pipeline_id)
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const { documentsData, searchValue, selectedPagesId, currentCredentialId } =
useDataSourceStoreWithSelector(
useShallow((state) => ({
@ -141,10 +144,8 @@ const OnlineDocuments = ({
)
const handleSetting = useCallback(() => {
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
}, [openIntegrationsSetting])
setSettingsDestination('data-source')
}, [setSettingsDestination])
return (
<div className="flex flex-col gap-y-2">

View File

@ -2,14 +2,13 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-so
import type { OnlineDriveFile } from '@/models/pipeline'
import type { DataSourceNodeCompletedResponse, DataSourceNodeErrorResponse } from '@/types/pipeline'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline'
import OnlineDrive from '../index'
const mocks = vi.hoisted(() => ({
pipelineId: 'pipeline-123' as string | undefined,
docLink: vi.fn((path?: string) => `https://docs.example.com${path || ''}`),
openIntegrationsSetting: vi.fn(),
setSettingsDestination: vi.fn(),
ssePost: vi.fn(),
toastError: vi.fn(),
useGetDataSourceAuth: vi.fn(),
@ -49,9 +48,10 @@ vi.mock('@/context/dataset-detail', () => ({
) => selector({ dataset: { pipeline_id: mocks.pipelineId } }),
}))
vi.mock('@/app/components/header/account-setting/use-integrations-setting', () => ({
useIntegrationsSetting: () => mocks.openIntegrationsSetting,
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mocks.setSettingsDestination] }
})
vi.mock('@/service/base', () => ({
ssePost: (...args: unknown[]) => mocks.ssePost(...args),
@ -432,9 +432,7 @@ describe('OnlineDrive', () => {
fireEvent.click(screen.getByRole('button', { name: 'Configure' }))
fireEvent.click(screen.getByRole('button', { name: 'Change Credential' }))
expect(mocks.openIntegrationsSetting).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
expect(mocks.setSettingsDestination).toHaveBeenCalledWith('data-source')
expect(onCredentialChange).toHaveBeenCalledWith('credential-2')
})
})

View File

@ -3,10 +3,13 @@ import type { OnlineDriveFile } from '@/models/pipeline'
import type { DataSourceNodeCompletedResponse, DataSourceNodeErrorResponse } from '@/types/pipeline'
import { toast } from '@langgenius/dify-ui/toast'
import { produce } from 'immer'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDocLink } from '@/context/i18n'
import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline'
@ -35,7 +38,7 @@ const OnlineDrive = ({
const docLink = useDocLink()
const [isInitialMount, setIsInitialMount] = useState(true)
const pipelineId = useDatasetDetailContextWithSelector((s) => s.dataset?.pipeline_id)
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const {
nextPageParameters,
breadcrumbs,
@ -202,10 +205,8 @@ const OnlineDrive = ({
)
const handleSetting = useCallback(() => {
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
}, [openIntegrationsSetting])
setSettingsDestination('data-source')
}, [setSettingsDestination])
return (
<div className="flex flex-col gap-y-2">

View File

@ -2,7 +2,6 @@ import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-so
import type { CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { CrawlStep } from '@/models/datasets'
import WebsiteCrawl from '../index'
@ -20,16 +19,11 @@ vi.mock('@/context/dataset-detail', () => ({
) => selector({ dataset: { pipeline_id: mockPipelineId } }),
}))
// Mock modal context - context provider requires mocking
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
useModalContextSelector: (
selector: (s: { setShowAccountSettingModal: typeof mockSetShowAccountSettingModal }) => unknown,
) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }),
}))
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
// Mock ssePost - API service requires mocking
const { mockSsePost } = vi.hoisted(() => ({
@ -257,7 +251,7 @@ describe('WebsiteCrawl', () => {
// Reset context values
mockPipelineId = 'pipeline-123'
mockSetShowAccountSettingModal.mockClear()
mockSetSettingsDestination.mockClear()
// Default mock return values
mockUseGetDataSourceAuth.mockReturnValue({
@ -616,9 +610,7 @@ describe('WebsiteCrawl', () => {
fireEvent.click(screen.getByTestId('header-config-btn'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
})
it('should have stable handleCredentialChange that resets state', () => {
@ -653,9 +645,7 @@ describe('WebsiteCrawl', () => {
fireEvent.click(screen.getByTestId('header-config-btn'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
})
it('should handle credential change', () => {

View File

@ -6,12 +6,15 @@ import type {
DataSourceNodeErrorResponse,
DataSourceNodeProcessingResponse,
} from '@/types/pipeline'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDocLink } from '@/context/i18n'
import { CrawlStep } from '@/models/datasets'
@ -52,7 +55,7 @@ const WebsiteCrawl = ({
const [crawledNum, setCrawledNum] = useState(0)
const [crawlErrorMessage, setCrawlErrorMessage] = useState('')
const pipelineId = useDatasetDetailContextWithSelector((s) => s.dataset?.pipeline_id)
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const { crawlResult, step, checkedCrawlResult, previewIndex, currentCredentialId } =
useDataSourceStoreWithSelector(
useShallow((state) => ({
@ -158,10 +161,8 @@ const WebsiteCrawl = ({
)
const handleSetting = useCallback(() => {
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
}, [openIntegrationsSetting])
setSettingsDestination('data-source')
}, [setSettingsDestination])
const handleCredentialChange = useCallback(
(credentialId: string) => {

View File

@ -4,7 +4,7 @@ import DocumentSettings from '../document-settings'
const mockPush = vi.fn()
const mockBack = vi.fn()
const mockOpenIntegrationsSetting = vi.fn()
const mockSetSettingsDestination = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
@ -107,9 +107,10 @@ vi.mock('@/app/components/datasets/create/step-two', () => ({
),
}))
vi.mock('@/app/components/header/account-setting/use-integrations-setting', () => ({
useIntegrationsSetting: () => mockOpenIntegrationsSetting,
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
describe('DocumentSettings', () => {
beforeEach(() => {
@ -205,7 +206,7 @@ describe('DocumentSettings', () => {
fireEvent.click(screen.getByTestId('setting-btn'))
expect(mockOpenIntegrationsSetting).toHaveBeenCalledWith({ payload: 'provider' })
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
})
})

View File

@ -10,16 +10,19 @@ import type {
UploadFileIdInfo,
WebsiteCrawlInfo,
} from '@/models/datasets'
import { useQueryState } from 'nuqs'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import StepTwo from '@/app/components/datasets/create/step-two'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import DatasetDetailContext from '@/context/dataset-detail'
import { useRouter } from '@/next/navigation'
import {
@ -36,12 +39,12 @@ type DocumentSettingsProps = {
const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
const { t } = useTranslation()
const router = useRouter()
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const { indexingTechnique, dataset } = useContext(DatasetDetailContext)
const { data: embeddingsDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding)
const handleOpenAccountSetting = useCallback(() => {
openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}, [openIntegrationsSetting])
setSettingsDestination('provider')
}, [setSettingsDestination])
const invalidDocumentList = useInvalidDocumentList(datasetId)
const invalidDocumentDetail = useInvalidDocumentDetail()

View File

@ -1,18 +0,0 @@
'use client'
import { useEffect } from 'react'
import { EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION } from '@/app/education-apply/constants'
import { useSetEducationVerifying } from '@/app/education-apply/storage'
import { useSearchParams } from '@/next/navigation'
export function EducationVerifyActionRecorder() {
const searchParams = useSearchParams()
const setEducationVerifying = useSetEducationVerifying()
useEffect(() => {
if (searchParams.get('action') === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
setEducationVerifying('yes')
}, [searchParams, setEducationVerifying])
return null
}

View File

@ -8,7 +8,6 @@ import { toast } from '@langgenius/dify-ui/toast'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import { getDocDownloadUrl } from '@/service/common'
@ -40,9 +39,14 @@ vi.mock('@/utils/download', () => ({
downloadUrl: vi.fn(),
}))
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
describe('Compliance', () => {
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const toastSuccessSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success')
const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
let queryClient: QueryClient
@ -66,7 +70,6 @@ describe('Compliance', () => {
})
vi.mocked(useModalContext).mockReturnValue({
setShowPricingModal: mockSetShowPricingModal,
setShowAccountSettingModal: mockSetShowAccountSettingModal,
} as unknown as ModalContextState)
})
@ -224,9 +227,7 @@ describe('Compliance', () => {
fireEvent.click(upgradeBadges[0]!)
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.BILLING,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('billing')
})
// isPending branches: spinner visible, loading button contract, guard blocks second call

View File

@ -7,7 +7,6 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderToString } from 'react-dom/server'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import AccountSection from '@/app/components/main-nav/components/account-section'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
@ -82,6 +81,12 @@ vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
vi.mock('@/service/use-common', async (importOriginal) => ({
...(await importOriginal<typeof import('@/service/use-common')>()),
useLogout: vi.fn(),
@ -176,7 +181,6 @@ const setConsoleState = (value: ConsoleStateFixture) => {
describe('AccountDropdown', () => {
const mockPush = vi.fn()
const mockLogout = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
let deploymentEdition: GetSystemFeaturesResponse['deployment_edition'] = 'COMMUNITY'
const renderWithRouter = (
@ -207,9 +211,7 @@ describe('AccountDropdown', () => {
isEducationAccount: false,
plan: { type: Plan.sandbox },
} as unknown as ProviderContextState)
vi.mocked(useModalContext).mockReturnValue({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
} as unknown as ModalContextState)
vi.mocked(useModalContext).mockReturnValue({} as unknown as ModalContextState)
vi.mocked(useLogout).mockReturnValue({
mutateAsync: mockLogout,
} as unknown as ReturnType<typeof useLogout>)
@ -288,14 +290,14 @@ describe('AccountDropdown', () => {
})
describe('Settings and Support', () => {
it('should trigger setShowAccountSettingModal when settings is clicked', () => {
it('should open member settings when settings is clicked', () => {
// Act
renderWithRouter(<AppSelector />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByText('common.userProfile.settings'))
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalled()
expect(mockSetSettingsDestination).toHaveBeenCalledWith('members')
})
it('should open preferences from the account dropdown', () => {
@ -305,9 +307,7 @@ describe('AccountDropdown', () => {
fireEvent.click(screen.getByText('common.settings.preferences'))
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.PREFERENCES,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('preferences')
})
it('should show Appearance after Preferences in the main nav account dropdown', () => {

View File

@ -10,10 +10,14 @@ import {
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useMutation } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { getDocDownloadUrl } from '@/service/common'
@ -95,7 +99,8 @@ type ComplianceDocRowItemProps = {
function ComplianceDocRowItem({ icon, label, docName }: ComplianceDocRowItemProps) {
const { t } = useTranslation()
const { plan } = useProviderContext()
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
const { setShowPricingModal } = useModalContext()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const isFreePlan = plan.type === Plan.sandbox
const { isPending, mutate: downloadCompliance } = useMutation({
@ -128,13 +133,13 @@ function ComplianceDocRowItem({ icon, label, docName }: ComplianceDocRowItemProp
}
if (isFreePlan) setShowPricingModal()
else setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
else setSettingsDestination('billing')
}, [
downloadCompliance,
isCurrentPlanCanDownload,
isFreePlan,
isPending,
setShowAccountSettingModal,
setSettingsDestination,
setShowPricingModal,
])

View File

@ -11,13 +11,16 @@ import {
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useQueryState } from 'nuqs'
import { useTranslation } from 'react-i18next'
import PremiumBadge from '@/app/components/base/premium-badge'
import ThemeSwitcher from '@/app/components/base/theme-switcher'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { userProfileAtom } from '@/context/account-state'
import { useDocLink } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { langGeniusVersionInfoAtom } from '@/context/version-state'
import { isCurrentWorkspaceOwnerAtom } from '@/context/workspace-state'
@ -114,7 +117,7 @@ export function DefaultMenuContent({
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const isCurrentWorkspaceOwner = useAtomValue(isCurrentWorkspaceOwnerAtom)
const { isEducationAccount } = useProviderContext()
const { setShowAccountSettingModal } = useModalContext()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
return (
<>
@ -145,7 +148,7 @@ export function DefaultMenuContent({
<AccountMenuActionItem
iconClassName="i-ri-settings-3-line"
label={t(($) => $['userProfile.settings'], { ns: 'common' })}
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })}
onClick={() => setSettingsDestination('members')}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator className="my-0! bg-divider-subtle" />

View File

@ -18,10 +18,13 @@ import {
} from '@langgenius/dify-ui/dropdown-menu'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { useQueryState } from 'nuqs'
import { useTranslation } from 'react-i18next'
import PremiumBadge from '@/app/components/base/premium-badge'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useProviderContext } from '@/context/provider-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import Link from '@/next/link'
@ -107,7 +110,7 @@ export function MainNavMenuContent({ onLogout }: MainNavMenuContentProps) {
select: (data) => data.profile,
})
const { isEducationAccount } = useProviderContext()
const { setShowAccountSettingModal } = useModalContext()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
return (
<>
@ -156,7 +159,7 @@ export function MainNavMenuContent({ onLogout }: MainNavMenuContentProps) {
</DropdownMenuLinkItem>
<DropdownMenuItem
className="mx-0 h-8 gap-1 px-3 py-1"
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PREFERENCES })}
onClick={() => setSettingsDestination('preferences')}
>
<MenuItemContent
iconClassName="i-ri-equalizer-2-line"

View File

@ -1,15 +1,26 @@
import { isValidSettingsTab } from '../constants'
import {
isAccountSettingDestination,
isIntegrationSettingDestination,
settingsQueryParser,
} from '../query-params'
describe('isValidSettingsTab', () => {
describe('settingsQueryParser', () => {
it.each([
['roles-and-permissions', true],
['preferences', true],
['provider', true],
['mcp', true],
['agent-strategy', true],
['invalid', false],
[null, false],
])('validates %s', (tab, expected) => {
expect(isValidSettingsTab(tab)).toBe(expected)
['roles-and-permissions', 'roles-and-permissions'],
['preferences', 'preferences'],
['provider', 'provider'],
['mcp', 'mcp'],
['agent-strategy', 'agent-strategy'],
['invalid', null],
['', null],
])('parses %s', (value, expected) => {
expect(settingsQueryParser.parse(value)).toBe(expected)
})
it('keeps account and integration destinations in their owning branches', () => {
expect(isAccountSettingDestination('members')).toBe(true)
expect(isAccountSettingDestination('provider')).toBe(false)
expect(isIntegrationSettingDestination('provider')).toBe(true)
expect(isIntegrationSettingDestination('members')).toBe(false)
})
})

View File

@ -1,7 +1,6 @@
import type { AccountSettingTab } from '../constants'
import type { ConsoleStateFixture } from '@/test/console/state-fixture'
import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
@ -9,7 +8,6 @@ import { renderWithConsoleQuery } from '@/test/console/query-data'
import { ACCOUNT_SETTING_TAB } from '../constants'
import AccountSetting from '../index'
const mockResetModelProviderListExpanded = vi.fn()
const mockConsoleState = vi.hoisted(() => ({
current: null as unknown,
}))
@ -66,10 +64,6 @@ vi.mock('next-themes', () => ({
})),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/atoms', () => ({
useResetModelProviderListExpanded: () => mockResetModelProviderListExpanded,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({
default: ({
onSearchTextChange,
@ -274,25 +268,6 @@ describe('AccountSetting', () => {
expect(screen.getByText('common.settings.preferences'))!.toBeInTheDocument()
})
it('should keep hidden legacy tab metadata for direct entries', () => {
// Act
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.DATA_SOURCE })
// Assert
expect(screen.getByText('common.settings.dataSource'))!.toBeInTheDocument()
})
it('should normalize legacy language tab entries to preferences', () => {
// Act
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.LANGUAGE })
// Assert
const preferencesButton = screen.getByRole('button', { name: 'common.settings.preferences' })
expect(preferencesButton.querySelector('.i-ri-equalizer-2-fill')).toBeInTheDocument()
expect(screen.getByText('common.account.general')).toBeInTheDocument()
expect(screen.getByText('common.account.appearanceLabel')).toBeInTheDocument()
})
it('should hide sidebar labels on mobile', () => {
// Arrange
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
@ -691,19 +666,6 @@ describe('AccountSetting', () => {
expect(mockOnCancel).toHaveBeenCalled()
})
it('should keep provider search controlled at the account setting boundary', async () => {
// Arrange
const user = userEvent.setup()
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.PROVIDER })
// Act
const input = screen.getByRole('searchbox', { name: 'common.operation.search' })
await user.type(input, 'test-search')
// Assert
expect(input)!.toHaveValue('test-search')
})
it('should handle scroll event in panel', () => {
// Act
renderAccountSetting()

View File

@ -0,0 +1,134 @@
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useQueryState } from 'nuqs'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { render } from '@/test/console/render'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import { SettingsModal } from '../settings-modal'
vi.mock('@/app/components/header/account-setting', () => ({
default: ({ activeTab, onCancelAction }: { activeTab: string; onCancelAction: () => void }) => (
<>
<div role="status" aria-label="active account setting tab">
{activeTab}
</div>
<button type="button" onClick={onCancelAction}>
cancel account setting
</button>
</>
),
}))
vi.mock('@/app/components/integrations/modal', () => ({
default: ({
section,
onCancel,
onSectionChange,
}: {
section: string
onCancel: () => void
onSectionChange: (section: 'data-source') => void
}) => (
<>
<div role="status" aria-label="active integration setting section">
{section}
</div>
<button type="button" onClick={() => onSectionChange('data-source')}>
switch integration section
</button>
<button type="button" onClick={onCancel}>
cancel integration setting
</button>
</>
),
}))
function PreferencesOpener() {
const [settingsDestination, setSettingsDestination] = useQueryState(
settingsQueryParamName,
settingsQueryParser,
)
return (
<button
type="button"
aria-pressed={settingsDestination === ACCOUNT_SETTING_TAB.PREFERENCES}
onClick={() => setSettingsDestination(ACCOUNT_SETTING_TAB.PREFERENCES)}
>
open preferences
</button>
)
}
const renderSettingsModal = (searchParams = '', children?: React.ReactNode) => {
const { wrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams })
return {
...render(
<>
{children}
<SettingsModal />
</>,
{ wrapper },
),
onUrlUpdate,
}
}
describe('SettingsModal', () => {
it('opens account settings with push and closes them with replace', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderSettingsModal('', <PreferencesOpener />)
await user.click(screen.getByRole('button', { name: 'open preferences' }))
expect(
await screen.findByRole('status', { name: 'active account setting tab' }),
).toHaveTextContent(ACCOUNT_SETTING_TAB.PREFERENCES)
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('settings')).toBe('preferences')
expect(onUrlUpdate.mock.calls.at(-1)?.[0].options).toMatchObject({
history: 'push',
shallow: false,
})
await user.click(screen.getByRole('button', { name: 'cancel account setting' }))
await waitFor(() => {
expect(
screen.queryByRole('status', { name: 'active account setting tab' }),
).not.toBeInTheDocument()
})
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.has('settings')).toBe(false)
expect(onUrlUpdate.mock.calls.at(-1)?.[0].options).toMatchObject({
history: 'replace',
shallow: true,
})
})
it('renders an integration destination and replaces it when switching sections', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderSettingsModal('?settings=provider')
expect(
await screen.findByRole('status', { name: 'active integration setting section' }),
).toHaveTextContent('provider')
await user.click(screen.getByRole('button', { name: 'switch integration section' }))
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('settings')).toBe('data-source')
expect(onUrlUpdate.mock.calls.at(-1)?.[0].options).toMatchObject({
history: 'replace',
shallow: true,
})
})
it('ignores invalid settings destinations', () => {
renderSettingsModal('?settings=unknown')
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
})

View File

@ -1,3 +1,4 @@
import type { SettingsDestination } from '@/app/components/header/account-setting/query-params'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import {
@ -5,20 +6,17 @@ import {
AUTO_UPDATE_STRATEGY,
} from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { ACCOUNT_SETTING_TAB } from '../constants'
import UpdateSettingDialogForm from '../update-setting-dialog-form'
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: (
selector: (s: {
setShowAccountSettingModal: typeof mockSetShowAccountSettingModal
}) => typeof mockSetShowAccountSettingModal,
) => {
return selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal })
},
}))
const mockSetSettingsDestination = vi.fn()
let mockSettingsDestination: SettingsDestination | null = null
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return {
...actual,
useQueryState: () => [mockSettingsDestination, mockSetSettingsDestination],
}
})
vi.mock('react-i18next', async () => {
const { withSelectorKey, withSelectorKeyProps } = await import('@/test/i18n-mock')
@ -64,6 +62,7 @@ vi.mock(
describe('UpdateSettingDialogForm', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSettingsDestination = null
})
it('should open preferences after closing the update setting dialog when timezone link is clicked', () => {
@ -96,8 +95,41 @@ describe('UpdateSettingDialogForm', () => {
fireEvent.click(screen.getByText('autoUpdate.changeTimezone'))
expect(onRequestClose).toHaveBeenCalledTimes(1)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.PREFERENCES,
expect(mockSetSettingsDestination).toHaveBeenCalledWith('preferences')
})
it('should replace the current destination when timezone link is clicked inside settings', () => {
mockSettingsDestination = 'provider'
render(
<UpdateSettingDialogForm
autoUpgrade={{
strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly,
upgrade_time_of_day: 0,
upgrade_mode: AUTO_UPDATE_MODE.update_all,
exclude_plugins: [],
include_plugins: [],
}}
category={PluginCategoryEnum.tool}
plugins={[]}
scopeOptions={[{ value: AUTO_UPDATE_MODE.update_all, label: 'All' }]}
strategyOptions={[{ value: AUTO_UPDATE_STRATEGY.fixOnly, label: 'Fix only' }]}
timezone="UTC"
updateTimeValue="00:00"
minuteFilter={(minutes) => minutes}
onAutoUpgradeChange={vi.fn()}
onPluginsChange={vi.fn()}
onRequestClose={vi.fn()}
onUpdateTimeChange={vi.fn()}
renderTimePickerTrigger={() => <button type="button">Pick time</button>}
/>,
)
fireEvent.click(screen.getByText('autoUpdate.changeTimezone'))
expect(mockSetSettingsDestination).toHaveBeenCalledWith('preferences', {
history: 'replace',
shallow: true,
})
})
})

View File

@ -1,70 +0,0 @@
import { act, renderHook } from '@testing-library/react'
import { ACCOUNT_SETTING_TAB } from '../constants'
import { useIntegrationsSetting } from '../use-integrations-setting'
const { mockSetShowAccountSettingModal } = vi.hoisted(() => ({
mockSetShowAccountSettingModal: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
describe('useIntegrationsSetting', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it.each([
[ACCOUNT_SETTING_TAB.PROVIDER, 'provider'],
[ACCOUNT_SETTING_TAB.DATA_SOURCE, 'data-source'],
[ACCOUNT_SETTING_TAB.API_BASED_EXTENSION, 'custom-endpoint'],
])('should open integrations settings for migrated tab %s', (tab, section) => {
const { result } = renderHook(() => useIntegrationsSetting())
act(() => {
result.current({ payload: tab })
})
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: section })
})
it('should open integrations settings from a direct section', () => {
const { result } = renderHook(() => useIntegrationsSetting())
act(() => {
result.current({ section: 'mcp' })
})
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: 'mcp' })
})
it('should preserve the agent source for agent-scoped settings', () => {
const { result } = renderHook(() => useIntegrationsSetting())
act(() => {
result.current({ payload: ACCOUNT_SETTING_TAB.PROVIDER, source: 'agent' })
})
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
source: 'agent',
})
})
it('should preserve the cancel callback for migrated integrations settings', () => {
const onCancelCallback = vi.fn()
const { result } = renderHook(() => useIntegrationsSetting())
act(() => {
result.current({ payload: ACCOUNT_SETTING_TAB.PROVIDER, onCancelCallback })
})
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
onCancelCallback,
})
})
})

View File

@ -1,8 +1,5 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { ModalContextState } from '@/context/modal-context'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { ApiBasedExtensionSelector } from '../selector'
const { mockApiBasedExtensionsQuery, mockCreateApiBasedExtension, mockUpdateApiBasedExtension } =
@ -12,9 +9,11 @@ const { mockApiBasedExtensionsQuery, mockCreateApiBasedExtension, mockUpdateApiB
mockUpdateApiBasedExtension: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
vi.mock('@/context/i18n', () => ({
useDocLink:
@ -55,7 +54,6 @@ vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/bas
describe('ApiBasedExtensionSelector', () => {
const mockOnChange = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockData: ApiBasedExtensionResponse[] = [
{ id: '1', name: 'Extension 1', api_endpoint: 'https://api1.test', api_key: 'key1' },
@ -64,9 +62,6 @@ describe('ApiBasedExtensionSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useModalContext).mockReturnValue({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
} as unknown as ModalContextState)
mockApiBasedExtensionsQuery.mockReturnValue({
data: mockData,
isPending: false,
@ -131,9 +126,7 @@ describe('ApiBasedExtensionSelector', () => {
fireEvent.click(manageButton)
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('custom-endpoint')
})
it('should open add modal when clicking add button and close it after save', async () => {

View File

@ -1,9 +1,12 @@
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useQuery } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { consoleQuery } from '@/service/client'
import { ApiBasedExtensionModal } from './modal'
@ -16,7 +19,7 @@ export function ApiBasedExtensionSelector({ value, onChange }: ApiBasedExtension
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [addModalOpen, setAddModalOpen] = useState(false)
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const { data: apiBasedExtensions = [] } = useQuery(
consoleQuery.apiBasedExtension.get.queryOptions(),
)
@ -84,9 +87,7 @@ export function ApiBasedExtensionSelector({ value, onChange }: ApiBasedExtension
className="flex cursor-pointer items-center border-none bg-transparent p-0 text-xs text-text-accent"
onClick={() => {
setOpen(false)
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION,
})
setSettingsDestination('custom-endpoint')
}}
>
{t(($) => $['apiBasedExtension.selector.manage'], { ns: 'common' })}

View File

@ -1,69 +1,21 @@
import type { IntegrationSection } from '@/app/components/integrations/routes'
import { INTEGRATION_SECTION_VALUES } from '@/app/components/integrations/routes'
export const ACCOUNT_SETTING_MODAL_ACTION = 'showSettings'
export const ACCOUNT_SETTING_TAB = {
PROVIDER: 'provider',
MEMBERS: 'members',
ROLES_AND_PERMISSIONS: 'roles-and-permissions',
PERMISSION_SET: 'permission-set',
BILLING: 'billing',
WORKFLOW_LOG_ARCHIVES: 'workflow-log-archives',
DATA_SOURCE: 'data-source',
API_BASED_EXTENSION: 'custom-endpoint',
CUSTOM: 'custom',
PREFERENCES: 'preferences',
LANGUAGE: 'language',
} as const
export type AccountSettingTab = (typeof ACCOUNT_SETTING_TAB)[keyof typeof ACCOUNT_SETTING_TAB]
export const DEFAULT_ACCOUNT_SETTING_TAB = ACCOUNT_SETTING_TAB.MEMBERS
const WORKSPACE_SETTING_TAB_VALUES = [
export const ACCOUNT_SETTING_TAB_VALUES = [
ACCOUNT_SETTING_TAB.MEMBERS,
ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS,
ACCOUNT_SETTING_TAB.PERMISSION_SET,
ACCOUNT_SETTING_TAB.BILLING,
ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
ACCOUNT_SETTING_TAB.CUSTOM,
] as const
export type WorkspaceSettingTab = (typeof WORKSPACE_SETTING_TAB_VALUES)[number]
const USER_SETTING_TAB_VALUES = [
ACCOUNT_SETTING_TAB.PREFERENCES,
ACCOUNT_SETTING_TAB.LANGUAGE,
] as const
export type UserSettingTab = (typeof USER_SETTING_TAB_VALUES)[number]
export type IntegrationSettingTab = IntegrationSection
export const SETTINGS_TAB_VALUES = [
...WORKSPACE_SETTING_TAB_VALUES,
...USER_SETTING_TAB_VALUES,
...INTEGRATION_SECTION_VALUES,
] as const
export type SettingsTab = (typeof SETTINGS_TAB_VALUES)[number]
export const isValidSettingsTab = (tab: string | null): tab is SettingsTab => {
if (!tab) return false
return SETTINGS_TAB_VALUES.includes(tab as SettingsTab)
}
export const isWorkspaceSettingTab = (tab: SettingsTab | null): tab is WorkspaceSettingTab => {
if (!tab) return false
return WORKSPACE_SETTING_TAB_VALUES.includes(tab as WorkspaceSettingTab)
}
export const isUserSettingTab = (tab: SettingsTab | null): tab is UserSettingTab => {
if (!tab) return false
return USER_SETTING_TAB_VALUES.includes(tab as UserSettingTab)
}
export const isIntegrationSettingTab = (tab: SettingsTab | null): tab is IntegrationSettingTab => {
if (!tab) return false
return INTEGRATION_SECTION_VALUES.includes(tab as IntegrationSection)
}

View File

@ -1,11 +0,0 @@
import type { AccountSettingTab } from './constants'
import type { IntegrationSection } from '@/app/components/integrations/routes'
import { ACCOUNT_SETTING_TAB } from './constants'
export const integrationSectionByMovedAccountSettingTab = {
[ACCOUNT_SETTING_TAB.PROVIDER]: 'provider',
[ACCOUNT_SETTING_TAB.DATA_SOURCE]: 'data-source',
[ACCOUNT_SETTING_TAB.API_BASED_EXTENSION]: 'custom-endpoint',
} as const satisfies Partial<Record<AccountSettingTab, IntegrationSection>>
export type MovedAccountSettingTab = keyof typeof integrationSectionByMovedAccountSettingTab

View File

@ -5,7 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useCallback, useRef, useState } from 'react'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import BillingPage from '@/app/components/billing/billing-page'
import CustomPage from '@/app/components/custom/custom-page'
@ -21,11 +21,7 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { hasPermission } from '@/utils/permission'
import AccessRulesPage from './access-rules-page'
import { ApiBasedExtensionPage } from './api-based-extension-page'
import DataSourcePage from './data-source-page-new'
import MembersPage from './members-page'
import ModelProviderPage from './model-provider-page'
import { useResetModelProviderListExpanded } from './model-provider-page/atoms'
import PermissionsPage from './permissions-page'
import PreferencePage from './preference-page'
import WorkflowLogArchivesPage from './workflow-log-archives-page'
@ -54,7 +50,6 @@ export default function AccountSetting({
activeTab,
onTabChangeAction,
}: IAccountSettingProps) {
const resetModelProviderListExpanded = useResetModelProviderListExpanded()
const { t } = useTranslation()
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
@ -67,34 +62,22 @@ export default function AccountSetting({
const canViewBilling = enableBilling && !isCurrentWorkspaceDatasetOperator
const canViewWorkflowLogArchives =
systemFeatures.deployment_edition === 'CLOUD' && isCurrentWorkspaceManager
// Keep legacy `language` deep links opening Preferences during the tab rename migration.
const normalizedActiveTab =
activeTab === ACCOUNT_SETTING_TAB.LANGUAGE ? ACCOUNT_SETTING_TAB.PREFERENCES : activeTab
const activeMenu = (() => {
if (normalizedActiveTab === ACCOUNT_SETTING_TAB.BILLING && !canViewBilling)
if (activeTab === ACCOUNT_SETTING_TAB.BILLING && !canViewBilling)
return ACCOUNT_SETTING_TAB.PREFERENCES
if (
normalizedActiveTab === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES &&
!canViewWorkflowLogArchives
)
if (activeTab === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES && !canViewWorkflowLogArchives)
return ACCOUNT_SETTING_TAB.MEMBERS
if (
(normalizedActiveTab === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS ||
normalizedActiveTab === ACCOUNT_SETTING_TAB.PERMISSION_SET) &&
(activeTab === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS ||
activeTab === ACCOUNT_SETTING_TAB.PERMISSION_SET) &&
!canManageWorkspaceRoles
)
return ACCOUNT_SETTING_TAB.MEMBERS
return normalizedActiveTab
return activeTab
})()
const scrollContainerRef = useRef<HTMLDivElement>(null)
const settingItems: GroupItem[] = [
{
key: ACCOUNT_SETTING_TAB.PROVIDER,
name: t(($) => $['settings.provider'], { ns: 'common' }),
icon: <span className={cn('i-ri-brain-2-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-brain-2-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.MEMBERS,
name: t(($) => $['settings.members'], { ns: 'common' }),
@ -128,18 +111,6 @@ export default function AccountSetting({
icon: <span className={cn('i-ri-archive-drawer-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-archive-drawer-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.DATA_SOURCE,
name: t(($) => $['settings.dataSource'], { ns: 'common' }),
icon: <span className={cn('i-ri-database-2-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-database-2-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION,
name: t(($) => $['settings.customEndpoint'], { ns: 'common' }),
icon: <span className={cn('i-ri-puzzle-2-line', iconClassName)} />,
activeIcon: <span className={cn('i-ri-puzzle-2-fill', iconClassName)} />,
},
{
key: ACCOUNT_SETTING_TAB.CUSTOM,
name: t(($) => $.custom, { ns: 'custom' }),
@ -193,31 +164,15 @@ export default function AccountSetting({
},
]
const [searchValue, setSearchValue] = useState<string>('')
const handleTabChange = useCallback(
(tab: AccountSettingTab) => {
if (tab === ACCOUNT_SETTING_TAB.PROVIDER) resetModelProviderListExpanded()
onTabChangeAction(tab)
},
[onTabChangeAction, resetModelProviderListExpanded],
)
const handleClose = useCallback(() => {
resetModelProviderListExpanded()
onCancelAction()
}, [onCancelAction, resetModelProviderListExpanded])
return (
<MenuDialog show onClose={handleClose}>
<MenuDialog show onClose={onCancelAction}>
<div className="fixed top-6 right-6 z-20 flex shrink-0 flex-col items-center">
<Button
variant="tertiary"
size="large"
className="px-2"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
onClick={handleClose}
onClick={onCancelAction}
>
<span className="i-ri-close-line size-5" />
</Button>
@ -257,7 +212,7 @@ export default function AccountSetting({
aria-label={item.name}
title={item.name}
onClick={() => {
handleTabChange(item.key)
onTabChangeAction(item.key)
}}
>
{activeMenu === item.key ? item.activeIcon : item.icon}
@ -289,9 +244,6 @@ export default function AccountSetting({
</div>
</div>
<div className="max-w-full min-w-0 px-4 pt-6 sm:px-8">
{activeMenu === ACCOUNT_SETTING_TAB.PROVIDER && (
<ModelProviderPage searchText={searchValue} onSearchTextChange={setSearchValue} />
)}
{activeMenu === ACCOUNT_SETTING_TAB.MEMBERS && <MembersPage />}
{activeMenu === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS && (
<PermissionsPage containerRef={scrollContainerRef} />
@ -301,8 +253,6 @@ export default function AccountSetting({
{activeMenu === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES && (
<WorkflowLogArchivesPage />
)}
{activeMenu === ACCOUNT_SETTING_TAB.DATA_SOURCE && <DataSourcePage />}
{activeMenu === ACCOUNT_SETTING_TAB.API_BASED_EXTENSION && <ApiBasedExtensionPage />}
{activeMenu === ACCOUNT_SETTING_TAB.CUSTOM && <CustomPage />}
{activeMenu === ACCOUNT_SETTING_TAB.PREFERENCES && <PreferencePage />}
</div>

View File

@ -5,7 +5,6 @@ import { beforeEach, describe, expect, it } from 'vitest'
import {
useExpandModelProviderList,
useModelProviderListExpanded,
useResetModelProviderListExpanded,
useSetModelProviderListExpanded,
} from '../atoms'
@ -169,73 +168,6 @@ describe('atoms', () => {
})
})
// Reset hook: clears all expanded state back to empty
describe('useResetModelProviderListExpanded', () => {
it('should reset all expanded providers to false', () => {
const { result } = renderHook(
() => ({
openaiExpanded: useModelProviderListExpanded('openai'),
anthropicExpanded: useModelProviderListExpanded('anthropic'),
expand: useExpandModelProviderList(),
reset: useResetModelProviderListExpanded(),
}),
{ wrapper },
)
act(() => {
result.current.expand('openai')
})
act(() => {
result.current.expand('anthropic')
})
act(() => {
result.current.reset()
})
expect(result.current.openaiExpanded).toBe(false)
expect(result.current.anthropicExpanded).toBe(false)
})
it('should be safe to call when no providers are expanded', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
reset: useResetModelProviderListExpanded(),
}),
{ wrapper },
)
act(() => {
result.current.reset()
})
expect(result.current.expanded).toBe(false)
})
it('should allow re-expanding providers after reset', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
expand: useExpandModelProviderList(),
reset: useResetModelProviderListExpanded(),
}),
{ wrapper },
)
act(() => {
result.current.expand('openai')
})
act(() => {
result.current.reset()
})
act(() => {
result.current.expand('openai')
})
expect(result.current.expanded).toBe(true)
})
})
// Cross-hook interaction: verify hooks cooperate through the shared atom
describe('Cross-hook interaction', () => {
it('should reflect state set by useSetModelProviderListExpanded in useModelProviderListExpanded', () => {
@ -290,26 +222,6 @@ describe('atoms', () => {
})
expect(result.current.expanded).toBe(false)
})
it('should reset state set by useSetModelProviderListExpanded via useResetModelProviderListExpanded', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
setExpanded: useSetModelProviderListExpanded('openai'),
reset: useResetModelProviderListExpanded(),
}),
{ wrapper },
)
act(() => {
result.current.setExpanded(true)
})
act(() => {
result.current.reset()
})
expect(result.current.expanded).toBe(false)
})
})
// selectAtom granularity: changing one provider should not affect unrelated reads

View File

@ -26,8 +26,8 @@ type MockReferenceSetting = {
}
}
const { mockSetAccountSettingModal, mockSaveAutoUpgrade } = vi.hoisted(() => ({
mockSetAccountSettingModal: vi.fn(),
const { mockSetSettingsDestination, mockSaveAutoUpgrade } = vi.hoisted(() => ({
mockSetSettingsDestination: vi.fn(),
mockSaveAutoUpgrade: vi.fn(),
}))
@ -289,11 +289,10 @@ vi.mock('@langgenius/dify-ui/dialog', () => ({
DialogCloseButton: () => <button type="button" aria-label="close" />,
}))
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: (
selector: (state: { setShowAccountSettingModal: typeof mockSetAccountSettingModal }) => unknown,
) => selector({ setShowAccountSettingModal: mockSetAccountSettingModal }),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({
default: ({

View File

@ -25,8 +25,3 @@ export function useExpandModelProviderList() {
[set],
)
}
export function useResetModelProviderListExpanded() {
const set = useSetAtom(expandedAtom)
return useCallback(() => set({}), [set])
}

View File

@ -16,16 +16,17 @@ import Popup from '../popup'
let mockLanguage = 'en_US'
const mockSetShowAccountSettingModal = vi.hoisted(() => vi.fn())
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
const mockSearchParams = vi.hoisted(() => ({
current: new URLSearchParams(),
}))
const mockSetSettingsDestination = vi.hoisted(() => vi.fn())
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return {
...actual,
useQueryState: () => [mockSearchParams.current.get('settings'), mockSetSettingsDestination],
}
})
vi.mock('@/next/navigation', () => ({
useSearchParams: () => mockSearchParams.current,
}))
@ -1052,13 +1053,11 @@ describe('Popup', () => {
fireEvent.click(screen.getByText('common.modelProvider.selector.modelProviderSettings'))
expect(onHide).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
})
it('should hide provider settings footer when current account settings tab is provider', () => {
mockSearchParams.current = new URLSearchParams('action=showSettings&tab=provider')
it('should hide provider settings footer when provider settings are already open', () => {
mockSearchParams.current = new URLSearchParams('settings=provider')
renderPopup(<PopupHarness modelList={[makeModel()]} onHide={vi.fn()} />)
@ -1090,20 +1089,18 @@ describe('Popup', () => {
fireEvent.click(screen.getByText(/modelProvider\.selector\.configure/))
expect(onHide).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('provider')
})
it('should only close the empty state selector when current account settings tab is provider', () => {
mockSearchParams.current = new URLSearchParams('action=showSettings&tab=provider')
it('should only close the empty state selector when provider settings are already open', () => {
mockSearchParams.current = new URLSearchParams('settings=provider')
const onHide = vi.fn()
renderPopup(<PopupHarness modelList={[]} onHide={onHide} />)
fireEvent.click(screen.getByText(/modelProvider\.selector\.configure/))
expect(onHide).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).not.toHaveBeenCalled()
expect(mockSetSettingsDestination).not.toHaveBeenCalled()
})
it('should render marketplace providers that are not installed', () => {

View File

@ -33,7 +33,6 @@ type ModelSelectorProps = {
hideProviderSettingsFooter?: boolean
onConfigureEmptyState?: () => void
onOpenMarketplace?: () => void
providerSettingsSource?: 'agent'
showModelMeta?: boolean
modelPredicate?: ModelSelectorModelPredicate
modelSuggestionPredicate?: ModelSelectorModelPredicate
@ -52,7 +51,6 @@ function ModelSelector({
hideProviderSettingsFooter,
onConfigureEmptyState,
onOpenMarketplace,
providerSettingsSource,
showModelMeta,
modelPredicate,
modelSuggestionPredicate,
@ -175,7 +173,6 @@ function ModelSelector({
modelList={modelList}
scopeFeatures={scopeFeatures}
hideProviderSettingsFooter={hideProviderSettingsFooter}
providerSettingsSource={providerSettingsSource}
modelPredicate={modelPredicate}
modelSuggestionPredicate={modelSuggestionPredicate}
onConfigureEmptyState={onConfigureEmptyState ? handleConfigureEmptyState : undefined}

View File

@ -10,19 +10,18 @@ import {
} from '@langgenius/dify-ui/preview-card'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { useQueryState } from 'nuqs'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
ACCOUNT_SETTING_MODAL_ACTION,
ACCOUNT_SETTING_TAB,
} from '@/app/components/header/account-setting/constants'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import useWorkspacePluginInstallPermission from '@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useSearchParams } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
import {
@ -63,7 +62,6 @@ export type PopupProps = {
modelList: Model[]
scopeFeatures?: ModelFeatureEnum[]
hideProviderSettingsFooter?: boolean
providerSettingsSource?: 'agent'
modelPredicate?: ModelSelectorModelPredicate
modelSuggestionPredicate?: ModelSelectorModelPredicate
onConfigureEmptyState?: () => void
@ -77,7 +75,6 @@ function Popup({
modelList,
scopeFeatures = [],
hideProviderSettingsFooter,
providerSettingsSource,
modelPredicate,
modelSuggestionPredicate,
onConfigureEmptyState,
@ -86,7 +83,10 @@ function Popup({
onHide,
}: PopupProps) {
const { t } = useTranslation()
const searchParams = useSearchParams()
const [settingsDestination, setSettingsDestination] = useQueryState(
settingsQueryParamName,
settingsQueryParser,
)
const { theme } = useTheme()
const language = useLanguage()
const previewCardHandle = useMemo(
@ -95,7 +95,6 @@ function Popup({
)
const [marketplaceCollapsed, setMarketplaceCollapsed] = useState(false)
const [showIncompatibleModels, setShowIncompatibleModels] = useState(false)
const openIntegrationsSetting = useIntegrationsSetting()
const { modelProviders } = useProviderContext()
const { data: enableMarketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
@ -255,17 +254,12 @@ function Popup({
const handleOpenSettings = useCallback(() => {
onHide()
openIntegrationsSetting({
payload: ACCOUNT_SETTING_TAB.PROVIDER,
source: providerSettingsSource,
})
}, [onHide, openIntegrationsSetting, providerSettingsSource])
setSettingsDestination('provider')
}, [onHide, setSettingsDestination])
const handleClosePreviewCard = useCallback(() => {
previewCardHandle.close()
}, [previewCardHandle])
const isProviderSettingsCurrentPage =
searchParams?.get('action') === ACCOUNT_SETTING_MODAL_ACTION &&
searchParams?.get('tab') === ACCOUNT_SETTING_TAB.PROVIDER
const isProviderSettingsCurrentPage = settingsDestination === 'provider'
const handleConfigureEmptyState =
onConfigureEmptyState ?? (isProviderSettingsCurrentPage ? onHide : handleOpenSettings)

View File

@ -0,0 +1,28 @@
import type { inferParserType } from 'nuqs'
import { parseAsStringLiteral } from 'nuqs'
import { INTEGRATION_SECTION_VALUES } from '@/app/components/integrations/routes'
import { ACCOUNT_SETTING_TAB_VALUES } from './constants'
export const settingsQueryParamName = 'settings'
// Opening the full-screen settings surface is the common write. It creates a history entry and
// opts into a Next.js navigation so browser Back updates both the URL and the nuqs snapshot.
// Closing and switching destinations stay shallow and replace history at the modal owner.
export const settingsQueryParser = parseAsStringLiteral([
...ACCOUNT_SETTING_TAB_VALUES,
...INTEGRATION_SECTION_VALUES,
] as const).withOptions({ history: 'push', shallow: false })
export type SettingsDestination = inferParserType<typeof settingsQueryParser>
export const isAccountSettingDestination = (
destination: SettingsDestination | null,
): destination is (typeof ACCOUNT_SETTING_TAB_VALUES)[number] => {
return ACCOUNT_SETTING_TAB_VALUES.some((value) => value === destination)
}
export const isIntegrationSettingDestination = (
destination: SettingsDestination | null,
): destination is (typeof INTEGRATION_SECTION_VALUES)[number] => {
return INTEGRATION_SECTION_VALUES.some((value) => value === destination)
}

View File

@ -0,0 +1,54 @@
'use client'
import type { SettingsDestination } from './query-params'
import { useQueryState } from 'nuqs'
import dynamic from '@/next/dynamic'
import {
isAccountSettingDestination,
isIntegrationSettingDestination,
settingsQueryParamName,
settingsQueryParser,
} from './query-params'
// This controller is mounted globally, so concrete settings surfaces must stay lazy and only
// load after the URL selects their destination.
const AccountSetting = dynamic(() => import('@/app/components/header/account-setting'), {
ssr: false,
})
const IntegrationsSettingModal = dynamic(() => import('@/app/components/integrations/modal'), {
ssr: false,
})
export function SettingsModal() {
const [destination, setDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const handleClose = () => {
setDestination(null, { history: 'replace', shallow: true })
}
const handleDestinationChange = (nextDestination: SettingsDestination) => {
setDestination(nextDestination, { history: 'replace', shallow: true })
}
if (isAccountSettingDestination(destination)) {
return (
<AccountSetting
activeTab={destination}
onCancelAction={handleClose}
onTabChangeAction={handleDestinationChange}
/>
)
}
if (isIntegrationSettingDestination(destination)) {
return (
<IntegrationsSettingModal
section={destination}
onCancel={handleClose}
onSectionChange={handleDestinationChange}
/>
)
}
return null
}

View File

@ -5,17 +5,20 @@ import type { dayjsToTimeOfDay } from '@/app/components/plugins/reference-settin
import type { PluginCategoryEnum } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioGroup } from '@langgenius/dify-ui/radio'
import { useQueryState } from 'nuqs'
import { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import PluginsPicker from '@/app/components/plugins/reference-setting-modal/auto-update-setting/plugins-picker'
import {
AUTO_UPDATE_MODE,
AUTO_UPDATE_STRATEGY,
} from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types'
import { convertLocalSecondsToUTCDaySeconds } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/utils'
import { useModalContextSelector } from '@/context/modal-context'
import UpdateSettingOptionCard from './update-setting-option-card'
type Option<Value extends string> = {
@ -49,7 +52,10 @@ function SettingTimeZone({
children?: ReactNode
onRequestClose: () => void
}) {
const setShowAccountSettingModal = useModalContextSelector((s) => s.setShowAccountSettingModal)
const [settingsDestination, setSettingsDestination] = useQueryState(
settingsQueryParamName,
settingsQueryParser,
)
return (
<button
@ -57,7 +63,9 @@ function SettingTimeZone({
className="cursor-pointer border-none bg-transparent p-0 text-left body-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={() => {
onRequestClose()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PREFERENCES })
if (settingsDestination)
setSettingsDestination('preferences', { history: 'replace', shallow: true })
else setSettingsDestination('preferences')
}}
>
{children}

View File

@ -1,33 +0,0 @@
'use client'
import type { MovedAccountSettingTab } from './destinations'
import type { IntegrationSection } from '@/app/components/integrations/routes'
import { useCallback } from 'react'
import { useModalContext } from '@/context/modal-context'
import { integrationSectionByMovedAccountSettingTab } from './destinations'
type IntegrationsSettingState =
| { payload: MovedAccountSettingTab; source?: 'agent'; onCancelCallback?: () => void }
| { section: IntegrationSection; source?: 'agent'; onCancelCallback?: () => void }
export const useIntegrationsSetting = () => {
const { setShowAccountSettingModal } = useModalContext()
return useCallback(
(state: IntegrationsSettingState) => {
const section =
'section' in state
? state.section
: integrationSectionByMovedAccountSettingTab[state.payload]
if (section) {
setShowAccountSettingModal({
payload: section,
...(state.source ? { source: state.source } : {}),
...(state.onCancelCallback ? { onCancelCallback: state.onCancelCallback } : {}),
})
}
},
[setShowAccountSettingModal],
)
}

View File

@ -1,29 +1,25 @@
'use client'
import type { IntegrationSection } from '@/app/components/integrations/routes'
import type { IntegrationSection } from './routes'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import MenuDialog from '@/app/components/header/account-setting/menu-dialog'
import IntegrationsPage from '@/app/components/integrations/page'
import { getMarketplaceUrl } from '@/utils/var'
import IntegrationsPage from './page'
type IntegrationsSettingModalProps = {
section: IntegrationSection
source?: 'agent'
onCancel: () => void
onSectionChange: (section: IntegrationSection) => void
}
export default function IntegrationsSettingModal({
section,
source,
onCancel,
onSectionChange,
}: IntegrationsSettingModalProps) {
const { t } = useTranslation()
const isAgentSource = source === 'agent'
const handleSwitchToMarketplace = useCallback((path: string) => {
window.open(
getMarketplaceUrl(path, undefined, { source: window.location.origin }),
@ -33,18 +29,8 @@ export default function IntegrationsSettingModal({
}, [])
return (
<MenuDialog
show
backdropClassName={isAgentSource ? 'bg-background-overlay' : undefined}
className={isAgentSource ? 'bg-transparent backdrop-blur-none' : undefined}
onClose={onCancel}
>
<div
className={cn(
'mx-auto flex h-dvh w-[min(1440px,calc(100vw-48px))] shrink-0 py-6',
isAgentSource && 'w-full p-6',
)}
>
<MenuDialog show onClose={onCancel}>
<div className="mx-auto flex h-dvh w-[min(1440px,calc(100vw-48px))] shrink-0 py-6">
<div className="relative flex min-h-0 w-full shrink-0 overflow-hidden rounded-2xl border border-divider-subtle bg-components-panel-bg shadow-2xl">
<IntegrationsPage
section={section}

View File

@ -343,7 +343,11 @@ vi.mock('@/config', async (importOriginal) => {
const mockPush = vi.fn()
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
const mockUninstall = vi.fn()
const mockUpdatePinStatus = vi.fn()
let mockPathname = '/apps'
@ -537,7 +541,6 @@ describe('MainNav', () => {
} as ProviderContextState)
;(useModalContext as Mock).mockReturnValue({
setShowPricingModal: mockSetShowPricingModal,
setShowAccountSettingModal: mockSetShowAccountSettingModal,
} as unknown as ModalContextState)
;(useGetInstalledApps as Mock).mockImplementation(() => ({
isPending: mockInstalledAppsPending,
@ -1113,24 +1116,18 @@ describe('MainNav', () => {
expect(
screen.getByRole('link', { name: /common\.mainNav\.workspace\.credits|7,500 credits/ }),
).toHaveAttribute('href', '/integrations/model-provider')
expect(mockSetShowAccountSettingModal).not.toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.PROVIDER,
})
expect(mockSetSettingsDestination).not.toHaveBeenCalledWith('provider')
fireEvent.click(screen.getByText('billing.upgradeBtn.plain'))
expect(mockSetShowPricingModal).toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(await screen.findByText('common.mainNav.workspace.settings'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.BILLING,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.BILLING)
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(await screen.findByText('common.mainNav.workspace.inviteMembers'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.MEMBERS,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.MEMBERS)
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(await screen.findByText('Evan Workspace'))
@ -1168,9 +1165,7 @@ describe('MainNav', () => {
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('billing.upgradeBtn.plain'))
expect(mockSetShowPricingModal).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).not.toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.BILLING,
})
expect(mockSetSettingsDestination).not.toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.BILLING)
})
it('limits invite members by member management permission', async () => {

View File

@ -110,7 +110,11 @@ const currentWorkspaceValue: PostWorkspacesCurrentResponse = {
}
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
let mockCurrentWorkspace: PostWorkspacesCurrentResponse | undefined = currentWorkspaceValue
let mockWorkspaces: IWorkspace[] = []
@ -182,7 +186,6 @@ describe('WorkspaceCard', () => {
mockWorkspacePermissionKeys(['workspace.member.manage'])
vi.mocked(useModalContext).mockReturnValue({
setShowPricingModal: mockSetShowPricingModal,
setShowAccountSettingModal: mockSetShowAccountSettingModal,
} as unknown as ModalContextState)
})
@ -448,9 +451,7 @@ describe('WorkspaceCard', () => {
await screen.findByRole('button', { name: 'common.mainNav.workspace.settings' }),
)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.BILLING,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.BILLING)
})
it('opens members settings from workspace menu when billing is disabled', async () => {
@ -466,12 +467,8 @@ describe('WorkspaceCard', () => {
await screen.findByRole('button', { name: 'common.mainNav.workspace.settings' }),
)
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.MEMBERS,
})
expect(mockSetShowAccountSettingModal).not.toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.BILLING,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.MEMBERS)
expect(mockSetSettingsDestination).not.toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.BILLING)
})
it('switches workspace from the workspace switcher item', async () => {

View File

@ -7,11 +7,15 @@ import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgeni
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useQueryState } from 'nuqs'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { WorkspaceAvatar } from '@/app/components/base/workspace-avatar'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import LicenseNav from '@/app/components/header/license-env'
import { buildIntegrationPath } from '@/app/components/integrations/routes'
import { useModalContext } from '@/context/modal-context'
@ -259,7 +263,8 @@ export function WorkspaceCard() {
const currentWorkspace = currentWorkspaceQuery.data
const workspaces = workspacesQuery.data?.workspaces
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
const { setShowPricingModal } = useModalContext()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const isCloudEdition = deploymentEdition === 'CLOUD'
const prefetchWorkspaces = () => {
void queryClient.prefetchQuery(workspacesQueryOptions)
@ -329,13 +334,11 @@ export function WorkspaceCard() {
inviteMembersLabel={t(($) => $['mainNav.workspace.inviteMembers'], { ns: 'common' })}
onOpenSettings={() => {
setOpen(false)
setShowAccountSettingModal({
payload: hasBillingPlan ? ACCOUNT_SETTING_TAB.BILLING : ACCOUNT_SETTING_TAB.MEMBERS,
})
setSettingsDestination(hasBillingPlan ? 'billing' : 'members')
}}
onInviteMembers={() => {
setOpen(false)
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })
setSettingsDestination('members')
}}
/>
<WorkspaceSwitcher

View File

@ -1,12 +1,11 @@
import { cleanup, fireEvent, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { render } from '@/test/console/render'
import PluginAuth from '../plugin-auth'
import { AuthCategory } from '../types'
const mockUsePluginAuth = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockSetSettingsDestination = vi.fn()
const mockConsoleState = vi.hoisted(() => ({
workspacePermissionKeys: ['credential.use', 'credential.create', 'credential.manage'] as string[],
}))
@ -31,11 +30,10 @@ vi.mock('@/context/permission-state', async () => {
}))
})
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
const defaultPayload = {
category: AuthCategory.tool,
@ -155,9 +153,7 @@ describe('PluginAuth', () => {
render(<PluginAuth pluginPayload={defaultPayload} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.auth.permissionHint.action' }))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.MEMBERS,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('members')
})
it('does not render permission hint for datasource authorization', () => {

View File

@ -1,9 +1,12 @@
import type { PluginPayload } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { useQueryState } from 'nuqs'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useCredentialPermissions } from '@/hooks/use-credential-permissions'
import Authorize from './authorize'
import Authorized from './authorized'
@ -17,7 +20,7 @@ type PluginAuthProps = {
}
const PluginAuth = ({ pluginPayload, children, className }: PluginAuthProps) => {
const { t } = useTranslation()
const { setShowAccountSettingModal } = useModalContext()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const { canCreateCredential } = useCredentialPermissions()
const {
isAuthorized,
@ -66,9 +69,7 @@ const PluginAuth = ({ pluginPayload, children, className }: PluginAuthProps) =>
<button
type="button"
className="-ml-1.5 rounded-md px-1.5 py-0.5 system-xs-medium text-text-accent hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={() =>
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })
}
onClick={() => setSettingsDestination('members')}
>
{t(($) => $['auth.permissionHint.action'], { ns: 'plugin' })}
</button>

View File

@ -8,7 +8,6 @@ import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { createAccountProfileQueryClient } from '@/test/console/account-profile'
import { PluginCategoryEnum, PluginSource } from '../../../types'
import AutoUpdateSetting from '../index'
@ -34,17 +33,11 @@ dayjs.extend(timezone)
// Mock app context
const mockTimezone = 'America/New_York'
// Mock modal context
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: (
selector: (s: {
setShowAccountSettingModal: typeof mockSetShowAccountSettingModal
}) => typeof mockSetShowAccountSettingModal,
) => {
return selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal })
},
}))
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
// Mock i18n context
@ -1396,9 +1389,7 @@ describe('auto-update-setting', () => {
fireEvent.click(screen.getByText('autoUpdate.changeTimezone'))
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.PREFERENCES,
})
expect(mockSetSettingsDestination).toHaveBeenCalledWith('preferences')
})
})

View File

@ -6,13 +6,16 @@ import { cn } from '@langgenius/dify-ui/cn'
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
import { RiTimeLine } from '@remixicon/react'
import { useQuery } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
import { convertTimezoneToOffsetStr } from '@/app/components/base/date-and-time-picker/utils/dayjs'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContextSelector } from '@/context/modal-context'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import Label from '../label'
import PluginsPicker from './plugins-picker'
@ -35,12 +38,12 @@ type Props = Readonly<{
const SettingTimeZone: FC<{
children?: React.ReactNode
}> = ({ children }) => {
const setShowAccountSettingModal = useModalContextSelector((s) => s.setShowAccountSettingModal)
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
return (
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left body-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PREFERENCES })}
onClick={() => setSettingsDestination('preferences')}
>
{children}
</button>

View File

@ -14,6 +14,7 @@ import { Plan } from '@/app/components/billing/type'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture'
import { createSystemFeaturesFixture } from '@/test/console/system-features'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
import { createTestQueryClient } from '@/test/query-client'
import StepByStepTourMount from '../mount'
import { stepByStepTourSessionAtom } from '../state'
@ -480,7 +481,7 @@ const setStepByStepTourTestState = (state: Partial<StepByStepTourFixtureState>)
}
}
const renderStepByStepTourMount = () => {
const renderStepByStepTourMount = (searchParams = '') => {
const queryClient = createTestQueryClient()
queryClient.setQueryData(mockStepByStepTour.stateQueryKey, mockStepByStepTour.state)
queryClient.setQueryData(
@ -495,6 +496,7 @@ const renderStepByStepTourMount = () => {
seedRegisteredConsoleStateFixture(jotaiStore)
jotaiStore.set(queryClientAtom, queryClient)
jotaiStore.set(stepByStepTourSessionAtom, mockStepByStepTour.uiState)
const { wrapper } = createNuqsTestWrapper({ searchParams })
return render(
<JotaiProvider store={jotaiStore}>
@ -502,6 +504,7 @@ const renderStepByStepTourMount = () => {
<StepByStepTourMount />
</QueryClientProvider>
</JotaiProvider>,
{ wrapper },
)
}
@ -782,6 +785,23 @@ describe('StepByStepTourMount', () => {
expect(document.body.querySelector('[data-base-ui-portal]')).not.toBeInTheDocument()
})
it('hides expanded tour overlays while settings is open', async () => {
setStepByStepTourTestState({
manuallyEnabledWorkspaceIds: ['workspace-1'],
manuallyDisabledWorkspaceIds: [],
minimized: false,
completedTaskIds: [],
skipped: false,
})
renderStepByStepTourMount('?settings=preferences')
await waitFor(() => {
expect(screen.queryByRole('region', { name: 'Get to know Dify' })).not.toBeInTheDocument()
})
expect(document.body.querySelector('[data-base-ui-portal]')).not.toBeInTheDocument()
})
it('keeps the minimized tour entry available while a blocking modal is open', async () => {
mockHasBlockingModalOpen.value = true
localStorage.setItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY, 'collapsed')

View File

@ -11,8 +11,13 @@ import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent } from '@langgenius/dify-ui/popover'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { useQueryState } from 'nuqs'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { buildIntegrationPath } from '@/app/components/integrations/routes'
import { useDocLink } from '@/context/i18n'
import { useModalContextSelector } from '@/context/modal-context'
@ -130,6 +135,7 @@ export default function StepByStepTourMount({ className }: StepByStepTourMountPr
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const hasBlockingModalOpen = useModalContextSelector((state) => state.hasBlockingModalOpen)
const [settingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const completedTaskIds = useAtomValue(completedStepByStepTourTaskIdsAtom)
const skipped = useAtomValue(stepByStepTourSkippedAtom)
@ -232,7 +238,7 @@ export default function StepByStepTourMount({ className }: StepByStepTourMountPr
stepByStepTourFeatureEnabled &&
enabledForCurrentWorkspace &&
(hasActiveGuide || !shouldHideOnPathname(pathname))
const overlayVisible = visible && !hasBlockingModalOpen
const overlayVisible = visible && !hasBlockingModalOpen && !settingsDestination
const completionPromptVisible = visible && allTasksCompleted && !activeTask
const checklistMinimized = completionPromptVisible ? false : minimized
const expanded = !checklistMinimized

View File

@ -22,11 +22,9 @@ vi.mock('@/service/tools', () => ({
const parseParamsSchemaMock = vi.mocked(parseParamsSchema)
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: (): ModalContextState => ({
hasBlockingModalOpen: false,
setShowAccountSettingModal: mockSetShowAccountSettingModal,
setShowModerationSettingModal: vi.fn(),
setShowExternalDataToolModal: vi.fn(),
setShowPricingModal: mockSetShowPricingModal,

View File

@ -11,7 +11,7 @@ const mockHandleNodeDataUpdate = vi.fn()
const mockHandleNodeDataUpdateWithSyncDraft = vi.fn()
const mockSaveStateToHistory = vi.fn()
const mockSetDetail = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockSetSettingsDestination = vi.fn()
const mockHandleSingleRun = vi.fn()
const mockHandleStop = vi.fn()
const mockHandleRunWithParams = vi.fn()
@ -238,11 +238,10 @@ vi.mock('@/service/use-triggers', () => ({
}),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
}),
}))
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
})
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
@ -672,7 +671,7 @@ describe('workflow-panel index', () => {
fireEvent.click(screen.getByText('authorized-in-datasource-node'))
expect(mockSetShowAccountSettingModal).toHaveBeenCalled()
expect(mockSetSettingsDestination).toHaveBeenCalledWith('data-source')
})
it('should react to pending single run actions', () => {

View File

@ -7,6 +7,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
import { RiCloseLine, RiPlayLargeLine } from '@remixicon/react'
import { debounce } from 'es-toolkit/compat'
import { useAtomValue } from 'jotai'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { cloneElement, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -14,9 +15,11 @@ import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { Stop } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import {
AuthCategory,
AuthorizedInDataSourceNode,
@ -374,11 +377,11 @@ const BasePanel: FC<BasePanelProps> = ({ id, data, children }) => {
[handleNodeDataUpdateWithSyncDraft, id],
)
const openIntegrationsSetting = useIntegrationsSetting()
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const handleJumpToDataSourcePage = useCallback(() => {
openIntegrationsSetting({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE })
}, [openIntegrationsSetting])
setSettingsDestination('data-source')
}, [setSettingsDestination])
const { appendNodeInspectVars } = useInspectVarsCrud()

View File

@ -66,10 +66,6 @@ vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => vi.fn(),
}))
vi.mock('@/app/education-apply/storage', () => ({
useSetEducationVerifying: () => vi.fn(),
}))
vi.mock('@/service/client', () => ({
consoleClient: {
billing: {

View File

@ -1,2 +0,0 @@
export const EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION = 'getEducationVerify'
export const EDUCATION_RE_VERIFY_ACTION = 'educationReVerify'

View File

@ -13,7 +13,6 @@ import { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useEducationDiscount } from '@/app/components/billing/hooks/use-education-discount'
import { Plan } from '@/app/components/billing/type'
import { useSetEducationVerifying } from '@/app/education-apply/storage'
import { useDocLink } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import { currentWorkspaceAtom, isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
@ -51,7 +50,6 @@ const EducationApplyAgeContent = () => {
const router = useRouter()
const openAsyncWindow = useAsyncWindowOpen()
const switchWorkspaceMutation = useMutation(consoleQuery.workspaces.switch.post.mutationOptions())
const setEducationVerifying = useSetEducationVerifying()
const searchParams = useSearchParams()
const token = searchParams.get('token')
@ -71,7 +69,6 @@ const EducationApplyAgeContent = () => {
if (res.message === 'success') {
onPlanInfoChanged()
updateEducationStatus()
setEducationVerifying(null)
setHasSubmittedEducation(true)
} else {
toast.error(t(($) => $.submitError, { ns: 'education' }))

View File

@ -5,19 +5,15 @@ import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import { useCallback, useEffect, useState } from 'react'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import {
useEducationExpiredHasNoticed,
useEducationReverifyHasNoticed,
useEducationReverifyPrevExpireAt,
useEducationVerifying,
} from '@/app/education-apply/storage'
import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { useRouter, useSearchParams } from '@/next/navigation'
import { useEducationAutocomplete, useEducationVerify } from '@/service/use-education'
import { EDUCATION_RE_VERIFY_ACTION, EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION } from './constants'
import { useEducationAutocomplete } from '@/service/use-education'
dayjs.extend(utc)
dayjs.extend(timezone)
@ -119,37 +115,13 @@ const useEducationReverifyNotice = ({ onNotice }: useEducationReverifyNoticePara
}
export const useEducationInit = () => {
const setShowAccountSettingModal = useModalContextSelector((s) => s.setShowAccountSettingModal)
const setShowEducationExpireNoticeModal = useModalContextSelector(
(s) => s.setShowEducationExpireNoticeModal,
)
const [educationVerifying, setEducationVerifying] = useEducationVerifying()
const searchParams = useSearchParams()
const educationVerifyAction = searchParams.get('action')
useEducationReverifyNotice({
onNotice: (payload) => {
setShowEducationExpireNoticeModal({ payload })
},
})
const router = useRouter()
const { mutateAsync } = useEducationVerify()
const handleVerify = async () => {
const { token } = await mutateAsync()
if (token) router.push(`/education-apply?token=${token}`)
}
useEffect(() => {
if (
educationVerifying === 'yes' ||
educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION
) {
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
if (educationVerifyAction === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
setEducationVerifying('yes')
}
if (educationVerifyAction === EDUCATION_RE_VERIFY_ACTION) handleVerify()
}, [setShowAccountSettingModal, setEducationVerifying, educationVerifying, educationVerifyAction])
}

View File

@ -1,10 +1,5 @@
import { createLocalStorageState } from 'foxact/create-local-storage-state'
const EDUCATION_VERIFYING_LOCALSTORAGE_ITEM = 'educationVerifying'
const [useEducationVerifying, _useEducationVerifyingValue, useSetEducationVerifying] =
createLocalStorageState<string>(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'no', { raw: true })
const [
useEducationReverifyPrevExpireAt,
_useEducationReverifyPrevExpireAtValue,
@ -27,9 +22,7 @@ export {
useEducationExpiredHasNoticed,
useEducationReverifyHasNoticed,
useEducationReverifyPrevExpireAt,
useEducationVerifying,
useSetEducationExpiredHasNoticed,
useSetEducationReverifyHasNoticed,
useSetEducationReverifyPrevExpireAt,
useSetEducationVerifying,
}

View File

@ -1,10 +1,9 @@
'use client'
import type { ReactNode, SetStateAction } from 'react'
import type { ReactNode } from 'react'
import type { ModalState, ModelModalType } from './modal-context'
import type { OpeningStatement } from '@/app/components/base/features/types'
import type { CreateExternalAPIReq } from '@/app/components/datasets/external-api/declarations'
import type { SettingsTab } from '@/app/components/header/account-setting/constants'
import type { ModelLoadBalancingModalProps } from '@/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal'
import type { UpdatePluginPayload } from '@/app/components/plugins/types'
import type { InputVar } from '@/app/components/workflow/types'
@ -12,31 +11,14 @@ import type { ExpireNoticeModalPayloadProps } from '@/app/education-apply/expire
import type { ExternalDataTool } from '@/models/common'
import type { ModerationConfig, PromptVariable } from '@/models/debug'
import { useAtomValue } from 'jotai'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
DEFAULT_ACCOUNT_SETTING_TAB,
isIntegrationSettingTab,
isUserSettingTab,
isValidSettingsTab,
isWorkspaceSettingTab,
} from '@/app/components/header/account-setting/constants'
import { useSetEducationVerifying } from '@/app/education-apply/storage'
import { useCallback, useState } from 'react'
import { useProviderContext } from '@/context/provider-context'
import { currentWorkspaceIdAtom } from '@/context/workspace-state'
import { useAccountSettingModal, usePricingModal } from '@/hooks/use-query-params'
import { usePricingModal } from '@/hooks/use-query-params'
import dynamic from '@/next/dynamic'
import { useTriggerEventsLimitModal } from './hooks/use-trigger-events-limit-modal'
import { ModalContext } from './modal-context'
const AccountSetting = dynamic(() => import('@/app/components/header/account-setting'), {
ssr: false,
})
const IntegrationsSettingModal = dynamic(
() => import('@/app/components/tools/integrations-setting-modal'),
{
ssr: false,
},
)
const ModerationSettingModal = dynamic(
() =>
import('@/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal'),
@ -102,19 +84,7 @@ type ModalContextProviderProps = {
children: ReactNode
}
export const ModalContextProvider = ({ children }: ModalContextProviderProps) => {
// Use nuqs hooks for URL-based modal state management
const [showPricingModal, setPricingModalOpen] = usePricingModal()
const [urlAccountModalState, setUrlAccountModalState] = useAccountSettingModal()
const accountSettingCallbacksRef = useRef<Omit<ModalState<SettingsTab>, 'payload'> | null>(null)
const settingsTab = urlAccountModalState.isOpen
? isValidSettingsTab(urlAccountModalState.payload)
? urlAccountModalState.payload
: DEFAULT_ACCOUNT_SETTING_TAB
: null
const accountSettingModalTab =
isWorkspaceSettingTab(settingsTab) || isUserSettingTab(settingsTab) ? settingsTab : null
const integrationSettingModalSection = isIntegrationSettingTab(settingsTab) ? settingsTab : null
const [showModerationSettingModal, setShowModerationSettingModal] =
useState<ModalState<ModerationConfig> | null>(null)
const [showExternalDataToolModal, setShowExternalDataToolModal] =
@ -136,47 +106,8 @@ export const ModalContextProvider = ({ children }: ModalContextProviderProps) =>
const [showEducationExpireNoticeModal, setShowEducationExpireNoticeModal] =
useState<ModalState<ExpireNoticeModalPayloadProps> | null>(null)
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom)
const setEducationVerifying = useSetEducationVerifying()
const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false)
const handleCancelAccountSettingModal = () => {
setEducationVerifying((educationVerifying) =>
educationVerifying === 'yes' ? null : educationVerifying,
)
accountSettingCallbacksRef.current?.onCancelCallback?.()
accountSettingCallbacksRef.current = null
setUrlAccountModalState(null)
}
const handleAccountSettingTabChange = useCallback(
(tab: SettingsTab) => {
setUrlAccountModalState({ payload: tab })
},
[setUrlAccountModalState],
)
const setShowAccountSettingModal = useCallback(
(next: SetStateAction<ModalState<SettingsTab> | null>) => {
const currentState = settingsTab
? { payload: settingsTab, ...accountSettingCallbacksRef.current }
: null
const resolvedState = typeof next === 'function' ? next(currentState) : next
if (!resolvedState) {
accountSettingCallbacksRef.current = null
setUrlAccountModalState(null)
return
}
const { payload, ...callbacks } = resolvedState
accountSettingCallbacksRef.current = callbacks
setUrlAccountModalState({ payload })
},
[settingsTab, setUrlAccountModalState],
)
useEffect(() => {
if (!urlAccountModalState.isOpen) accountSettingCallbacksRef.current = null
}, [urlAccountModalState.isOpen])
const { plan, isFetchedPlan } = useProviderContext()
const {
showTriggerEventsLimitModal,
@ -281,8 +212,6 @@ export const ModalContextProvider = ({ children }: ModalContextProviderProps) =>
setPricingModalOpen(false)
}, [setPricingModalOpen])
const hasBlockingModalOpen = Boolean(
accountSettingModalTab ||
integrationSettingModalSection ||
showModerationSettingModal ||
showExternalDataToolModal ||
showPricingModal ||
@ -300,7 +229,6 @@ export const ModalContextProvider = ({ children }: ModalContextProviderProps) =>
<ModalContext.Provider
value={{
hasBlockingModalOpen,
setShowAccountSettingModal,
setShowModerationSettingModal,
setShowExternalDataToolModal,
setShowPricingModal: handleShowPricingModal,
@ -316,22 +244,6 @@ export const ModalContextProvider = ({ children }: ModalContextProviderProps) =>
>
<>
{children}
{accountSettingModalTab && (
<AccountSetting
activeTab={accountSettingModalTab}
onCancelAction={handleCancelAccountSettingModal}
onTabChangeAction={handleAccountSettingTabChange}
/>
)}
{integrationSettingModalSection && (
<IntegrationsSettingModal
section={integrationSettingModalSection}
source={accountSettingCallbacksRef.current?.source}
onCancel={handleCancelAccountSettingModal}
onSectionChange={(section) => setUrlAccountModalState({ payload: section })}
/>
)}
{!!showModerationSettingModal && (
<ModerationSettingModal
data={showModerationSettingModal.payload}

View File

@ -3,15 +3,11 @@ import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { defaultPlan } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContextSelector } from '@/context/modal-context'
import { ModalContextProvider } from '@/context/modal-context-provider'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render } from '@/test/console/render'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
const mockSetEducationVerifying = vi.hoisted(() => vi.fn())
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
@ -23,23 +19,6 @@ vi.mock('@/app/components/billing/pricing', () => ({
default: () => <div>billing.plansCommon.mostPopular</div>,
}))
vi.mock('@/app/components/header/account-setting', () => ({
default: ({ activeTab, onCancelAction }: { activeTab: string; onCancelAction: () => void }) => (
<>
<div role="status" aria-label="active account setting tab">
{activeTab}
</div>
<button type="button" onClick={onCancelAction}>
cancel account setting
</button>
</>
),
}))
vi.mock('@/app/education-apply/storage', () => ({
useSetEducationVerifying: () => mockSetEducationVerifying,
}))
const mockUseProviderContext = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),
@ -97,47 +76,10 @@ const renderProvider = (
return render(<ModalContextProvider>{children}</ModalContextProvider>, { wrapper })
}
const AccountSettingOpener = () => {
const setShowAccountSettingModal = useModalContextSelector(
(state) => state.setShowAccountSettingModal,
)
return (
<button
type="button"
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })}
>
open account setting
</button>
)
}
const PreferencesOpener = () => {
const setShowAccountSettingModal = useModalContextSelector(
(state) => state.setShowAccountSettingModal,
)
return (
<button
type="button"
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PREFERENCES })}
>
open preferences
</button>
)
}
const BlockingModalProbe = () => {
const hasBlockingModalOpen = useModalContextSelector((state) => state.hasBlockingModalOpen)
return <div data-testid="has-blocking-modal-open">{String(hasBlockingModalOpen)}</div>
}
describe('ModalContextProvider trigger events limit modal', () => {
beforeEach(() => {
mockConsoleStateReader.mockReset()
mockUseProviderContext.mockReset()
mockSetEducationVerifying.mockReset()
window.localStorage.clear()
mockConsoleStateReader.mockReturnValue({
currentWorkspace: {
@ -182,56 +124,6 @@ describe('ModalContextProvider trigger events limit modal', () => {
expect(value).toBe('1')
})
it('clears the education verifying flag when account settings are canceled', async () => {
mockUseProviderContext.mockReturnValue({
plan: createPlan(),
isFetchedPlan: true,
})
const user = userEvent.setup()
renderProvider(<AccountSettingOpener />)
await user.click(screen.getByRole('button', { name: 'open account setting' }))
await user.click(await screen.findByRole('button', { name: 'cancel account setting' }))
expect(mockSetEducationVerifying).toHaveBeenCalledWith(expect.any(Function))
const updater = mockSetEducationVerifying.mock.calls[0]?.[0] as (
educationVerifying: string,
) => string | null
expect(updater('yes')).toBeNull()
expect(updater('no')).toBe('no')
})
it('opens preferences in the account settings shell', async () => {
mockUseProviderContext.mockReturnValue({
plan: createPlan(),
isFetchedPlan: true,
})
const user = userEvent.setup()
renderProvider(
<>
<BlockingModalProbe />
<PreferencesOpener />
</>,
)
expect(screen.getByTestId('has-blocking-modal-open')).toHaveTextContent('false')
await user.click(screen.getByRole('button', { name: 'open preferences' }))
expect(
await screen.findByRole('status', { name: 'active account setting tab' }),
).toHaveTextContent(ACCOUNT_SETTING_TAB.PREFERENCES)
expect(screen.getByTestId('has-blocking-modal-open')).toHaveTextContent('true')
await user.click(screen.getByRole('button', { name: 'cancel account setting' }))
await waitFor(() => {
expect(screen.getByTestId('has-blocking-modal-open')).toHaveTextContent('false')
})
})
it('relies on the in-memory guard when localStorage reads throw', async () => {
const plan = createPlan({
type: Plan.professional,

View File

@ -4,7 +4,6 @@ import type { Dispatch, SetStateAction } from 'react'
import type { TriggerEventsLimitModalPayload } from './hooks/use-trigger-events-limit-modal'
import type { OpeningStatement } from '@/app/components/base/features/types'
import type { CreateExternalAPIReq } from '@/app/components/datasets/external-api/declarations'
import type { SettingsTab } from '@/app/components/header/account-setting/constants'
import type {
ConfigurationMethodEnum,
Credential,
@ -24,7 +23,6 @@ import { createContext, useContext, useContextSelector } from 'use-context-selec
export type ModalState<T> = {
payload: T
source?: 'agent'
onCancelCallback?: () => void
onSaveCallback?: (newPayload?: T, formValues?: Record<string, unknown>) => void
onRemoveCallback?: (newPayload?: T, formValues?: Record<string, unknown>) => void
@ -46,7 +44,6 @@ export type ModelModalType = {
export type ModalContextState = {
hasBlockingModalOpen: boolean
setShowAccountSettingModal: Dispatch<SetStateAction<ModalState<SettingsTab> | null>>
setShowModerationSettingModal: Dispatch<SetStateAction<ModalState<ModerationConfig> | null>>
setShowExternalDataToolModal: Dispatch<SetStateAction<ModalState<ExternalDataTool> | null>>
setShowPricingModal: () => void
@ -76,7 +73,6 @@ export type ModalContextState = {
export const ModalContext = createContext<ModalContextState>({
hasBlockingModalOpen: false,
setShowAccountSettingModal: noop,
setShowModerationSettingModal: noop,
setShowExternalDataToolModal: noop,
setShowPricingModal: noop,

View File

@ -46,7 +46,6 @@ export function AgentModelField({
modelList={textGenerationModelList}
triggerClassName="h-8! w-full rounded-r-none! [&_.i-ri-arrow-down-s-line]:hidden"
popupClassName="w-(--anchor-width) max-w-[min(var(--anchor-width),var(--available-width),calc(100vw-32px))]"
providerSettingsSource="agent"
showModelMeta={false}
modelPredicate={isAgentCompatibleModel}
modelSuggestionPredicate={isAgentSuggestedModel}

View File

@ -1,10 +1,8 @@
import { act, waitFor } from '@testing-library/react'
import { ACCOUNT_SETTING_MODAL_ACTION } from '@/app/components/header/account-setting/constants'
import { renderHookWithNuqs } from '@/test/nuqs-testing'
import {
PRICING_MODAL_QUERY_PARAM,
PRICING_MODAL_QUERY_VALUE,
useAccountSettingModal,
usePluginInstallation,
usePricingModal,
} from './use-query-params'
@ -142,186 +140,6 @@ describe('useQueryParams hooks', () => {
})
})
// Account settings modal query behavior.
describe('useAccountSettingModal', () => {
it('should return closed state with null payload when query params are missing', () => {
// Arrange
const { result } = renderWithAdapter(() => useAccountSettingModal())
// Act
const [state] = result.current
// Assert
expect(state.isOpen).toBe(false)
expect(state.payload).toBeNull()
})
it('should return open state when action matches', () => {
// Arrange
const { result } = renderWithAdapter(
() => useAccountSettingModal(),
`?action=${ACCOUNT_SETTING_MODAL_ACTION}&tab=billing`,
)
// Act
const [state] = result.current
// Assert
expect(state.isOpen).toBe(true)
expect(state.payload).toBe('billing')
})
it('should accept integrations tabs with the shared settings action', () => {
// Arrange
const { result } = renderWithAdapter(
() => useAccountSettingModal(),
`?action=${ACCOUNT_SETTING_MODAL_ACTION}&tab=mcp`,
)
// Act
const [state] = result.current
// Assert
expect(state.isOpen).toBe(true)
expect(state.payload).toBe('mcp')
})
it('should return closed state when action does not match', () => {
// Arrange
const { result } = renderWithAdapter(
() => useAccountSettingModal(),
'?action=other&tab=billing',
)
// Act
const [state] = result.current
// Assert
expect(state.isOpen).toBe(false)
expect(state.payload).toBeNull()
})
it('should set action and tab when opening', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(() => useAccountSettingModal())
// Act
act(() => {
result.current[1]({ payload: 'members' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(update.searchParams.get('action')).toBe(ACCOUNT_SETTING_MODAL_ACTION)
expect(update.searchParams.get('tab')).toBe('members')
})
it('should set an integrations tab with the shared settings action', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(() => useAccountSettingModal())
// Act
act(() => {
result.current[1]({ payload: 'data-source' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(update.searchParams.get('action')).toBe(ACCOUNT_SETTING_MODAL_ACTION)
expect(update.searchParams.get('tab')).toBe('data-source')
})
it('should use push history when opening from closed state', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(() => useAccountSettingModal())
// Act
act(() => {
result.current[1]({ payload: 'members' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(update.options.history).toBe('push')
})
it('should update tab when switching while open', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => useAccountSettingModal(),
`?action=${ACCOUNT_SETTING_MODAL_ACTION}&tab=billing`,
)
// Act
act(() => {
result.current[1]({ payload: 'provider' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(update.searchParams.get('tab')).toBe('provider')
})
it('should use replace history when switching tabs while open', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => useAccountSettingModal(),
`?action=${ACCOUNT_SETTING_MODAL_ACTION}&tab=billing`,
)
// Act
act(() => {
result.current[1]({ payload: 'provider' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(update.options.history).toBe('replace')
})
it('should clear action and tab when closing', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => useAccountSettingModal(),
`?action=${ACCOUNT_SETTING_MODAL_ACTION}&tab=billing`,
)
// Act
act(() => {
result.current[1](null)
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(update.searchParams.has('action')).toBe(false)
expect(update.searchParams.has('tab')).toBe(false)
})
it('should use replace history when closing', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => useAccountSettingModal(),
`?action=${ACCOUNT_SETTING_MODAL_ACTION}&tab=billing`,
)
// Act
act(() => {
result.current[1](null)
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(update.options.history).toBe('replace')
})
})
// Plugin installation query behavior.
describe('usePluginInstallation', () => {
it('should parse package ids from JSON arrays', () => {

View File

@ -13,19 +13,7 @@
* - Use shallow routing to avoid unnecessary re-renders
*/
import type { SettingsTab } from '@/app/components/header/account-setting/constants'
import {
createParser,
parseAsStringEnum,
parseAsStringLiteral,
useQueryState,
useQueryStates,
} from 'nuqs'
import { useCallback } from 'react'
import {
ACCOUNT_SETTING_MODAL_ACTION,
SETTINGS_TAB_VALUES,
} from '@/app/components/header/account-setting/constants'
import { createParser, useQueryState, useQueryStates } from 'nuqs'
/**
* Modal State Query Parameters
@ -53,51 +41,6 @@ export function usePricingModal() {
return useQueryState(PRICING_MODAL_QUERY_PARAM, parseAsPricingModal)
}
const settingsTabValues = [...SETTINGS_TAB_VALUES] as SettingsTab[]
const parseAsAccountSettingAction = parseAsStringLiteral([ACCOUNT_SETTING_MODAL_ACTION] as const)
const parseAsSettingsTab = parseAsStringEnum<SettingsTab>(settingsTabValues)
/**
* Hook to manage account setting modal state via URL
* @returns [state, setState] - Object with isOpen + payload (tab) and setter
*
* @example
* const [accountModalState, setAccountModalState] = useAccountSettingModal()
* setAccountModalState({ payload: 'billing' }) // Sets ?action=showSettings&tab=billing
* setAccountModalState(null) // Removes both params
*/
export function useAccountSettingModal() {
const [accountState, setAccountState] = useQueryStates(
{
action: parseAsAccountSettingAction,
tab: parseAsSettingsTab,
},
{
history: 'replace',
},
)
const setState = useCallback(
(state: { payload: SettingsTab } | null) => {
if (!state) {
setAccountState({ action: null, tab: null }, { history: 'replace' })
return
}
const shouldPush = accountState.action !== ACCOUNT_SETTING_MODAL_ACTION
setAccountState(
{ action: ACCOUNT_SETTING_MODAL_ACTION, tab: state.payload },
{ history: shouldPush ? 'push' : 'replace' },
)
},
[accountState.action, setAccountState],
)
const isOpen = accountState.action === ACCOUNT_SETTING_MODAL_ACTION
const currentTab = isOpen ? accountState.tab : null
return [{ isOpen, payload: currentTab }, setState] as const
}
/**
* Plugin Installation Query Parameters
*/