mirror of
https://github.com/langgenius/dify.git
synced 2026-07-27 15:08:35 +08:00
fix: align workspace card plan ownership (#39616)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
b6099d09ff
commit
28d174603f
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
<PopoverTrigger
|
||||
aria-label={t(($) => $['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 (
|
||||
<WorkspaceCardSkeleton
|
||||
showCloudBilling={showCloudBilling}
|
||||
@ -274,9 +276,9 @@ export function WorkspaceCard() {
|
||||
)
|
||||
}
|
||||
|
||||
const workspacePlan = currentWorkspaceInList.plan
|
||||
const workspacePlan = isWorkspacePlan(currentWorkspace.plan) ? currentWorkspace.plan : null
|
||||
const isFreePlan = workspacePlan === Plan.sandbox
|
||||
const showPlanAction = showCloudBilling
|
||||
const showPlanAction = showCloudBilling && workspacePlan !== null
|
||||
const planActionLabel = t(
|
||||
($) => $[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 ? <WorkspacePlanBadge plan={workspacePlan} /> : null
|
||||
return enableBilling && workspacePlan ? <WorkspacePlanBadge plan={workspacePlan} /> : null
|
||||
if (deploymentEdition === 'ENTERPRISE') return <LicenseNav />
|
||||
return null
|
||||
}
|
||||
@ -313,6 +315,7 @@ export function WorkspaceCard() {
|
||||
showPlanAction={showPlanAction}
|
||||
planActionLabel={planActionLabel}
|
||||
creditsHref={buildIntegrationPath('provider')}
|
||||
onPrefetchWorkspaces={prefetchWorkspaces}
|
||||
onPlanClick={setShowPricingModal}
|
||||
/>
|
||||
<PopoverContent
|
||||
@ -338,17 +341,14 @@ export function WorkspaceCard() {
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })
|
||||
}}
|
||||
/>
|
||||
{workspaces.length > 0 && (
|
||||
<div className="p-1 pb-2">
|
||||
<WorkspaceSwitcher
|
||||
workspaces={workspaces}
|
||||
onSwitchWorkspace={(workspaceId) => {
|
||||
setOpen(false)
|
||||
void handleSwitchWorkspace(workspaceId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<WorkspaceSwitcher
|
||||
workspaces={workspaces}
|
||||
isPending={workspacesQuery.isPending}
|
||||
onSwitchWorkspace={(workspaceId) => {
|
||||
setOpen(false)
|
||||
void handleSwitchWorkspace(workspaceId)
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</>
|
||||
</Popover>
|
||||
|
||||
@ -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({
|
||||
<DropdownMenu open={sortMenuOpen} onOpenChange={setSortMenuOpen}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={sortMenuLabel}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
workspaceSwitchActionButtonClassName,
|
||||
sortMenuOpen && 'bg-state-base-hover text-text-secondary',
|
||||
@ -113,6 +116,7 @@ function WorkspaceSwitchControls({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['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<WorkspaceSort>('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 (
|
||||
<>
|
||||
<div className="p-1 pb-2">
|
||||
<WorkspaceSwitchControls
|
||||
disabled={isPending}
|
||||
searchText={workspaceSearchText}
|
||||
sort={workspaceSort}
|
||||
onSearchTextChange={setWorkspaceSearchText}
|
||||
onSortChange={setWorkspaceSort}
|
||||
/>
|
||||
<div className={workspaceSwitchListClassName}>
|
||||
{displayedWorkspaces.map((workspace) => {
|
||||
const workspaceName = getWorkspaceName(workspace)
|
||||
<div aria-busy={isPending} className={workspaceSwitchListClassName}>
|
||||
{isPending ? (
|
||||
<div aria-hidden className="flex h-8 items-center justify-center">
|
||||
<span className="i-ri-loader-2-line size-4 animate-spin text-text-tertiary motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : (
|
||||
displayedWorkspaces.map((workspace) => {
|
||||
const workspaceName = getWorkspaceName(workspace)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={workspace.id}
|
||||
aria-current={workspace.current ? 'true' : undefined}
|
||||
title={workspaceName}
|
||||
className={cn(
|
||||
'flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid',
|
||||
workspace.current && 'bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
onSwitchWorkspace(workspace.id)
|
||||
}}
|
||||
>
|
||||
<WorkspaceMenuItemContent
|
||||
icon={<WorkspaceAvatar name={workspaceName} size="xs" />}
|
||||
label={workspaceName}
|
||||
trailing={
|
||||
workspace.current ? (
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4 text-text-accent" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={workspace.id}
|
||||
aria-current={workspace.current ? 'true' : undefined}
|
||||
title={workspaceName}
|
||||
className={cn(
|
||||
'flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid',
|
||||
workspace.current && 'bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
onSwitchWorkspace(workspace.id)
|
||||
}}
|
||||
>
|
||||
<WorkspaceMenuItemContent
|
||||
icon={<WorkspaceAvatar name={workspaceName} size="xs" />}
|
||||
label={workspaceName}
|
||||
trailing={
|
||||
workspace.current ? (
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4 text-text-accent" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -556,6 +556,7 @@ describe('Console bootstrap', () => {
|
||||
expect.objectContaining({
|
||||
email: 'user@example.com',
|
||||
workspace_id: 'workspace-1',
|
||||
workspace_plan: 'sandbox',
|
||||
workspace_role: 'editor',
|
||||
}),
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user