diff --git a/web/__tests__/base/notion-page-selector-flow.test.tsx b/web/__tests__/base/notion-page-selector-flow.test.tsx index 524093c437f..14a7ae9535d 100644 --- a/web/__tests__/base/notion-page-selector-flow.test.tsx +++ b/web/__tests__/base/notion-page-selector-flow.test.tsx @@ -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() + 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') }) }) diff --git a/web/__tests__/billing/billing-integration.test.tsx b/web/__tests__/billing/billing-integration.test.tsx index 494b6593054..f522f39159e 100644 --- a/web/__tests__/billing/billing-integration.test.tsx +++ b/web/__tests__/billing/billing-integration.test.tsx @@ -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) => unknown) => - selector({ - setShowAccountSettingModal: mockSetShowAccountSettingModal, - }), })) vi.mock('@/context/i18n', () => ({ diff --git a/web/__tests__/billing/education-verification-flow.test.tsx b/web/__tests__/billing/education-verification-flow.test.tsx index caa04d1e73d..769a00a30d0 100644 --- a/web/__tests__/billing/education-verification-flow.test.tsx +++ b/web/__tests__/billing/education-verification-flow.test.tsx @@ -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) => 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() - - await user.click(screen.getByText(/toVerified/i)) - - await waitFor(() => { - expect(mockSetEducationVerifying).toHaveBeenCalledWith(null) - }) - }) }) // ─── 3. Failed Verification Flow ──────────────────────────────────────── diff --git a/web/app/(commonLayout)/global-mounts.tsx b/web/app/(commonLayout)/global-mounts.tsx index 7d0c118b95f..f85cfff5a5c 100644 --- a/web/app/(commonLayout)/global-mounts.tsx +++ b/web/app/(commonLayout)/global-mounts.tsx @@ -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() { + ) } diff --git a/web/app/(commonLayout)/providers.tsx b/web/app/(commonLayout)/providers.tsx index 28cd8ab94f5..e0cc043fc2a 100644 --- a/web/app/(commonLayout)/providers.tsx +++ b/web/app/(commonLayout)/providers.tsx @@ -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 ( <> - diff --git a/web/app/components/__tests__/education-verify-action-recorder.spec.tsx b/web/app/components/__tests__/education-verify-action-recorder.spec.tsx deleted file mode 100644 index 6abcf2226ed..00000000000 --- a/web/app/components/__tests__/education-verify-action-recorder.spec.tsx +++ /dev/null @@ -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, - ) - }) - - 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, - ) - - render() - - await waitFor(() => { - expect(setEducationVerifyingMock).toHaveBeenCalledWith('yes') - }) - }) - - it('should leave localStorage unchanged for unrelated routes', () => { - render() - - expect(setEducationVerifyingMock).not.toHaveBeenCalled() - }) -}) diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx index cf29f617e13..81d0904bc6c 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/__tests__/index.spec.tsx @@ -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() + 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') }) }) diff --git a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx index 36a5bf54007..ce20215f8d7 100644 --- a/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx +++ b/web/app/components/app/configuration/dataset-config/settings-modal/index.tsx @@ -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 = ({ 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 = ({ diff --git a/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx b/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx index 6e47677e660..469658233b7 100644 --- a/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx +++ b/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx @@ -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() + 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', diff --git a/web/app/components/app/configuration/hooks/use-configuration.ts b/web/app/components/app/configuration/hooks/use-configuration.ts index a9326c3ae8a..3420b0bb752 100644 --- a/web/app/components/app/configuration/hooks/use-configuration.ts +++ b/web/app/components/app/configuration/hooks/use-configuration.ts @@ -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) diff --git a/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx b/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx index 9f7214d4ae4..3e345357417 100644 --- a/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx +++ b/web/app/components/app/log/__tests__/archived-logs-notice.spec.tsx @@ -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() - return { - ...actual, - useModalContextSelector: vi.fn(), - } +const setSettingsDestination = vi.fn() +vi.mock('nuqs', async (importOriginal) => { + const actual = await importOriginal() + 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[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', () => { diff --git a/web/app/components/app/log/archived-logs-notice.tsx b/web/app/components/app/log/archived-logs-notice.tsx index ef3f59f1c72..2792e3eade5 100644 --- a/web/app/components/app/log/archived-logs-notice.tsx +++ b/web/app/components/app/log/archived-logs-notice.tsx @@ -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() { diff --git a/web/app/components/app/overview/apikey-info-panel/__tests__/cloud.spec.tsx b/web/app/components/app/overview/apikey-info-panel/__tests__/cloud.spec.tsx index 81ed2b9f286..efe8a459829 100644 --- a/web/app/components/app/overview/apikey-info-panel/__tests__/cloud.spec.tsx +++ b/web/app/components/app/overview/apikey-info-panel/__tests__/cloud.spec.tsx @@ -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', () => { diff --git a/web/app/components/app/overview/apikey-info-panel/__tests__/index.spec.tsx b/web/app/components/app/overview/apikey-info-panel/__tests__/index.spec.tsx index 3ae1a19f0ab..75d6c60cef6 100644 --- a/web/app/components/app/overview/apikey-info-panel/__tests__/index.spec.tsx +++ b/web/app/components/app/overview/apikey-info-panel/__tests__/index.spec.tsx @@ -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', () => { diff --git a/web/app/components/app/overview/apikey-info-panel/__tests__/test-utils.tsx b/web/app/components/app/overview/apikey-info-panel/__tests__/test-utils.tsx index 2864d2fc0fb..321dbe73ed2 100644 --- a/web/app/components/app/overview/apikey-info-panel/__tests__/test-utils.tsx +++ b/web/app/components/app/overview/apikey-info-panel/__tests__/test-utils.tsx @@ -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() + 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 -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 - modalContext?: Partial } 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 } diff --git a/web/app/components/app/overview/apikey-info-panel/index.tsx b/web/app/components/app/overview/apikey-info-panel/index.tsx index 59b5e3a39b8..f670dd13f73 100644 --- a/web/app/components/app/overview/apikey-info-panel/index.tsx +++ b/web/app/components/app/overview/apikey-info-panel/index.tsx @@ -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 = () => { + + ), +})) + +vi.mock('@/app/components/integrations/modal', () => ({ + default: ({ + section, + onCancel, + onSectionChange, + }: { + section: string + onCancel: () => void + onSectionChange: (section: 'data-source') => void + }) => ( + <> +
+ {section} +
+ + + + ), +})) + +function PreferencesOpener() { + const [settingsDestination, setSettingsDestination] = useQueryState( + settingsQueryParamName, + settingsQueryParser, + ) + + return ( + + ) +} + +const renderSettingsModal = (searchParams = '', children?: React.ReactNode) => { + const { wrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams }) + + return { + ...render( + <> + {children} + + , + { wrapper }, + ), + onUrlUpdate, + } +} + +describe('SettingsModal', () => { + it('opens account settings with push and closes them with replace', async () => { + const user = userEvent.setup() + const { onUrlUpdate } = renderSettingsModal('', ) + + 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() + }) +}) diff --git a/web/app/components/header/account-setting/__tests__/update-setting-dialog-form.spec.tsx b/web/app/components/header/account-setting/__tests__/update-setting-dialog-form.spec.tsx index c62c6cf6ae0..9fd44622fe3 100644 --- a/web/app/components/header/account-setting/__tests__/update-setting-dialog-form.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/update-setting-dialog-form.spec.tsx @@ -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() + 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( + minutes} + onAutoUpgradeChange={vi.fn()} + onPluginsChange={vi.fn()} + onRequestClose={vi.fn()} + onUpdateTimeChange={vi.fn()} + renderTimePickerTrigger={() => } + />, + ) + + fireEvent.click(screen.getByText('autoUpdate.changeTimezone')) + + expect(mockSetSettingsDestination).toHaveBeenCalledWith('preferences', { + history: 'replace', + shallow: true, }) }) }) diff --git a/web/app/components/header/account-setting/__tests__/use-integrations-setting.spec.ts b/web/app/components/header/account-setting/__tests__/use-integrations-setting.spec.ts deleted file mode 100644 index 2e0af718046..00000000000 --- a/web/app/components/header/account-setting/__tests__/use-integrations-setting.spec.ts +++ /dev/null @@ -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, - }) - }) -}) diff --git a/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx b/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx index ba06a51d27c..75f0b4267cc 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/__tests__/selector.spec.tsx @@ -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() + 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 () => { diff --git a/web/app/components/header/account-setting/api-based-extension-page/selector.tsx b/web/app/components/header/account-setting/api-based-extension-page/selector.tsx index fb16e6ef1dc..d36bc90a6d4 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/selector.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/selector.tsx @@ -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' })} diff --git a/web/app/components/header/account-setting/constants.ts b/web/app/components/header/account-setting/constants.ts index c433f6e0ac1..78e0ef3e95e 100644 --- a/web/app/components/header/account-setting/constants.ts +++ b/web/app/components/header/account-setting/constants.ts @@ -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) -} diff --git a/web/app/components/header/account-setting/destinations.ts b/web/app/components/header/account-setting/destinations.ts deleted file mode 100644 index 6921fa81a32..00000000000 --- a/web/app/components/header/account-setting/destinations.ts +++ /dev/null @@ -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> - -export type MovedAccountSettingTab = keyof typeof integrationSectionByMovedAccountSettingTab diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index 7749686568e..dbd1ec3bd83 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -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(null) const settingItems: GroupItem[] = [ - { - key: ACCOUNT_SETTING_TAB.PROVIDER, - name: t(($) => $['settings.provider'], { ns: 'common' }), - icon: , - activeIcon: , - }, { key: ACCOUNT_SETTING_TAB.MEMBERS, name: t(($) => $['settings.members'], { ns: 'common' }), @@ -128,18 +111,6 @@ export default function AccountSetting({ icon: , activeIcon: , }, - { - key: ACCOUNT_SETTING_TAB.DATA_SOURCE, - name: t(($) => $['settings.dataSource'], { ns: 'common' }), - icon: , - activeIcon: , - }, - { - key: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION, - name: t(($) => $['settings.customEndpoint'], { ns: 'common' }), - icon: , - activeIcon: , - }, { key: ACCOUNT_SETTING_TAB.CUSTOM, name: t(($) => $.custom, { ns: 'custom' }), @@ -193,31 +164,15 @@ export default function AccountSetting({ }, ] - const [searchValue, setSearchValue] = useState('') - - const handleTabChange = useCallback( - (tab: AccountSettingTab) => { - if (tab === ACCOUNT_SETTING_TAB.PROVIDER) resetModelProviderListExpanded() - - onTabChangeAction(tab) - }, - [onTabChangeAction, resetModelProviderListExpanded], - ) - - const handleClose = useCallback(() => { - resetModelProviderListExpanded() - onCancelAction() - }, [onCancelAction, resetModelProviderListExpanded]) - return ( - +
@@ -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({
- {activeMenu === ACCOUNT_SETTING_TAB.PROVIDER && ( - - )} {activeMenu === ACCOUNT_SETTING_TAB.MEMBERS && } {activeMenu === ACCOUNT_SETTING_TAB.ROLES_AND_PERMISSIONS && ( @@ -301,8 +253,6 @@ export default function AccountSetting({ {activeMenu === ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES && ( )} - {activeMenu === ACCOUNT_SETTING_TAB.DATA_SOURCE && } - {activeMenu === ACCOUNT_SETTING_TAB.API_BASED_EXTENSION && } {activeMenu === ACCOUNT_SETTING_TAB.CUSTOM && } {activeMenu === ACCOUNT_SETTING_TAB.PREFERENCES && }
diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/atoms.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/atoms.spec.tsx index 890aeade4c8..b1ae492cbb6 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/atoms.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/atoms.spec.tsx @@ -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 diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx index 6a20a6a08b1..5f0fd4805ef 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx @@ -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: () => diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx index 0c53e7aa7cb..ea81677eb7b 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/__tests__/index.spec.tsx @@ -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() + 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') }) }) diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx index c974a1f7505..6197cbd320c 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx @@ -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 ( diff --git a/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx b/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx index e099f13a36f..c9d09999467 100644 --- a/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx +++ b/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx @@ -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) } } -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( @@ -502,6 +504,7 @@ const renderStepByStepTourMount = () => { , + { 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') diff --git a/web/app/components/step-by-step-tour/mount.tsx b/web/app/components/step-by-step-tour/mount.tsx index b50ea058f9d..de604958905 100644 --- a/web/app/components/step-by-step-tour/mount.tsx +++ b/web/app/components/step-by-step-tour/mount.tsx @@ -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 diff --git a/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx index 7523ef42b7c..021d0ee57b7 100644 --- a/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx @@ -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, diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx index dc530886182..ee134a640f6 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx @@ -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() + return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } +}) vi.mock('@/app/components/workflow/utils', async (importOriginal) => { const actual = await importOriginal() @@ -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', () => { diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index 0cab2f85aee..a412af20c76 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -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 = ({ 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() diff --git a/web/app/education-apply/__tests__/education-apply-page.spec.tsx b/web/app/education-apply/__tests__/education-apply-page.spec.tsx index 027145b39e2..b52afa04e77 100644 --- a/web/app/education-apply/__tests__/education-apply-page.spec.tsx +++ b/web/app/education-apply/__tests__/education-apply-page.spec.tsx @@ -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: { diff --git a/web/app/education-apply/constants.ts b/web/app/education-apply/constants.ts deleted file mode 100644 index cb0c339af3a..00000000000 --- a/web/app/education-apply/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION = 'getEducationVerify' -export const EDUCATION_RE_VERIFY_ACTION = 'educationReVerify' diff --git a/web/app/education-apply/education-apply-page.tsx b/web/app/education-apply/education-apply-page.tsx index 316769bbc3b..190eb4eb10b 100644 --- a/web/app/education-apply/education-apply-page.tsx +++ b/web/app/education-apply/education-apply-page.tsx @@ -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' })) diff --git a/web/app/education-apply/hooks.ts b/web/app/education-apply/hooks.ts index cb51f74695b..abb91ea6930 100644 --- a/web/app/education-apply/hooks.ts +++ b/web/app/education-apply/hooks.ts @@ -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]) } diff --git a/web/app/education-apply/storage.ts b/web/app/education-apply/storage.ts index d460d622d31..fc7e764d62a 100644 --- a/web/app/education-apply/storage.ts +++ b/web/app/education-apply/storage.ts @@ -1,10 +1,5 @@ import { createLocalStorageState } from 'foxact/create-local-storage-state' -const EDUCATION_VERIFYING_LOCALSTORAGE_ITEM = 'educationVerifying' - -const [useEducationVerifying, _useEducationVerifyingValue, useSetEducationVerifying] = - createLocalStorageState(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'no', { raw: true }) - const [ useEducationReverifyPrevExpireAt, _useEducationReverifyPrevExpireAtValue, @@ -27,9 +22,7 @@ export { useEducationExpiredHasNoticed, useEducationReverifyHasNoticed, useEducationReverifyPrevExpireAt, - useEducationVerifying, useSetEducationExpiredHasNoticed, useSetEducationReverifyHasNoticed, useSetEducationReverifyPrevExpireAt, - useSetEducationVerifying, } diff --git a/web/context/modal-context-provider.tsx b/web/context/modal-context-provider.tsx index 8631a52c491..760a9325f2a 100644 --- a/web/context/modal-context-provider.tsx +++ b/web/context/modal-context-provider.tsx @@ -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, '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 | null>(null) const [showExternalDataToolModal, setShowExternalDataToolModal] = @@ -136,47 +106,8 @@ export const ModalContextProvider = ({ children }: ModalContextProviderProps) => const [showEducationExpireNoticeModal, setShowEducationExpireNoticeModal] = useState | 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 | 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) => > <> {children} - {accountSettingModalTab && ( - - )} - {integrationSettingModalSection && ( - setUrlAccountModalState({ payload: section })} - /> - )} - {!!showModerationSettingModal && ( vi.fn()) - vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), @@ -23,23 +19,6 @@ vi.mock('@/app/components/billing/pricing', () => ({ default: () =>
billing.plansCommon.mostPopular
, })) -vi.mock('@/app/components/header/account-setting', () => ({ - default: ({ activeTab, onCancelAction }: { activeTab: string; onCancelAction: () => void }) => ( - <> -
- {activeTab} -
- - - ), -})) - -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({children}, { wrapper }) } -const AccountSettingOpener = () => { - const setShowAccountSettingModal = useModalContextSelector( - (state) => state.setShowAccountSettingModal, - ) - - return ( - - ) -} - -const PreferencesOpener = () => { - const setShowAccountSettingModal = useModalContextSelector( - (state) => state.setShowAccountSettingModal, - ) - - return ( - - ) -} - -const BlockingModalProbe = () => { - const hasBlockingModalOpen = useModalContextSelector((state) => state.hasBlockingModalOpen) - - return
{String(hasBlockingModalOpen)}
-} - 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() - - 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( - <> - - - , - ) - - 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, diff --git a/web/context/modal-context.ts b/web/context/modal-context.ts index 045aaaef468..04ce47f3198 100644 --- a/web/context/modal-context.ts +++ b/web/context/modal-context.ts @@ -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 = { payload: T - source?: 'agent' onCancelCallback?: () => void onSaveCallback?: (newPayload?: T, formValues?: Record) => void onRemoveCallback?: (newPayload?: T, formValues?: Record) => void @@ -46,7 +44,6 @@ export type ModelModalType = { export type ModalContextState = { hasBlockingModalOpen: boolean - setShowAccountSettingModal: Dispatch | null>> setShowModerationSettingModal: Dispatch | null>> setShowExternalDataToolModal: Dispatch | null>> setShowPricingModal: () => void @@ -76,7 +73,6 @@ export type ModalContextState = { export const ModalContext = createContext({ hasBlockingModalOpen: false, - setShowAccountSettingModal: noop, setShowModerationSettingModal: noop, setShowExternalDataToolModal: noop, setShowPricingModal: noop, diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/field.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/field.tsx index 1de8273dbcf..0c33929f08b 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/field.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/model-config/field.tsx @@ -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} diff --git a/web/hooks/use-query-params.spec.tsx b/web/hooks/use-query-params.spec.tsx index 262aab398f8..a67726865fa 100644 --- a/web/hooks/use-query-params.spec.tsx +++ b/web/hooks/use-query-params.spec.tsx @@ -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', () => { diff --git a/web/hooks/use-query-params.ts b/web/hooks/use-query-params.ts index f459881634b..c3d88cae8a4 100644 --- a/web/hooks/use-query-params.ts +++ b/web/hooks/use-query-params.ts @@ -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(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 */