diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index d98841ff..9521773f 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -35,7 +35,7 @@ import { ConnectIDEs } from '@/components/ConnectIDEs'; import { useDataSync } from '@/hooks/useDataSync'; import { useAnalytics } from '@/hooks/useAnalytics'; import { initAnalytics, capture, optIn, optOut } from '@/lib/analytics'; -import { useAppStore, useActiveSpace, useViewSpace, useTheme, useAnalyticsEnabled } from '@/stores'; +import { useAppStore, useActiveSpace, useViewSpace, useTheme, useAnalyticsEnabled, useActiveNav, useNavigateTo } from '@/stores'; import { RegistryPage } from '@/features/registry'; import { FeatureSetsPage } from '@/features/featuresets'; import { ClientsPage } from '@/features/clients'; @@ -80,13 +80,12 @@ function McpMuxGlyph({ className }: { className?: string }) { ); } -type NavItem = 'home' | 'registry' | 'servers' | 'spaces' | 'featuresets' | 'clients' | 'settings'; - function AppContent() { // Sync data from backend on mount useDataSync(); - const [activeNav, setActiveNav] = useState('home'); + const activeNav = useActiveNav(); + const navigateTo = useNavigateTo(); const [availableUpdate, setAvailableUpdate] = useState<{ version: string } | null>(null); // Auto-check for updates on startup (silent check after 5 seconds) @@ -199,21 +198,21 @@ function AppContent() { icon={} label="Dashboard" active={activeNav === 'home'} - onClick={() => setActiveNav('home')} + onClick={() => navigateTo('home')} data-testid="nav-dashboard" /> } label="My Servers" active={activeNav === 'servers'} - onClick={() => setActiveNav('servers')} + onClick={() => navigateTo('servers')} data-testid="nav-my-servers" /> } label="Discover" active={activeNav === 'registry'} - onClick={() => setActiveNav('registry')} + onClick={() => navigateTo('registry')} data-testid="nav-discover" /> @@ -223,14 +222,14 @@ function AppContent() { icon={} label="Spaces" active={activeNav === 'spaces'} - onClick={() => setActiveNav('spaces')} + onClick={() => navigateTo('spaces')} data-testid="nav-spaces" /> } label="FeatureSets" active={activeNav === 'featuresets'} - onClick={() => setActiveNav('featuresets')} + onClick={() => navigateTo('featuresets')} data-testid="nav-featuresets" /> @@ -240,7 +239,7 @@ function AppContent() { icon={} label="Clients" active={activeNav === 'clients'} - onClick={() => setActiveNav('clients')} + onClick={() => navigateTo('clients')} data-testid="nav-clients" /> @@ -250,7 +249,7 @@ function AppContent() { icon={} label="Settings" active={activeNav === 'settings'} - onClick={() => setActiveNav('settings')} + onClick={() => navigateTo('settings')} data-testid="nav-settings" /> @@ -316,7 +315,7 @@ function AppContent() { - -
+ +
- + {!isValidJson && ( {validationErrors.length > 0 ? 'Schema Error' : 'Invalid JSON'} )} + + + Ctrl+S save · Ctrl+Shift+F format +
{/* Editor Area */} diff --git a/apps/desktop/src/components/OAuthConsentModal.tsx b/apps/desktop/src/components/OAuthConsentModal.tsx index 2dc26cc1..0a022187 100644 --- a/apps/desktop/src/components/OAuthConsentModal.tsx +++ b/apps/desktop/src/components/OAuthConsentModal.tsx @@ -12,10 +12,11 @@ import { useState, useEffect } from 'react'; import { invoke } from '@tauri-apps/api/core'; -import { listen } from '@tauri-apps/api/event'; +import { listen, emit } from '@tauri-apps/api/event'; import { Check, X, AlertCircle, Loader2, Globe, Lock } from 'lucide-react'; import { Button, Card, CardHeader, CardTitle, CardDescription, CardContent } from '@mcpmux/ui'; import { listSpaces, type Space } from '@/lib/api/spaces'; +import { useNavigateTo, useSetPendingClientId } from '@/stores'; import { resolveKnownClientKey } from '@/lib/clientIcons'; import cursorIcon from '@/assets/client-icons/cursor.svg'; import vscodeIcon from '@/assets/client-icons/vscode.png'; @@ -72,7 +73,8 @@ type ModalState = | { type: 'hidden' } | { type: 'loading'; requestId: string } | { type: 'error'; requestId: string; error: ConsentError } - | { type: 'consent'; details: ConsentRequestDetails }; + | { type: 'consent'; details: ConsentRequestDetails } + | { type: 'approved'; clientName: string; clientId: string }; /** Open a URL using the backend open command (handles custom protocols like cursor://) */ async function openRedirectUrl(url: string): Promise { @@ -121,6 +123,8 @@ export function OAuthConsentModal() { const [processError, setProcessError] = useState(null); /** 2-second cooldown before the Approve button becomes active */ const [approveReady, setApproveReady] = useState(false); + const navigateTo = useNavigateTo(); + const setPendingClientId = useSetPendingClientId(); // Load spaces when modal opens useEffect(() => { @@ -196,7 +200,7 @@ export function OAuthConsentModal() { if (response.success && response.redirect_url) { console.log('[OAuth] Approved, redirecting to:', response.redirect_url); await openRedirectUrl(response.redirect_url); - setModalState({ type: 'hidden' }); + setModalState({ type: 'approved', clientName: clientAlias || details.clientName, clientId: details.clientId }); } else { setProcessError(response.error || 'Failed to approve consent'); } @@ -289,6 +293,63 @@ export function OAuthConsentModal() { ); } + // Approved state - show success with next-step guidance + if (modalState.type === 'approved') { + return ( +
+ + +
+
+ +
+
+ Client Approved + + {modalState.clientName} is now connected + +
+
+
+ +
+

Next step: Grant permissions

+

+ Assign FeatureSets to control which tools, prompts, and resources this client can access. +

+
+
+ + +
+
+
+
+ ); + } + // Consent state - show approval modal const { details } = modalState; const scopes = details.scope?.split(' ').filter(Boolean) || ['mcp']; diff --git a/apps/desktop/src/components/ServerLogViewer.tsx b/apps/desktop/src/components/ServerLogViewer.tsx index 5942f329..7905496d 100644 --- a/apps/desktop/src/components/ServerLogViewer.tsx +++ b/apps/desktop/src/components/ServerLogViewer.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useRef } from 'react'; import { X, Download, Trash2, RefreshCw } from 'lucide-react'; -import { useToast, ToastContainer } from '@mcpmux/ui'; +import { useToast, ToastContainer, useConfirm } from '@mcpmux/ui'; import { getServerLogs, clearServerLogs, getServerLogFile, type ServerLogEntry } from '@/lib/api/logs'; interface ServerLogViewerProps { @@ -41,6 +41,7 @@ export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogView const scrollContainerRef = useRef(null); const shouldScrollRef = useRef(true); const { toasts, success, error: showError, dismiss } = useToast(); + const { confirm, ConfirmDialogElement } = useConfirm(); const loadLogs = async () => { try { @@ -93,7 +94,12 @@ export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogView }; const handleClearLogs = async () => { - if (!confirm('Clear all logs for this server? This cannot be undone.')) { + if (!await confirm({ + title: 'Clear logs', + message: `Clear all logs for "${serverName}"? This cannot be undone.`, + confirmLabel: 'Clear', + variant: 'danger', + })) { return; } @@ -135,6 +141,7 @@ export function ServerLogViewer({ serverId, serverName, onClose }: ServerLogView return (
+ {ConfirmDialogElement}
{/* Header */}
diff --git a/apps/desktop/src/features/clients/ClientsPage.tsx b/apps/desktop/src/features/clients/ClientsPage.tsx index 30b22139..475d5770 100644 --- a/apps/desktop/src/features/clients/ClientsPage.tsx +++ b/apps/desktop/src/features/clients/ClientsPage.tsx @@ -32,12 +32,13 @@ import { Button, useToast, ToastContainer, + useConfirm, } from '@mcpmux/ui'; import type { OAuthClient, UpdateClientRequest } from '@/lib/api/gateway'; import { listOAuthClients, updateOAuthClient, deleteOAuthClient } from '@/lib/api/gateway'; import type { Space } from '@/lib/api/spaces'; import { listSpaces } from '@/lib/api/spaces'; -import { useViewSpace } from '@/stores'; +import { useViewSpace, usePendingClientId, useSetPendingClientId } from '@/stores'; import type { FeatureSet } from '@/lib/api/featureSets'; import { listFeatureSetsBySpace } from '@/lib/api/featureSets'; import { @@ -120,7 +121,10 @@ export default function ClientsPage() { const [selectedClient, setSelectedClient] = useState(null); const { toasts, success, error: showError, info, dismiss } = useToast(); - + const { confirm, ConfirmDialogElement } = useConfirm(); + const pendingClientId = usePendingClientId(); + const setPendingClientId = useSetPendingClientId(); + // Edit state const [editAlias, setEditAlias] = useState(''); const [editMode, setEditMode] = useState('follow_active'); @@ -265,6 +269,16 @@ export default function ClientsPage() { loadData(); }, []); + // Auto-open a client panel when navigated from "Manage Permissions" + useEffect(() => { + if (!pendingClientId || isLoading) return; + const client = oauthClients.find(c => c.client_id === pendingClientId); + if (client) { + openPanel(client); + setPendingClientId(null); + } + }, [pendingClientId, isLoading, oauthClients]); + useEffect(() => { setActiveSpace(viewSpace); }, [viewSpace?.id]); @@ -383,9 +397,14 @@ export default function ClientsPage() { }; const handleDelete = async (clientId: string) => { - if (!confirm('Remove this client? All tokens will be revoked.')) return; - const deletedClient = oauthClients.find(c => c.client_id === clientId); + const name = deletedClient?.client_alias || deletedClient?.client_name || 'this client'; + if (!await confirm({ + title: 'Remove client', + message: `Remove "${name}"? All tokens will be revoked.`, + confirmLabel: 'Remove', + variant: 'danger', + })) return; const clientName = deletedClient?.client_alias || deletedClient?.client_name || 'Client'; try { @@ -1321,6 +1340,7 @@ export default function ClientsPage() { )} + {ConfirmDialogElement}
); } diff --git a/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx b/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx index 6ea56841..d2664b22 100644 --- a/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx +++ b/apps/desktop/src/features/featuresets/FeatureSetPanel.tsx @@ -20,7 +20,7 @@ import { Shield, Save, } from 'lucide-react'; -import { Button, useToast, ToastContainer } from '@mcpmux/ui'; +import { Button, useToast, ToastContainer, useConfirm } from '@mcpmux/ui'; import type { FeatureSet, AddMemberInput } from '@/lib/api/featureSets'; import { setFeatureSetMembers } from '@/lib/api/featureSets'; import type { ServerFeature } from '@/lib/api/serverFeatures'; @@ -49,6 +49,7 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda const [error, setError] = useState(null); const [expandedServers, setExpandedServers] = useState>(new Set()); const { toasts, success, error: showError, dismiss } = useToast(); + const { confirm, ConfirmDialogElement } = useConfirm(); // Collapsible sections - only one expanded at a time, features by default const [expandedSections, setExpandedSections] = useState({ @@ -279,6 +280,7 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda return (
+ {ConfirmDialogElement} {/* Panel Header */}
@@ -598,8 +600,13 @@ export function FeatureSetPanel({ featureSet, spaceId, onClose, onDelete, onUpda )} @@ -890,7 +891,14 @@ export function ServersPage() {
📦

No servers installed

-

Visit Discover to install MCP servers

+
) : (
diff --git a/apps/desktop/src/features/spaces/SpacesPage.tsx b/apps/desktop/src/features/spaces/SpacesPage.tsx index 7e9ca240..67e3e45f 100644 --- a/apps/desktop/src/features/spaces/SpacesPage.tsx +++ b/apps/desktop/src/features/spaces/SpacesPage.tsx @@ -16,6 +16,7 @@ import { Button, useToast, ToastContainer, + useConfirm, } from '@mcpmux/ui'; import { useAppStore, @@ -39,6 +40,7 @@ export function SpacesPage() { const [searchQuery, setSearchQuery] = useState(''); const [error, setError] = useState(null); const [isActionLoading, setIsActionLoading] = useState(null); // ID of space being acted on + const { confirm, ConfirmDialogElement } = useConfirm(); const { toasts, success, error: showError, dismiss } = useToast(); // Create Modal State @@ -69,7 +71,13 @@ export function SpacesPage() { }; const handleDelete = async (id: string) => { - if (!confirm('Are you sure you want to delete this space? This action cannot be undone.')) return; + const spaceName = spaces.find(s => s.id === id)?.name || 'this space'; + if (!await confirm({ + title: 'Delete workspace', + message: `Are you sure you want to delete "${spaceName}"? This action cannot be undone.`, + confirmLabel: 'Delete', + variant: 'danger', + })) return; setIsActionLoading(id); setError(null); @@ -117,6 +125,7 @@ export function SpacesPage() { return ( <> + {ConfirmDialogElement}
{/* Header */}
diff --git a/apps/desktop/src/stores/appStore.ts b/apps/desktop/src/stores/appStore.ts index 7f5b1618..91e554dd 100644 --- a/apps/desktop/src/stores/appStore.ts +++ b/apps/desktop/src/stores/appStore.ts @@ -7,6 +7,8 @@ const initialState: AppState = { spaces: [], activeSpaceId: null, viewSpaceId: null, + activeNav: 'home', + pendingClientId: null, sidebarCollapsed: false, theme: 'system', analyticsEnabled: true, @@ -85,6 +87,17 @@ export const useAppStore = create()( } }), + // Navigation + navigateTo: (nav) => + set((state) => { + state.activeNav = nav; + }), + + setPendingClientId: (id) => + set((state) => { + state.pendingClientId = id; + }), + // UI toggleSidebar: () => set((state) => { diff --git a/apps/desktop/src/stores/selectors.ts b/apps/desktop/src/stores/selectors.ts index 756b3219..02ed4cb4 100644 --- a/apps/desktop/src/stores/selectors.ts +++ b/apps/desktop/src/stores/selectors.ts @@ -5,6 +5,10 @@ import { Space } from '@/lib/api/spaces'; export const useSpaces = () => useAppStore((state) => state.spaces); export const useActiveSpaceId = () => useAppStore((state) => state.activeSpaceId); export const useViewSpaceId = () => useAppStore((state) => state.viewSpaceId); +export const useActiveNav = () => useAppStore((state) => state.activeNav); +export const useNavigateTo = () => useAppStore((state) => state.navigateTo); +export const usePendingClientId = () => useAppStore((state) => state.pendingClientId); +export const useSetPendingClientId = () => useAppStore((state) => state.setPendingClientId); export const useTheme = () => useAppStore((state) => state.theme); export const useSidebarCollapsed = () => useAppStore((state) => state.sidebarCollapsed); export const useAnalyticsEnabled = () => useAppStore((state) => state.analyticsEnabled); diff --git a/apps/desktop/src/stores/types.ts b/apps/desktop/src/stores/types.ts index 48686aae..15b55d3c 100644 --- a/apps/desktop/src/stores/types.ts +++ b/apps/desktop/src/stores/types.ts @@ -1,11 +1,18 @@ import { Space } from '@/lib/api/spaces'; +export type NavItem = 'home' | 'registry' | 'servers' | 'spaces' | 'featuresets' | 'clients' | 'settings'; + export interface AppState { // Spaces spaces: Space[]; activeSpaceId: string | null; viewSpaceId: string | null; + // Navigation + activeNav: NavItem; + /** Client ID to auto-select when navigating to Clients page */ + pendingClientId: string | null; + // UI state sidebarCollapsed: boolean; theme: 'light' | 'dark' | 'system'; @@ -27,6 +34,10 @@ export interface AppActions { removeSpace: (id: string) => void; updateSpace: (id: string, updates: Partial) => void; + // Navigation + navigateTo: (nav: NavItem) => void; + setPendingClientId: (id: string | null) => void; + // UI toggleSidebar: () => void; setTheme: (theme: 'light' | 'dark' | 'system') => void; diff --git a/packages/ui/src/components/common/ConfirmDialog.tsx b/packages/ui/src/components/common/ConfirmDialog.tsx new file mode 100644 index 00000000..7a3e3f58 --- /dev/null +++ b/packages/ui/src/components/common/ConfirmDialog.tsx @@ -0,0 +1,134 @@ +import { useCallback, useState, useRef } from 'react'; +import { AlertCircle } from 'lucide-react'; + +export interface ConfirmDialogState { + open: boolean; + title: string; + message: string; + confirmLabel?: string; + variant?: 'danger' | 'default'; +} + +export interface ConfirmDialogProps extends ConfirmDialogState { + onConfirm: () => void; + onCancel: () => void; +} + +export function ConfirmDialog({ + open, + title, + message, + confirmLabel = 'Confirm', + variant = 'default', + onConfirm, + onCancel, +}: ConfirmDialogProps) { + if (!open) return null; + + const isDanger = variant === 'danger'; + + return ( +
+
e.stopPropagation()} + data-testid="confirm-dialog" + > +
+ {isDanger && ( +
+ +
+ )} +
+

{title}

+

{message}

+
+
+
+ + +
+
+
+ ); +} + +/** + * Hook that provides a promise-based confirm dialog. + * + * Usage: + * ```tsx + * const { confirm, ConfirmDialogElement } = useConfirm(); + * + * const handleDelete = async () => { + * if (!await confirm({ title: 'Delete?', message: 'This cannot be undone.' })) return; + * // proceed with delete + * }; + * + * return <>{ConfirmDialogElement}; + * ``` + */ +export function useConfirm() { + const [state, setState] = useState({ + open: false, + title: '', + message: '', + key: 0, + }); + const resolveRef = useRef<((value: boolean) => void) | null>(null); + + const confirm = useCallback( + (options: Omit) => { + return new Promise((resolve) => { + resolveRef.current = resolve; + setState((prev) => ({ ...options, open: true, key: prev.key + 1 })); + }); + }, + [] + ); + + const handleConfirm = useCallback(() => { + setState((prev) => ({ ...prev, open: false })); + resolveRef.current?.(true); + resolveRef.current = null; + }, []); + + const handleCancel = useCallback(() => { + setState((prev) => ({ ...prev, open: false })); + resolveRef.current?.(false); + resolveRef.current = null; + }, []); + + const { key: dialogKey, ...dialogState } = state; + const ConfirmDialogElement = ( + + ); + + return { confirm, ConfirmDialogElement }; +} diff --git a/packages/ui/src/components/common/Toast.tsx b/packages/ui/src/components/common/Toast.tsx index a6cfac4e..d8c00c8d 100644 --- a/packages/ui/src/components/common/Toast.tsx +++ b/packages/ui/src/components/common/Toast.tsx @@ -4,12 +4,18 @@ import { cn } from '../../lib/cn'; export type ToastType = 'success' | 'error' | 'warning' | 'info'; +export interface ToastAction { + label: string; + onClick: () => void; +} + export interface ToastProps { id: string; type: ToastType; title: string; message?: string; duration?: number; + action?: ToastAction; onClose: (id: string) => void; } @@ -33,6 +39,7 @@ export function Toast({ title, message, duration = 3000, + action, onClose, }: ToastProps) { const Icon = iconMap[type]; @@ -62,6 +69,15 @@ export function Toast({ {message && (

{message}

)} + {action && ( + + )}
+ {ConfirmDialogElement} +
+ ); + } + + it('should resolve true when confirmed', async () => { + const user = userEvent.setup(); + const onResult = vi.fn(); + + render(); + + // Dialog should not be visible initially + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + + // Open dialog + await user.click(screen.getByTestId('trigger')); + + // Dialog should be visible + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument(); + expect(screen.getByText('Confirm action')).toBeInTheDocument(); + expect(screen.getByText('Do you want to proceed?')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-dialog-confirm')).toHaveTextContent('Proceed'); + + // Click confirm + await user.click(screen.getByTestId('confirm-dialog-confirm')); + + expect(onResult).toHaveBeenCalledWith(true); + // Dialog should close + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + + it('should resolve false when cancelled', async () => { + const user = userEvent.setup(); + const onResult = vi.fn(); + + render(); + + await user.click(screen.getByTestId('trigger')); + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument(); + + await user.click(screen.getByTestId('confirm-dialog-cancel')); + + expect(onResult).toHaveBeenCalledWith(false); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); + + it('should resolve false when overlay is clicked', async () => { + const user = userEvent.setup(); + const onResult = vi.fn(); + + render(); + + await user.click(screen.getByTestId('trigger')); + expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument(); + + await user.click(screen.getByTestId('confirm-dialog-overlay')); + + expect(onResult).toHaveBeenCalledWith(false); + expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument(); + }); +}); diff --git a/tests/ts/components/ToastAction.test.tsx b/tests/ts/components/ToastAction.test.tsx new file mode 100644 index 00000000..aa780d8a --- /dev/null +++ b/tests/ts/components/ToastAction.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Toast } from '../../../packages/ui/src/components/common/Toast'; + +describe('Toast with action', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should render action button when action is provided', () => { + const onClose = vi.fn(); + const onClick = vi.fn(); + render( + + ); + + expect(screen.getByTestId('toast-action')).toBeInTheDocument(); + expect(screen.getByText('Go to My Servers')).toBeInTheDocument(); + }); + + it('should not render action button when action is not provided', () => { + const onClose = vi.fn(); + render( + + ); + + expect(screen.queryByTestId('toast-action')).not.toBeInTheDocument(); + }); + + it('should call action onClick and close toast when action button is clicked', async () => { + vi.useRealTimers(); + const user = userEvent.setup(); + const onClose = vi.fn(); + const onClick = vi.fn(); + + render( + + ); + + await user.click(screen.getByTestId('toast-action')); + + expect(onClick).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledWith('test-1'); + vi.useFakeTimers(); + }); +}); diff --git a/tests/ts/hooks/useToast.test.ts b/tests/ts/hooks/useToast.test.ts index e52b5bc2..ac590971 100644 --- a/tests/ts/hooks/useToast.test.ts +++ b/tests/ts/hooks/useToast.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useToast } from '../../../packages/ui/src/hooks/useToast'; @@ -111,6 +111,16 @@ describe('useToast', () => { expect(result.current.toasts[0].duration).toBe(5000); }); + it('should allow custom duration via options object', () => { + const { result } = renderHook(() => useToast()); + + act(() => { + result.current.success('Toast', undefined, { duration: 6000 }); + }); + + expect(result.current.toasts[0].duration).toBe(6000); + }); + it('should use default duration when not specified', () => { const { result } = renderHook(() => useToast()); @@ -120,4 +130,34 @@ describe('useToast', () => { expect(result.current.toasts[0].duration).toBe(3000); }); + + it('should support action in success toast', () => { + const { result } = renderHook(() => useToast()); + const onClick = vi.fn(); + + act(() => { + result.current.success('Installed', 'Server ready', { + action: { label: 'Go to My Servers', onClick }, + }); + }); + + expect(result.current.toasts).toHaveLength(1); + expect(result.current.toasts[0].action).toBeDefined(); + expect(result.current.toasts[0].action?.label).toBe('Go to My Servers'); + }); + + it('should support action with custom duration', () => { + const { result } = renderHook(() => useToast()); + const onClick = vi.fn(); + + act(() => { + result.current.success('Installed', 'Done', { + duration: 6000, + action: { label: 'Enable', onClick }, + }); + }); + + expect(result.current.toasts[0].duration).toBe(6000); + expect(result.current.toasts[0].action?.label).toBe('Enable'); + }); }); diff --git a/tests/ts/stores/appStore.test.ts b/tests/ts/stores/appStore.test.ts index 9fb20e2f..0866a077 100644 --- a/tests/ts/stores/appStore.test.ts +++ b/tests/ts/stores/appStore.test.ts @@ -9,6 +9,8 @@ describe('appStore', () => { spaces: [], activeSpaceId: null, viewSpaceId: null, + activeNav: 'home', + pendingClientId: null, sidebarCollapsed: false, theme: 'system', loading: { spaces: false, servers: false }, @@ -278,6 +280,38 @@ describe('appStore', () => { }); }); + describe('navigateTo', () => { + it('should set activeNav', () => { + expect(useAppStore.getState().activeNav).toBe('home'); + + useAppStore.getState().navigateTo('servers'); + expect(useAppStore.getState().activeNav).toBe('servers'); + }); + + it('should navigate to different pages', () => { + useAppStore.getState().navigateTo('registry'); + expect(useAppStore.getState().activeNav).toBe('registry'); + + useAppStore.getState().navigateTo('clients'); + expect(useAppStore.getState().activeNav).toBe('clients'); + + useAppStore.getState().navigateTo('home'); + expect(useAppStore.getState().activeNav).toBe('home'); + }); + }); + + describe('setPendingClientId', () => { + it('should set and clear pending client id', () => { + expect(useAppStore.getState().pendingClientId).toBeNull(); + + useAppStore.getState().setPendingClientId('client-123'); + expect(useAppStore.getState().pendingClientId).toBe('client-123'); + + useAppStore.getState().setPendingClientId(null); + expect(useAppStore.getState().pendingClientId).toBeNull(); + }); + }); + describe('setLoading', () => { it('should set spaces loading state', () => { useAppStore.getState().setLoading('spaces', true);