From 28d174603f8fab29ed389e24249dca756d83f6b5 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:01:02 +0800 Subject: [PATCH] fix: align workspace card plan ownership (#39616) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/services/workspace_service.py | 2 +- .../services/test_workspace_service.py | 18 ++- .../main-nav/__tests__/index.spec.tsx | 29 ++--- .../__tests__/workspace-card.spec.tsx | 113 +++++++++++++----- .../main-nav/components/workspace-card.tsx | 74 ++++++------ .../components/workspace-switcher.tsx | 92 ++++++++------ .../__tests__/console-bootstrap.spec.tsx | 1 + 7 files changed, 204 insertions(+), 125 deletions(-) diff --git a/api/services/workspace_service.py b/api/services/workspace_service.py index 5f9003bb755..0635f519438 100644 --- a/api/services/workspace_service.py +++ b/api/services/workspace_service.py @@ -27,7 +27,6 @@ class WorkspaceService: tenant_info: dict[str, object] = { "id": tenant.id, "name": tenant.name, - "plan": tenant.plan, "status": tenant.status, "created_at": tenant.created_at, "trial_end_reason": None, @@ -44,6 +43,7 @@ class WorkspaceService: tenant_info["role"] = tenant_account_join.role feature = FeatureService.get_features(tenant.id, exclude_vector_space=True) + tenant_info["plan"] = feature.billing.subscription.plan if feature.billing.enabled else None can_replace_logo = feature.can_replace_logo if can_replace_logo and TenantService.has_roles( diff --git a/api/tests/test_containers_integration_tests/services/test_workspace_service.py b/api/tests/test_containers_integration_tests/services/test_workspace_service.py index f22775a13eb..1294156273d 100644 --- a/api/tests/test_containers_integration_tests/services/test_workspace_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workspace_service.py @@ -24,7 +24,10 @@ class TestWorkspaceService: patch("services.workspace_service.dify_config") as mock_dify_config, ): # Setup default mock returns - mock_feature_service.get_features.return_value.can_replace_logo = True + feature = mock_feature_service.get_features.return_value + feature.can_replace_logo = True + feature.billing.enabled = True + feature.billing.subscription.plan = "professional" mock_tenant_service.has_roles.return_value = True mock_dify_config.FILES_URL = "https://example.com/files" @@ -112,7 +115,7 @@ class TestWorkspaceService: assert result is not None assert result["id"] == tenant.id assert result["name"] == tenant.name - assert result["plan"] == tenant.plan + assert result["plan"] == "professional" assert result["status"] == tenant.status assert result["role"] == TenantAccountRole.OWNER assert result["created_at"] == tenant.created_at @@ -159,7 +162,7 @@ class TestWorkspaceService: assert result is not None assert result["id"] == tenant.id assert result["name"] == tenant.name - assert result["plan"] == tenant.plan + assert result["plan"] == "professional" assert result["status"] == tenant.status assert result["role"] == TenantAccountRole.OWNER assert result["created_at"] == tenant.created_at @@ -214,7 +217,7 @@ class TestWorkspaceService: assert result is not None assert result["id"] == tenant.id assert result["name"] == tenant.name - assert result["plan"] == tenant.plan + assert result["plan"] == "professional" assert result["status"] == tenant.status assert result["role"] == TenantAccountRole.NORMAL assert result["created_at"] == tenant.created_at @@ -606,20 +609,23 @@ class TestWorkspaceService: def test_get_tenant_info_should_not_include_cloud_fields_in_self_hosted( self, db_session_with_containers: Session, mock_external_service_dependencies ): - """next_credit_reset_date and trial_credits should NOT appear in SELF_HOSTED mode.""" + """Cloud-only billing data should not appear in SELF_HOSTED mode.""" fake = Faker() account, tenant = self._create_test_account_and_tenant( db_session_with_containers, mock_external_service_dependencies ) mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY - mock_external_service_dependencies["feature_service"].get_features.return_value.can_replace_logo = False + feature = mock_external_service_dependencies["feature_service"].get_features.return_value + feature.can_replace_logo = False + feature.billing.enabled = False mock_external_service_dependencies["tenant_service"].has_roles.return_value = False with patch("services.workspace_service.current_user", account): result = WorkspaceService.get_tenant_info(tenant, db_session_with_containers) assert result is not None + assert result["plan"] is None assert "next_credit_reset_date" not in result assert "trial_credits" not in result assert "trial_credits_used" not in result diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 0dfe17e4790..4a30a3edbd0 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -401,7 +401,7 @@ const consoleState: ConsoleStateFixture = { currentWorkspace: { id: 'workspace-1', name: 'Solar Studio', - plan: Plan.sandbox, + plan: Plan.team, status: 'normal', created_at: 0, role: 'owner', @@ -1125,16 +1125,13 @@ describe('MainNav', () => { }) it('shows the upgrade shortcut for sandbox workspaces', () => { - mockWorkspaces = [ - { - id: 'workspace-1', - name: 'Solar Studio', + mockConsoleState.current = { + ...consoleState, + currentWorkspace: { + ...consoleState.currentWorkspace, plan: Plan.sandbox, - status: 'normal', - created_at: 0, - current: true, }, - ] + } renderMainNav() @@ -1143,13 +1140,13 @@ describe('MainNav', () => { }) it('shows the view plan shortcut for paid workspaces', () => { - ;(useProviderContext as Mock).mockReturnValue({ - enableBilling: true, - isEducationAccount: false, - isEducationWorkspace: false, - isFetchedPlan: true, - plan: { type: Plan.team }, - } as ProviderContextState) + mockConsoleState.current = { + ...consoleState, + currentWorkspace: { + ...consoleState.currentWorkspace, + plan: Plan.professional, + }, + } renderMainNav() diff --git a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx index 2beb8ae09f0..dfc1b35d04b 100644 --- a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx +++ b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx @@ -2,6 +2,7 @@ import type { ModalContextState } from '@/context/modal-context' import type { ProviderContextState } from '@/context/provider-context' import type { ICurrentWorkspace, IWorkspace } from '@/models/common' import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { Plan } from '@/app/components/billing/type' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { useModalContext } from '@/context/modal-context' @@ -15,13 +16,17 @@ import { } from '@/test/console/query-data' import { WorkspaceCard } from '../workspace-card' -const { mockSwitchWorkspace, mockCurrentWorkspaceQueryKey, mockWorkspacesQueryKey } = vi.hoisted( - () => ({ - mockSwitchWorkspace: vi.fn(), - mockCurrentWorkspaceQueryKey: ['console', 'workspaces', 'current', 'post'] as const, - mockWorkspacesQueryKey: ['console', 'workspaces', 'get'] as const, - }), -) +const { + mockFetchWorkspaces, + mockSwitchWorkspace, + mockCurrentWorkspaceQueryKey, + mockWorkspacesQueryKey, +} = vi.hoisted(() => ({ + mockFetchWorkspaces: vi.fn(), + mockSwitchWorkspace: vi.fn(), + mockCurrentWorkspaceQueryKey: ['console', 'workspaces', 'current', 'post'] as const, + mockWorkspacesQueryKey: ['console', 'workspaces', 'get'] as const, +})) const mockConsoleState = vi.hoisted(() => ({ current: { workspacePermissionKeys: [] as string[], @@ -64,9 +69,10 @@ vi.mock('@/service/client', async (importOriginal) => { }, get: { queryKey: () => mockWorkspacesQueryKey, - queryOptions: () => ({ + queryOptions: (options?: object) => ({ queryKey: mockWorkspacesQueryKey, - queryFn: () => new Promise(() => {}), + queryFn: mockFetchWorkspaces, + ...options, }), }, switch: { @@ -163,6 +169,7 @@ describe('WorkspaceCard', () => { current: false, }, ] + mockFetchWorkspaces.mockResolvedValue({ workspaces: mockWorkspaces }) mockSwitchWorkspace.mockReturnValue(new Promise(() => {})) mockCurrentWorkspaceQuery() vi.mocked(useProviderContext).mockReturnValue({ @@ -210,16 +217,68 @@ describe('WorkspaceCard', () => { expect(screen.queryByText('Evan Workspace')).not.toBeInTheDocument() }) - it('renders a skeleton while the workspaces query has no data', () => { + it('renders the current workspace before loading the workspace list', async () => { + const user = userEvent.setup() renderWorkspaceCard({ seedWorkspaces: false }) expect( - screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' }), - ).not.toBeInTheDocument() - expect(screen.queryByText('Solar Studio')).not.toBeInTheDocument() + screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }), + ).toBeInTheDocument() + expect(screen.getByText('Solar Studio')).toBeInTheDocument() + expect(mockFetchWorkspaces).not.toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) + + expect(await screen.findByRole('dialog', { name: 'Solar Studio' })).toBeInTheDocument() + await waitFor(() => expect(mockFetchWorkspaces).toHaveBeenCalledOnce()) + expect(await screen.findByRole('button', { name: 'Evan Workspace' })).toBeInTheDocument() }) - it('uses the workspaces query current item for billing plan UI', () => { + it('prefetches the workspace list when the trigger is hovered', async () => { + const user = userEvent.setup() + renderWorkspaceCard({ seedWorkspaces: false }) + + const trigger = screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }) + await user.hover(trigger) + + await waitFor(() => expect(mockFetchWorkspaces).toHaveBeenCalledOnce()) + expect(screen.queryByRole('dialog', { name: 'Solar Studio' })).not.toBeInTheDocument() + + await user.click(trigger) + + expect(await screen.findByRole('button', { name: 'Evan Workspace' })).toBeInTheDocument() + expect(mockFetchWorkspaces).toHaveBeenCalledOnce() + }) + + it('prefetches the workspace list when the trigger receives keyboard focus', async () => { + const user = userEvent.setup() + renderWorkspaceCard({ seedWorkspaces: false }) + + await user.tab() + + expect(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })).toHaveFocus() + await waitFor(() => expect(mockFetchWorkspaces).toHaveBeenCalledOnce()) + expect(screen.queryByRole('dialog', { name: 'Solar Studio' })).not.toBeInTheDocument() + }) + + it('keeps workspace controls visible and disabled while the workspace list is loading', async () => { + const user = userEvent.setup() + mockFetchWorkspaces.mockReturnValue(new Promise(() => {})) + renderWorkspaceCard({ seedWorkspaces: false }) + + await user.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })) + + const panel = await screen.findByRole('dialog', { name: 'Solar Studio' }) + expect(within(panel).getByText('common.userProfile.workspace')).toBeInTheDocument() + expect( + within(panel).getByRole('button', { name: 'common.mainNav.workspace.sort.openMenu' }), + ).toBeDisabled() + expect(within(panel).getByRole('button', { name: 'common.operation.search' })).toBeDisabled() + expect(panel.querySelector('[aria-busy="true"]')).toBeInTheDocument() + expect(within(panel).queryByRole('button', { name: 'Evan Workspace' })).not.toBeInTheDocument() + }) + + it('uses the current workspace query for billing plan UI', () => { mockCurrentWorkspaceQuery({ ...currentWorkspaceValue, plan: Plan.team, @@ -234,23 +293,17 @@ describe('WorkspaceCard', () => { renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } }) - expect(screen.getByText(Plan.sandbox)).toBeInTheDocument() - expect(screen.getByText('billing.upgradeBtn.encourageShort')).toBeInTheDocument() - expect(screen.queryByText(Plan.team)).not.toBeInTheDocument() - expect(screen.queryByText('billing.upgradeBtn.plain')).not.toBeInTheDocument() + expect(screen.getByText(Plan.team)).toBeInTheDocument() + expect(screen.getByText('billing.upgradeBtn.plain')).toBeInTheDocument() + expect(screen.queryByText(Plan.sandbox)).not.toBeInTheDocument() + expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument() }) it('uses the original paid plan badge for paid workspaces', () => { - mockWorkspaces = [ - { - id: 'workspace-1', - name: 'Solar Studio', - plan: Plan.team, - status: 'normal', - created_at: 0, - current: true, - }, - ] + mockCurrentWorkspaceQuery({ + ...currentWorkspaceValue, + plan: Plan.team, + }) vi.mocked(useProviderContext).mockReturnValue({ enableBilling: true, isEducationAccount: false, @@ -265,6 +318,10 @@ describe('WorkspaceCard', () => { }) it('shows the Enterprise license status independently of the Cloud billing state', () => { + mockCurrentWorkspaceQuery({ + ...currentWorkspaceValue, + plan: '', + }) vi.mocked(useProviderContext).mockReturnValue({ enableBilling: true, isEducationAccount: false, diff --git a/web/app/components/main-nav/components/workspace-card.tsx b/web/app/components/main-nav/components/workspace-card.tsx index fe510a89de5..4c446efa3e5 100644 --- a/web/app/components/main-nav/components/workspace-card.tsx +++ b/web/app/components/main-nav/components/workspace-card.tsx @@ -1,10 +1,11 @@ 'use client' +import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' -import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -89,6 +90,7 @@ function WorkspaceCardTrigger({ showPlanAction, planActionLabel, creditsHref, + onPrefetchWorkspaces, onPlanClick, }: { open: boolean @@ -99,6 +101,7 @@ function WorkspaceCardTrigger({ showPlanAction: boolean planActionLabel: string creditsHref: string + onPrefetchWorkspaces: () => void onPlanClick: () => void }) { const { t } = useTranslation() @@ -111,6 +114,8 @@ function WorkspaceCardTrigger({ $['mainNav.workspace.openMenu'], { ns: 'common' })} title={name} + onMouseEnter={onPrefetchWorkspaces} + onFocus={onPrefetchWorkspaces} className={cn( 'flex w-full items-center gap-1.5 py-1.5 pr-3 pl-1.5 text-left transition-colors hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden', showCloudBilling ? 'rounded-t-xl' : 'rounded-xl', @@ -221,21 +226,21 @@ function WorkspaceMenuHeader({ ) } -const selectCurrentWorkspaceCardData = (workspace: { - id: string - name?: string | null - role?: string | null - trial_credits?: number | null - trial_credits_used?: number | null -}) => ({ +type CurrentWorkspaceCardSource = Pick< + PostWorkspacesCurrentResponse, + 'id' | 'name' | 'plan' | 'trial_credits' | 'trial_credits_used' +> + +const selectCurrentWorkspaceCardData = (workspace: CurrentWorkspaceCardSource) => ({ id: workspace.id, name: workspace.name, - role: workspace.role, + plan: workspace.plan, credits: getRemainingCredits(workspace.trial_credits ?? 0, workspace.trial_credits_used ?? 0), }) export function WorkspaceCard() { const { t } = useTranslation() + const queryClient = useQueryClient() const { data: deploymentEdition } = useSuspenseQuery({ ...systemFeaturesQueryOptions(), select: ({ deployment_edition }) => deployment_edition, @@ -245,27 +250,24 @@ export function WorkspaceCard() { select: selectCurrentWorkspaceCardData, }), ) - const workspacesQuery = useQuery(consoleQuery.workspaces.get.queryOptions()) + const [open, setOpen] = useState(false) + const workspacesQueryOptions = consoleQuery.workspaces.get.queryOptions() + const workspacesQuery = useQuery({ + ...workspacesQueryOptions, + enabled: open, + }) const switchWorkspaceMutation = useMutation(consoleQuery.workspaces.switch.post.mutationOptions()) const currentWorkspace = currentWorkspaceQuery.data - const workspacesData = workspacesQuery.data - const workspaces = workspacesData?.workspaces - const currentWorkspaceInList = workspaces?.find((workspace) => workspace.current) + const workspaces = workspacesQuery.data?.workspaces const { enableBilling } = useProviderContext() const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { setShowPricingModal, setShowAccountSettingModal } = useModalContext() const showCloudBilling = deploymentEdition === 'CLOUD' && enableBilling - const [open, setOpen] = useState(false) + const prefetchWorkspaces = () => { + void queryClient.prefetchQuery(workspacesQueryOptions) + } - if ( - currentWorkspaceQuery.isPending || - workspacesQuery.isPending || - !currentWorkspace?.name || - !currentWorkspace.role || - !workspaces || - !currentWorkspaceInList || - !isWorkspacePlan(currentWorkspaceInList.plan) - ) { + if (currentWorkspaceQuery.isPending || !currentWorkspace?.name) { return ( $[isFreePlan ? 'upgradeBtn.encourageShort' : 'upgradeBtn.plain'], { ns: 'billing' }, @@ -284,7 +286,7 @@ export function WorkspaceCard() { const showInviteMembers = hasPermission(workspacePermissionKeys, 'workspace.member.manage') const renderWorkspaceStatus = () => { if (deploymentEdition === 'CLOUD') - return enableBilling ? : null + return enableBilling && workspacePlan ? : null if (deploymentEdition === 'ENTERPRISE') return return null } @@ -313,6 +315,7 @@ export function WorkspaceCard() { showPlanAction={showPlanAction} planActionLabel={planActionLabel} creditsHref={buildIntegrationPath('provider')} + onPrefetchWorkspaces={prefetchWorkspaces} onPlanClick={setShowPricingModal} /> - {workspaces.length > 0 && ( -
- { - setOpen(false) - void handleSwitchWorkspace(workspaceId) - }} - /> -
- )} + { + setOpen(false) + void handleSwitchWorkspace(workspaceId) + }} + />
diff --git a/web/app/components/main-nav/components/workspace-switcher.tsx b/web/app/components/main-nav/components/workspace-switcher.tsx index 86cb90c9706..debaff3d384 100644 --- a/web/app/components/main-nav/components/workspace-switcher.tsx +++ b/web/app/components/main-nav/components/workspace-switcher.tsx @@ -17,7 +17,7 @@ import { WorkspaceAvatar } from '@/app/components/base/workspace-avatar' import { WorkspaceMenuItemContent } from './workspace-menu-content' const workspaceSwitchActionButtonClassName = - 'flex shrink-0 items-center justify-center rounded-md p-0.5 text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid' + 'flex shrink-0 items-center justify-center rounded-md p-0.5 text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled' const workspaceSwitchActionIconWrapClassName = 'flex size-5 shrink-0 items-center justify-center' const workspaceSwitchActionIconClassName = 'size-3.5 shrink-0' const workspaceSwitchListClassName = 'max-h-[240px] overflow-y-auto overscroll-contain scroll-py-1' @@ -30,11 +30,13 @@ const getWorkspaceLastOpenedAt = (workspace: TenantListItemResponse) => workspace.last_opened_at ?? 0 function WorkspaceSwitchControls({ + disabled, searchText, sort, onSearchTextChange, onSortChange, }: { + disabled: boolean searchText: string sort: WorkspaceSort onSearchTextChange: (value: string) => void @@ -72,6 +74,7 @@ function WorkspaceSwitchControls({ $['operation.search'], { ns: 'common' })} + disabled={disabled} className={cn( workspaceSwitchActionButtonClassName, searchVisible && 'bg-state-base-hover text-text-secondary', @@ -142,20 +146,25 @@ function WorkspaceSwitchControls({ } type WorkspaceSwitcherProps = { - workspaces: TenantListItemResponse[] + workspaces?: TenantListItemResponse[] + isPending: boolean onSwitchWorkspace: (workspaceId: string) => void } -export function WorkspaceSwitcher({ workspaces, onSwitchWorkspace }: WorkspaceSwitcherProps) { +export function WorkspaceSwitcher({ + workspaces, + isPending, + onSwitchWorkspace, +}: WorkspaceSwitcherProps) { const [workspaceSearchText, setWorkspaceSearchText] = useState('') const [workspaceSort, setWorkspaceSort] = useState('lastOpened') const displayedWorkspaces = useMemo(() => { const normalizedSearchText = workspaceSearchText.trim().toLowerCase() const filteredWorkspaces = normalizedSearchText - ? workspaces.filter((workspace) => + ? (workspaces?.filter((workspace) => getWorkspaceName(workspace).toLowerCase().includes(normalizedSearchText), - ) - : [...workspaces] + ) ?? []) + : [...(workspaces ?? [])] if (workspaceSort === 'createdAt') return filteredWorkspaces.sort((a, b) => getWorkspaceCreatedAt(b) - getWorkspaceCreatedAt(a)) @@ -168,45 +177,54 @@ export function WorkspaceSwitcher({ workspaces, onSwitchWorkspace }: WorkspaceSw }) }, [workspaceSearchText, workspaceSort, workspaces]) + if (!isPending && !workspaces) return null + return ( - <> +
-
- {displayedWorkspaces.map((workspace) => { - const workspaceName = getWorkspaceName(workspace) +
+ {isPending ? ( +
+ +
+ ) : ( + displayedWorkspaces.map((workspace) => { + const workspaceName = getWorkspaceName(workspace) - return ( - - ) - })} + return ( + + ) + }) + )}
- +
) } diff --git a/web/context/__tests__/console-bootstrap.spec.tsx b/web/context/__tests__/console-bootstrap.spec.tsx index 53936b20974..61987812eb7 100644 --- a/web/context/__tests__/console-bootstrap.spec.tsx +++ b/web/context/__tests__/console-bootstrap.spec.tsx @@ -556,6 +556,7 @@ describe('Console bootstrap', () => { expect.objectContaining({ email: 'user@example.com', workspace_id: 'workspace-1', + workspace_plan: 'sandbox', workspace_role: 'editor', }), )