From dea4e66456912fa5e7c8271d8e2c4e71266712fe Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:28:05 +0800 Subject: [PATCH] fix(web): use generated account-profile contracts (#36927) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/workspace/account.py | 10 +-- api/openapi/markdown/console-swagger.md | 2 +- .../unit_tests/controllers/test_swagger.py | 20 ++++++ .../app/log/__tests__/list.spec.tsx | 14 ++-- web/app/components/app/log/list.tsx | 8 ++- web/app/components/app/workflow-log/index.tsx | 8 ++- .../__tests__/chat-wrapper.spec.tsx | 7 ++ .../chat/chat/answer/__tests__/index.spec.tsx | 7 ++ .../__tests__/index.spec.tsx | 5 +- .../base/__tests__/date-picker.spec.tsx | 9 ++- .../datasets/metadata/base/date-picker.tsx | 8 ++- .../__tests__/index.spec.tsx | 6 ++ .../language-page/__tests__/index.spec.tsx | 6 +- .../detail-header/__tests__/index.spec.tsx | 9 ++- .../detail-header/index.tsx | 8 ++- .../__tests__/index.spec.tsx | 28 ++++---- .../auto-update-setting/index.tsx | 8 ++- .../__tests__/integration.spec.tsx | 38 +++++----- .../condition-list/condition-date.tsx | 8 ++- .../__tests__/use-config.spec.ts | 7 +- .../nodes/trigger-schedule/use-config.ts | 12 ++-- web/app/education-apply/hooks.ts | 8 ++- web/context/app-context-provider.tsx | 5 +- web/context/app-context.ts | 5 +- web/contract/console/account.ts | 37 ---------- web/contract/router.ts | 9 --- .../account-profile/__tests__/server.spec.ts | 4 +- web/features/account-profile/client.ts | 6 +- web/features/account-profile/server.ts | 4 +- web/hooks/use-timestamp.spec.ts | 9 +-- web/hooks/use-timestamp.ts | 8 ++- web/models/common.ts | 24 ++----- web/service/common.ts | 2 +- web/test/account-profile-query.ts | 70 +++++++++++++++++++ 34 files changed, 268 insertions(+), 151 deletions(-) delete mode 100644 web/contract/console/account.ts create mode 100644 web/test/account-profile-query.ts diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index d2f5d44b11..984a128376 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -18,7 +18,7 @@ from controllers.common.fields import ( SimpleResultResponse, VerificationTokenResponse, ) -from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models from controllers.console import console_ns from controllers.console.auth.error import ( EmailAlreadyInUseError, @@ -48,7 +48,7 @@ from fields.base import ResponseModel from fields.member_fields import Account as AccountResponse from graphon.file import helpers as file_helpers from libs.datetime_utils import naive_utc_now -from libs.helper import EmailStr, extract_remote_ip, timezone, to_timestamp +from libs.helper import EmailStr, dump_response, extract_remote_ip, timezone, to_timestamp from libs.login import current_account_with_tenant, login_required from models import AccountIntegrate, InvitationCode from models.account import AccountStatus, InvitationCodeStatus @@ -329,9 +329,9 @@ class AccountNameApi(Resource): @console_ns.route("/account/avatar") class AccountAvatarApi(Resource): - @console_ns.expect(console_ns.models[AccountAvatarQuery.__name__]) @console_ns.doc("get_account_avatar") @console_ns.doc(description="Get account avatar url") + @console_ns.doc(params=query_params_from_model(AccountAvatarQuery)) @console_ns.response(200, "Success", console_ns.models[AvatarUrlResponse.__name__]) @setup_required @login_required @@ -342,7 +342,7 @@ class AccountAvatarApi(Resource): avatar = args.avatar if avatar.startswith(("http://", "https://")): - return {"avatar_url": avatar} + return dump_response(AvatarUrlResponse, {"avatar_url": avatar}) upload_file = db.session.scalar(select(UploadFile).where(UploadFile.id == avatar).limit(1)) if upload_file is None: @@ -355,7 +355,7 @@ class AccountAvatarApi(Resource): raise NotFound("Avatar file not found") avatar_url = file_helpers.get_signed_file_url(upload_file_id=upload_file.id) - return {"avatar_url": avatar_url} + return dump_response(AvatarUrlResponse, {"avatar_url": avatar_url}) @console_ns.expect(console_ns.models[AccountAvatarPayload.__name__]) @setup_required diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index a925ed8989..04235cd528 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -27,7 +27,7 @@ Get account avatar url | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| payload | body | | Yes | [AccountAvatarQuery](#accountavatarquery) | +| avatar | query | Avatar file ID | Yes | string | ##### Responses diff --git a/api/tests/unit_tests/controllers/test_swagger.py b/api/tests/unit_tests/controllers/test_swagger.py index e45c2658d3..4e81763a20 100644 --- a/api/tests/unit_tests/controllers/test_swagger.py +++ b/api/tests/unit_tests/controllers/test_swagger.py @@ -140,3 +140,23 @@ def test_service_document_list_documents_query_params_render(monkeypatch: pytest for name in ("page", "limit", "keyword", "status"): assert params[name]["in"] == "query" + + +def test_console_account_avatar_query_param_renders_as_query(monkeypatch: pytest.MonkeyPatch): + from configs import dify_config + from controllers.console import bp as console_bp + + monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True) + + app = Flask(__name__) + app.config["TESTING"] = True + app.config["RESTX_INCLUDE_ALL_MODELS"] = True + app.register_blueprint(console_bp) + + payload = app.test_client().get("/console/api/swagger.json").get_json() + operation = payload["paths"]["/account/avatar"]["get"] + params = _parameters_by_name(operation) + + assert "payload" not in params + assert params["avatar"]["in"] == "query" + assert params["avatar"]["required"] is True diff --git a/web/app/components/app/log/__tests__/list.spec.tsx b/web/app/components/app/log/__tests__/list.spec.tsx index dbd350f16b..6812461671 100644 --- a/web/app/components/app/log/__tests__/list.spec.tsx +++ b/web/app/components/app/log/__tests__/list.spec.tsx @@ -1,6 +1,7 @@ /* eslint-disable ts/no-explicit-any */ import type { ReactNode } from 'react' import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import { AccountProfileQueryProvider, createAccountProfileQueryClient } from '@/test/account-profile-query' import { renderWithNuqs } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' import ConversationList from '../list' @@ -234,12 +235,15 @@ const renderConversationList = ({ logs?: any searchParams?: string } = {}) => { + const queryClient = createAccountProfileQueryClient({ timezone: 'Asia/Shanghai' }) return renderWithNuqs( - , + + + , { searchParams }, ) } diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index e28f5473f6..315cddaa12 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -21,6 +21,7 @@ import { StatusDot } from '@langgenius/dify-ui/status-dot' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiCloseLine, RiEditFill } from '@remixicon/react' +import { useQuery } from '@tanstack/react-query' import dayjs from 'dayjs' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' @@ -40,7 +41,7 @@ import CopyIcon from '@/app/components/base/copy-icon' import Loading from '@/app/components/base/loading' import MessageLogModal from '@/app/components/base/message-log-modal' import { WorkflowContextProvider } from '@/app/components/workflow/context' -import { useAppContext } from '@/context/app-context' +import { userProfileQueryOptions } from '@/features/account-profile/client' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useTimestamp from '@/hooks/use-timestamp' import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log' @@ -158,7 +159,10 @@ type IDetailPanel = { function DetailPanel({ detail, onFeedback }: IDetailPanel) { const MIN_ITEMS_FOR_SCROLL_LOADING = 8 const SCROLL_DEBOUNCE_MS = 200 - const { userProfile: { timezone } } = useAppContext() + const { data: timezone } = useQuery({ + ...userProfileQueryOptions(), + select: data => data.profile.timezone ?? undefined, + }) const { formatTime } = useTimestamp() const { onClose, appDetail } = useContext(DrawerContext) const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, showPromptLogModal, setShowPromptLogModal, currentLogModalActiveTab } = useAppStore(useShallow((state: AppStoreState) => ({ diff --git a/web/app/components/app/workflow-log/index.tsx b/web/app/components/app/workflow-log/index.tsx index 762a9897d8..ea7c6c178b 100644 --- a/web/app/components/app/workflow-log/index.tsx +++ b/web/app/components/app/workflow-log/index.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import type { App } from '@/types/app' import { Pagination } from '@langgenius/dify-ui/pagination' +import { useQuery } from '@tanstack/react-query' import { useDebounce } from 'ahooks' import dayjs from 'dayjs' import timezone from 'dayjs/plugin/timezone' @@ -13,7 +14,7 @@ import { useTranslation } from 'react-i18next' import EmptyElement from '@/app/components/app/log/empty-element' import Loading from '@/app/components/base/loading' import { APP_PAGE_LIMIT } from '@/config' -import { useAppContext } from '@/context/app-context' +import { userProfileQueryOptions } from '@/features/account-profile/client' import { useWorkflowLogs } from '@/service/use-log' import Filter, { TIME_PERIOD_MAPPING } from './filter' import List from './list' @@ -33,7 +34,10 @@ export type QueryParam = { const Logs: FC = ({ appDetail }) => { const { t } = useTranslation() - const { userProfile: { timezone } } = useAppContext() + const { data: timezone } = useQuery({ + ...userProfileQueryOptions(), + select: data => data.profile.timezone ?? undefined, + }) const [queryParams, setQueryParams] = useState({ status: 'all', period: '2' }) const [currPage, setCurrPage] = React.useState(0) const debouncedQueryParams = useDebounce(queryParams, { wait: 500 }) diff --git a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx index 563adbd59e..93fe71e59e 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx @@ -64,6 +64,13 @@ vi.mock('@/utils/model-config', () => ({ formatBooleanInputs: vi.fn((forms, inputs) => inputs), })) +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: (timestamp: number) => `formatted-${timestamp}`, + formatDate: (value: string) => `formatted-${value}`, + }), +})) + type ChatHookReturn = ReturnType const mockAppData = { diff --git a/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx index 3a9ddf4d5a..c5c5b163c8 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/index.spec.tsx @@ -10,6 +10,13 @@ vi.mock('../context', () => ({ })), })) +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: (timestamp: number) => `formatted-${timestamp}`, + formatDate: (value: string) => `formatted-${value}`, + }), +})) + describe('Answer Component', () => { const defaultProps = { item: { diff --git a/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx b/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx index 899908b32a..db8c6bdca4 100644 --- a/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx +++ b/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx @@ -1,8 +1,9 @@ +import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen' import type { Mock } from 'vitest' import type { UsagePlanInfo } from '@/app/components/billing/type' import type { AppContextValue } from '@/context/app-context' import type { ProviderContextState } from '@/context/provider-context' -import type { ICurrentWorkspace, LangGeniusVersionResponse, UserProfileResponse } from '@/models/common' +import type { ICurrentWorkspace, LangGeniusVersionResponse } from '@/models/common' import { render, screen } from '@testing-library/react' import { Plan } from '@/app/components/billing/type' import { mailToSupport } from '@/app/components/header/utils/util' @@ -59,7 +60,7 @@ const buildProviderContext = (overrides: Partial = {}): Pr }) const buildAppContext = (overrides: Partial = {}): AppContextValue => { - const userProfile: UserProfileResponse = { + const userProfile: GetAccountProfileResponse = { id: 'user-id', name: 'Test User', email: 'user@example.com', diff --git a/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx b/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx index 6b94a46e5a..5eab01b3f0 100644 --- a/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx +++ b/web/app/components/datasets/metadata/base/__tests__/date-picker.spec.tsx @@ -1,5 +1,7 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import type { ReactElement } from 'react' +import { fireEvent, render as rtlRender, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' +import { createAccountProfileQueryWrapper } from '@/test/account-profile-query' import WrappedDatePicker from '../date-picker' type TriggerArgs = { @@ -44,6 +46,11 @@ vi.mock('@/hooks/use-timestamp', () => ({ }), })) +const render = (ui: ReactElement) => { + const Wrapper = createAccountProfileQueryWrapper() + return rtlRender(ui, { wrapper: Wrapper }) +} + describe('WrappedDatePicker', () => { describe('Rendering', () => { it('should render without crashing', () => { diff --git a/web/app/components/datasets/metadata/base/date-picker.tsx b/web/app/components/datasets/metadata/base/date-picker.tsx index 66b7e6fd1f..4306bb6330 100644 --- a/web/app/components/datasets/metadata/base/date-picker.tsx +++ b/web/app/components/datasets/metadata/base/date-picker.tsx @@ -4,11 +4,12 @@ import { RiCalendarLine, RiCloseCircleFill, } from '@remixicon/react' +import { useQuery } from '@tanstack/react-query' import dayjs from 'dayjs' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import DatePicker from '@/app/components/base/date-and-time-picker/date-picker' -import { useAppContext } from '@/context/app-context' +import { userProfileQueryOptions } from '@/features/account-profile/client' import useTimestamp from '@/hooks/use-timestamp' type Props = { @@ -24,7 +25,10 @@ const WrappedDatePicker = ({ onChange, }: Props) => { const { t } = useTranslation() - const { userProfile: { timezone } } = useAppContext() + const { data: timezone } = useQuery({ + ...userProfileQueryOptions(), + select: data => data.profile.timezone ?? undefined, + }) const { formatTime: formatTimestamp } = useTimestamp() const handleDateChange = useCallback((date?: dayjs.Dayjs) => { diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx index d51ef2be4d..bf155aa9ac 100644 --- a/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx @@ -51,6 +51,12 @@ vi.mock('@/next/navigation', () => ({ }), })) +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: (timestamp: number) => `formatted-${timestamp}`, + }), +})) + describe('MetadataDocument', () => { const mockDocDetail = { id: 'doc-1', diff --git a/web/app/components/header/account-setting/language-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/language-page/__tests__/index.spec.tsx index 5ab061dedd..edeb14cb1c 100644 --- a/web/app/components/header/account-setting/language-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/language-page/__tests__/index.spec.tsx @@ -1,4 +1,4 @@ -import type { UserProfileResponse } from '@/models/common' +import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen' import { ToastHost } from '@langgenius/dify-ui/toast' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { languages } from '@/i18n-config/language' @@ -9,7 +9,7 @@ import LanguagePage from '../index' const mockRefresh = vi.fn() const mockMutateUserProfile = vi.fn() let mockLocale: string | undefined = 'en-US' -let mockUserProfile: UserProfileResponse +let mockUserProfile: GetAccountProfileResponse vi.mock('@langgenius/dify-ui/select', async () => { const React = await import('react') @@ -89,7 +89,7 @@ vi.mock('@/i18n-config', () => ({ const updateUserProfileMock = vi.mocked(updateUserProfile) -const createUserProfile = (overrides: Partial = {}): UserProfileResponse => ({ +const createUserProfile = (overrides: Partial = {}): GetAccountProfileResponse => ({ id: 'user-id', name: 'Test User', email: 'test@example.com', diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx index 24838fea59..278555c0bf 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/__tests__/index.spec.tsx @@ -1,7 +1,9 @@ +import type { ReactElement } from 'react' import type { PluginDetail } from '@/app/components/plugins/types' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render as rtlRender, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types' +import { createAccountProfileQueryWrapper } from '@/test/account-profile-query' import DetailHeader from '../index' const mockSetTargetVersion = vi.fn() @@ -10,6 +12,11 @@ const mockHandleUpdate = vi.fn() const mockHandleUpdatedFromMarketplace = vi.fn() const mockHandleDelete = vi.fn() +const render = (ui: ReactElement) => { + const Wrapper = createAccountProfileQueryWrapper({ timezone: 'UTC' }) + return rtlRender(ui, { wrapper: Wrapper }) +} + vi.mock('@/context/app-context', () => ({ useAppContext: () => ({ userProfile: { timezone: 'UTC' }, diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx index 8de6e1b911..2eb84b62af 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/index.tsx @@ -4,6 +4,7 @@ import type { PluginDetail } from '../../types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' @@ -12,8 +13,8 @@ import { AuthCategory, PluginAuth } from '@/app/components/plugins/plugin-auth' import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown' import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker' import { API_PREFIX } from '@/config' -import { useAppContext } from '@/context/app-context' import { useGetLanguage, useLocale } from '@/context/i18n' +import { userProfileQueryOptions } from '@/features/account-profile/client' import useTheme from '@/hooks/use-theme' import { useAllToolProviders } from '@/service/use-tools' import { getMarketplaceUrl } from '@/utils/var' @@ -72,7 +73,10 @@ const DetailHeader = ({ onUpdate, }: Props) => { const { t } = useTranslation() - const { userProfile: { timezone } } = useAppContext() + const { data: timezone } = useQuery({ + ...userProfileQueryOptions(), + select: data => data.profile.timezone ?? undefined, + }) const { theme } = useTheme() const locale = useGetLanguage() const currentLocale = useLocale() 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 0e13be6d48..f04cec8992 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 @@ -1,13 +1,14 @@ import type { AutoUpdateConfig } from '../types' import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen } from '@testing-library/react' +import { QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render as rtlRender, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import dayjs from 'dayjs' 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 { createAccountProfileQueryClient } from '@/test/account-profile-query' import { PluginCategoryEnum, PluginSource } from '../../../types' import { defaultValue } from '../config' import AutoUpdateSetting from '../index' @@ -291,21 +292,22 @@ const createMockAutoUpdateConfig = (overrides: Partial = {}): // Helper Functions // ================================ -const createQueryClient = () => new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, -}) +const createQueryClient = () => createAccountProfileQueryClient({ timezone: mockTimezone }) + +const render = (ui: React.ReactElement) => { + const queryClient = createQueryClient() + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ) + return rtlRender(ui, { wrapper: Wrapper }) +} const renderWithQueryClient = (ui: React.ReactElement) => { const queryClient = createQueryClient() - return render( - - {ui} - , + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} ) + return rtlRender(ui, { wrapper: Wrapper }) } // ================================ 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 30e646c0a1..bf22f26083 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 @@ -4,6 +4,7 @@ import type { AutoUpdateConfig } from './types' import type { TriggerParams } from '@/app/components/base/date-and-time-picker/types' import { cn } from '@langgenius/dify-ui/cn' import { RiTimeLine } from '@remixicon/react' +import { useQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useMemo } from 'react' import { Trans, useTranslation } from 'react-i18next' @@ -11,8 +12,8 @@ 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 OptionCard from '@/app/components/workflow/nodes/_base/components/option-card' -import { useAppContext } from '@/context/app-context' import { useModalContextSelector } from '@/context/modal-context' +import { userProfileQueryOptions } from '@/features/account-profile/client' import Label from '../label' import PluginsPicker from './plugins-picker' import StrategyPicker from './strategy-picker' @@ -47,7 +48,10 @@ const AutoUpdateSetting: FC = ({ onChange, }) => { const { t } = useTranslation() - const { userProfile: { timezone } } = useAppContext() + const { data: timezone } = useQuery({ + ...userProfileQueryOptions(), + select: data => data.profile.timezone ?? undefined, + }) const { strategy_setting, diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/integration.spec.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/integration.spec.tsx index 1c7513d13f..aec46ee2c7 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/integration.spec.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/__tests__/integration.spec.tsx @@ -12,6 +12,7 @@ import { DatasetPermission, DataSourceType, } from '@/models/datasets' +import { AccountProfileQueryProvider, createAccountProfileQueryClient } from '@/test/account-profile-query' import { RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app' import { DatasetsDetailContext } from '../../../datasets-detail-store/provider' import { createDatasetsDetailStore } from '../../../datasets-detail-store/store' @@ -583,25 +584,28 @@ describe('knowledge-retrieval path', () => { const onDateChange = vi.fn() const onRemoveCondition = vi.fn() const onUpdateCondition = vi.fn() + const queryClient = createAccountProfileQueryClient({ timezone: 'UTC' }) const { container } = render( -
- - - -
, + +
+ + + +
+
, ) await user.click(screen.getAllByRole('button', { name: /contains/i })[0]!) diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-date.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-date.tsx index 5b27963d98..57c1f40309 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-date.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-date.tsx @@ -4,11 +4,12 @@ import { RiCalendarLine, RiCloseCircleFill, } from '@remixicon/react' +import { useQuery } from '@tanstack/react-query' import dayjs from 'dayjs' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import DatePicker from '@/app/components/base/date-and-time-picker/date-picker' -import { useAppContext } from '@/context/app-context' +import { userProfileQueryOptions } from '@/features/account-profile/client' type ConditionDateProps = { value?: number @@ -19,7 +20,10 @@ const ConditionDate = ({ onChange, }: ConditionDateProps) => { const { t } = useTranslation() - const { userProfile: { timezone } } = useAppContext() + const { data: timezone } = useQuery({ + ...userProfileQueryOptions(), + select: data => data.profile.timezone ?? undefined, + }) const handleDateChange = useCallback((date?: dayjs.Dayjs) => { if (date) diff --git a/web/app/components/workflow/nodes/trigger-schedule/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/trigger-schedule/__tests__/use-config.spec.ts index 40b4c925c6..c334f0e952 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/__tests__/use-config.spec.ts @@ -3,6 +3,7 @@ import { renderHook } from '@testing-library/react' import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' import { useAppContext } from '@/context/app-context' +import { createAccountProfileQueryWrapper } from '@/test/account-profile-query' import { BlockEnum } from '../../../types' import useConfig from '../use-config' @@ -60,7 +61,7 @@ describe('trigger-schedule/use-config', () => { frequency: undefined, timezone: undefined, visual_config: undefined, - }))) + })), { wrapper: createAccountProfileQueryWrapper() }) expect(mockUseNodeCrud).toHaveBeenCalledWith('schedule-node', expect.objectContaining({ mode: 'visual', @@ -78,7 +79,7 @@ describe('trigger-schedule/use-config', () => { it('updates visual mode configuration and clears cron expression when needed', () => { const { result } = renderHook(() => useConfig('schedule-node', createData({ cron_expression: '0 0 * * *', - }))) + })), { wrapper: createAccountProfileQueryWrapper() }) result.current.handleModeChange('cron') result.current.handleFrequencyChange('hourly') @@ -107,7 +108,7 @@ describe('trigger-schedule/use-config', () => { }) it('switches to raw cron mode and clears visual schedule fields', () => { - const { result } = renderHook(() => useConfig('schedule-node', createData())) + const { result } = renderHook(() => useConfig('schedule-node', createData()), { wrapper: createAccountProfileQueryWrapper() }) result.current.handleCronExpressionChange('*/15 * * * *') diff --git a/web/app/components/workflow/nodes/trigger-schedule/use-config.ts b/web/app/components/workflow/nodes/trigger-schedule/use-config.ts index a3e5959f2e..128f87b2ed 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/use-config.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/use-config.ts @@ -1,27 +1,31 @@ import type { ScheduleFrequency, ScheduleMode, ScheduleTriggerNodeType } from './types' +import { useQuery } from '@tanstack/react-query' import { useCallback, useMemo } from 'react' import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' -import { useAppContext } from '@/context/app-context' +import { userProfileQueryOptions } from '@/features/account-profile/client' import { getDefaultVisualConfig } from './constants' const useConfig = (id: string, payload: ScheduleTriggerNodeType) => { const { nodesReadOnly: readOnly } = useNodesReadOnly() - const { userProfile } = useAppContext() + const { data: timezone } = useQuery({ + ...userProfileQueryOptions(), + select: data => data.profile.timezone ?? undefined, + }) const frontendPayload = useMemo(() => { return { ...payload, mode: payload.mode || 'visual', frequency: payload.frequency || 'daily', - timezone: payload.timezone || userProfile.timezone || 'UTC', + timezone: payload.timezone || timezone || 'UTC', visual_config: { ...getDefaultVisualConfig(), ...payload.visual_config, }, } - }, [payload, userProfile.timezone]) + }, [payload, timezone]) const { inputs, setInputs } = useNodeCrud(id, frontendPayload) diff --git a/web/app/education-apply/hooks.ts b/web/app/education-apply/hooks.ts index 0c7802b592..764ab1a090 100644 --- a/web/app/education-apply/hooks.ts +++ b/web/app/education-apply/hooks.ts @@ -1,4 +1,5 @@ import type { SearchParams } from './types' +import { useQuery } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' import dayjs from 'dayjs' import timezone from 'dayjs/plugin/timezone' @@ -9,9 +10,9 @@ import { useState, } from 'react' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' -import { useAppContext } from '@/context/app-context' import { useModalContextSelector } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' +import { userProfileQueryOptions } from '@/features/account-profile/client' import { useLocalStorage } from '@/hooks/use-local-storage' import { useRouter, useSearchParams } from '@/next/navigation' import { useEducationAutocomplete, useEducationVerify } from '@/service/use-education' @@ -81,7 +82,10 @@ const isExpired = (expireAt?: number, timezone?: string) => { const useEducationReverifyNotice = ({ onNotice, }: useEducationReverifyNoticeParams) => { - const { userProfile: { timezone } } = useAppContext() + const { data: timezone } = useQuery({ + ...userProfileQueryOptions(), + select: data => data.profile.timezone ?? undefined, + }) // const [educationInfo, setEducationInfo] = useState<{ is_student: boolean, allow_refresh: boolean, expire_at: number | null } | null>(null) // const isLoading = !educationInfo const { educationAccountExpireAt, allowRefreshEducationVerify, isLoadingEducationAccountInfo: isLoading } = useProviderContext() diff --git a/web/context/app-context-provider.tsx b/web/context/app-context-provider.tsx index 6f2b85e8ed..23bdb12dd6 100644 --- a/web/context/app-context-provider.tsx +++ b/web/context/app-context-provider.tsx @@ -1,8 +1,9 @@ 'use client' +import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen' import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { FC, ReactNode } from 'react' -import type { ICurrentWorkspace, LangGeniusVersionResponse, UserProfileResponse } from '@/models/common' +import type { ICurrentWorkspace, LangGeniusVersionResponse } from '@/models/common' import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useCallback, useEffect, useMemo } from 'react' import { setUserId, setUserProperties } from '@/app/components/base/amplitude' @@ -72,7 +73,7 @@ export const AppContextProvider: FC = ({ children }) => !systemFeatures.branding.enabled, ) - const userProfile = useMemo(() => userProfileResp?.profile || userProfilePlaceholder, [userProfileResp?.profile]) + const userProfile = useMemo(() => userProfileResp?.profile || userProfilePlaceholder, [userProfileResp?.profile]) const currentWorkspace = useMemo(() => normalizeCurrentWorkspace(currentWorkspaceResp), [currentWorkspaceResp]) const langGeniusVersionInfo = useMemo(() => { if (!userProfileResp?.meta?.currentVersion || !langGeniusVersionQuery.data) diff --git a/web/context/app-context.ts b/web/context/app-context.ts index 298e213e7d..50df95e71e 100644 --- a/web/context/app-context.ts +++ b/web/context/app-context.ts @@ -1,11 +1,12 @@ 'use client' -import type { ICurrentWorkspace, LangGeniusVersionResponse, UserProfileResponse } from '@/models/common' +import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen' +import type { ICurrentWorkspace, LangGeniusVersionResponse } from '@/models/common' import { noop } from 'es-toolkit/function' import { createContext, useContext, useContextSelector } from 'use-context-selector' export type AppContextValue = { - userProfile: UserProfileResponse + userProfile: GetAccountProfileResponse mutateUserProfile: VoidFunction currentWorkspace: ICurrentWorkspace isCurrentWorkspaceManager: boolean diff --git a/web/contract/console/account.ts b/web/contract/console/account.ts deleted file mode 100644 index 5e8e27e015..0000000000 --- a/web/contract/console/account.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { type } from '@orpc/contract' -import { base } from '../base' - -export type AccountProfileResponse = { - id: string - name: string - email: string - avatar: string - avatar_url: string | null - is_password_set: boolean - interface_language?: string - interface_theme?: string - timezone?: string - last_login_at?: string - last_active_at?: string - last_login_ip?: string - created_at?: string -} - -export const accountProfileContract = base - .route({ - path: '/account/profile', - method: 'GET', - }) - .output(type()) - -export const accountAvatarContract = base - .route({ - path: '/account/avatar', - method: 'GET', - }) - .input(type<{ - query: { - avatar: string - } - }>()) - .output(type<{ avatar_url: string }>()) diff --git a/web/contract/router.ts b/web/contract/router.ts index 58395b32c9..d03b2f2271 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -1,7 +1,6 @@ import type { InferContractRouterInputs } from '@orpc/contract' import { contract as communityContract } from '@dify/contracts/api/console/orpc.gen' import { contract as enterpriseContract } from '@dify/contracts/enterprise/orpc.gen' -import { accountAvatarContract, accountProfileContract } from './console/account' import { appDeleteContract, appListContract, workflowOnlineUsersContract } from './console/apps' import { bindPartnerStackContract, invoicesContract } from './console/billing' import { @@ -71,14 +70,6 @@ export type MarketPlaceInputs = InferContractRouterInputs ({ cookies: () => cookiesMock(), })) -const createProfile = (overrides: Partial = {}): AccountProfileResponse => ({ +const createProfile = (overrides: Partial = {}): GetAccountProfileResponse => ({ id: 'account-id', name: 'Dify User', email: 'user@example.com', diff --git a/web/features/account-profile/client.ts b/web/features/account-profile/client.ts index 128cd5b20e..fd56d8827b 100644 --- a/web/features/account-profile/client.ts +++ b/web/features/account-profile/client.ts @@ -1,4 +1,4 @@ -import type { AccountProfileResponse } from '@/contract/console/account' +import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen' import { queryOptions } from '@tanstack/react-query' import { IS_DEV } from '@/config' // eslint-disable-next-line no-restricted-imports @@ -6,7 +6,7 @@ import { get } from '@/service/base' import { consoleQuery } from '@/service/client' export type UserProfileWithMeta = { - profile: AccountProfileResponse + profile: GetAccountProfileResponse meta: { currentVersion: string | null currentEnv: string | null @@ -24,7 +24,7 @@ export const userProfileQueryOptions = () => needAllResponseContent: true, silent: true, }) - const profile: AccountProfileResponse = await response.clone().json() + const profile: GetAccountProfileResponse = await response.clone().json() return { profile, meta: { diff --git a/web/features/account-profile/server.ts b/web/features/account-profile/server.ts index 77adbcf2e5..90aba7cc5a 100644 --- a/web/features/account-profile/server.ts +++ b/web/features/account-profile/server.ts @@ -1,5 +1,5 @@ +import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen' import type { UserProfileWithMeta } from './client' -import type { AccountProfileResponse } from '@/contract/console/account' import { queryOptions } from '@tanstack/react-query' import { getServerConsoleRequestHeaders, resolveServerConsoleApiUrl, serverConsoleQuery } from '@/service/server' @@ -22,7 +22,7 @@ export const serverUserProfileQueryOptions = () => if (!response.ok) throw response - const profile: AccountProfileResponse = await response.clone().json() + const profile: GetAccountProfileResponse = await response.clone().json() return { profile, meta: { diff --git a/web/hooks/use-timestamp.spec.ts b/web/hooks/use-timestamp.spec.ts index e78211bbb1..22fb5f5016 100644 --- a/web/hooks/use-timestamp.spec.ts +++ b/web/hooks/use-timestamp.spec.ts @@ -1,4 +1,5 @@ import { renderHook } from '@testing-library/react' +import { createAccountProfileQueryWrapper } from '@/test/account-profile-query' import useTimestamp from './use-timestamp' vi.mock('@/context/app-context', () => ({ @@ -23,7 +24,7 @@ vi.mock('@/context/app-context', () => ({ describe('useTimestamp', () => { describe('formatTime', () => { it('should format unix timestamp correctly', () => { - const { result } = renderHook(() => useTimestamp()) + const { result } = renderHook(() => useTimestamp(), { wrapper: createAccountProfileQueryWrapper() }) const timestamp = 1704132000 expect(result.current.formatTime(timestamp, 'YYYY-MM-DD HH:mm:ss')) @@ -31,7 +32,7 @@ describe('useTimestamp', () => { }) it('should format with different patterns', () => { - const { result } = renderHook(() => useTimestamp()) + const { result } = renderHook(() => useTimestamp(), { wrapper: createAccountProfileQueryWrapper() }) const timestamp = 1704132000 expect(result.current.formatTime(timestamp, 'MM/DD/YYYY')) @@ -44,7 +45,7 @@ describe('useTimestamp', () => { describe('formatDate', () => { it('should format date string correctly', () => { - const { result } = renderHook(() => useTimestamp()) + const { result } = renderHook(() => useTimestamp(), { wrapper: createAccountProfileQueryWrapper() }) const dateString = '2024-01-01T12:00:00Z' expect(result.current.formatDate(dateString, 'YYYY-MM-DD HH:mm:ss')) @@ -52,7 +53,7 @@ describe('useTimestamp', () => { }) it('should format with different patterns', () => { - const { result } = renderHook(() => useTimestamp()) + const { result } = renderHook(() => useTimestamp(), { wrapper: createAccountProfileQueryWrapper() }) const dateString = '2024-01-01T12:00:00Z' expect(result.current.formatDate(dateString, 'MM/DD/YYYY')) diff --git a/web/hooks/use-timestamp.ts b/web/hooks/use-timestamp.ts index 05afa8e178..a872b4c852 100644 --- a/web/hooks/use-timestamp.ts +++ b/web/hooks/use-timestamp.ts @@ -1,15 +1,19 @@ 'use client' +import { useQuery } from '@tanstack/react-query' import dayjs from 'dayjs' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' import { useCallback } from 'react' -import { useAppContext } from '@/context/app-context' +import { userProfileQueryOptions } from '@/features/account-profile/client' dayjs.extend(utc) dayjs.extend(timezone) const useTimestamp = () => { - const { userProfile: { timezone } } = useAppContext() + const { data: timezone } = useQuery({ + ...userProfileQueryOptions(), + select: data => data.profile.timezone ?? undefined, + }) const formatTime = useCallback((value: number, format: string) => { return dayjs.unix(value).tz(timezone).format(format) diff --git a/web/models/common.ts b/web/models/common.ts index 2b3acd1d2f..3fe550a701 100644 --- a/web/models/common.ts +++ b/web/models/common.ts @@ -1,3 +1,4 @@ +import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen' import type { I18nText } from '@/i18n-config/language' import type { Model } from '@/types/app' @@ -18,24 +19,8 @@ export type InitValidateStatusResponse = { status: 'finished' | 'not_started' } -export type UserProfileResponse = { - id: string - name: string - email: string - avatar: string - avatar_url: string | null - is_password_set: boolean - interface_language?: string - interface_theme?: string - timezone?: string - last_login_at?: string - last_active_at?: string - last_login_ip?: string - created_at?: string -} - export type UserProfileOriginResponse = { - json: () => Promise + json: () => Promise bodyUsed: boolean headers: any } @@ -50,8 +35,11 @@ export type LangGeniusVersionResponse = { current_env: string } -export type Member = Pick & { +export type Member = Pick & { avatar: string + last_login_at?: string + last_active_at?: string + created_at?: string status: 'pending' | 'active' | 'banned' | 'closed' role: 'owner' | 'admin' | 'editor' | 'normal' | 'dataset_operator' } diff --git a/web/service/common.ts b/web/service/common.ts index 3f0ae66a9b..7c068eabe1 100644 --- a/web/service/common.ts +++ b/web/service/common.ts @@ -371,5 +371,5 @@ export const checkEmailExisted = (body: { email: string }): Promise => { const { consoleClient } = await import('./client') - return consoleClient.account.avatar({ query: { avatar } }) + return consoleClient.account.avatar.get({ query: { avatar } }) } diff --git a/web/test/account-profile-query.ts b/web/test/account-profile-query.ts new file mode 100644 index 0000000000..9bb5ca7861 --- /dev/null +++ b/web/test/account-profile-query.ts @@ -0,0 +1,70 @@ +import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen' +import type { QueryClient } from '@tanstack/react-query' +import type { ReactNode } from 'react' +import type { UserProfileWithMeta } from '@/features/account-profile/client' +import { QueryClientProvider, QueryClient as TanStackQueryClient } from '@tanstack/react-query' +import { createElement } from 'react' +import { userProfileQueryOptions } from '@/features/account-profile/client' + +const createMockAccountProfile = ( + overrides: Partial = {}, +): GetAccountProfileResponse => ({ + id: 'user-1', + name: 'Test User', + email: 'test@dify.ai', + avatar: '', + avatar_url: null, + is_password_set: false, + timezone: 'Asia/Shanghai', + ...overrides, +}) + +const createMockUserProfileResponse = ( + profile: Partial = {}, +): UserProfileWithMeta => ({ + profile: createMockAccountProfile(profile), + meta: { + currentVersion: null, + currentEnv: null, + }, +}) + +export const createAccountProfileQueryClient = ( + profile: Partial = {}, +) => { + const queryClient = new TanStackQueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Number.POSITIVE_INFINITY, + }, + }, + }) + + queryClient.setQueryData( + userProfileQueryOptions().queryKey, + createMockUserProfileResponse(profile), + ) + + return queryClient +} + +export const createAccountProfileQueryWrapper = ( + profile: Partial = {}, +) => { + const queryClient = createAccountProfileQueryClient(profile) + + return function AccountProfileQueryWrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children) + } +} + +export function AccountProfileQueryProvider({ + children, + queryClient, +}: { + children: ReactNode + queryClient: QueryClient +}) { + return createElement(QueryClientProvider, { client: queryClient }, children) +}