From 2271cb70e1f9d604ab5a7ddb80428e4c0a7ee4f4 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 10 Apr 2026 12:45:37 +0200 Subject: [PATCH] feat: add feedback modal flow --- src/App.tsx | 8 ++- src/components/FeedbackDialog.test.tsx | 53 +++++++++++++++++ src/components/FeedbackDialog.tsx | 59 +++++++++++++++++++ src/components/StatusBar.test.tsx | 13 ++++ src/components/StatusBar.tsx | 3 + .../status-bar/StatusBarSections.tsx | 18 ++++++ src/constants/feedback.ts | 1 + src/hooks/commands/settingsCommands.ts | 4 +- src/hooks/useAppCommands.ts | 2 + src/hooks/useCommandRegistry.test.ts | 13 ++++ src/hooks/useCommandRegistry.ts | 7 ++- 11 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 src/components/FeedbackDialog.test.tsx create mode 100644 src/components/FeedbackDialog.tsx create mode 100644 src/constants/feedback.ts diff --git a/src/App.tsx b/src/App.tsx index 7f759f87..02fc80a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { SettingsPanel } from './components/SettingsPanel' import { GitHubVaultModal } from './components/GitHubVaultModal' import { WelcomeScreen } from './components/WelcomeScreen' import { TelemetryConsentDialog } from './components/TelemetryConsentDialog' +import { FeedbackDialog } from './components/FeedbackDialog' import { useTelemetry } from './hooks/useTelemetry' import { useMcpStatus } from './hooks/useMcpStatus' import { useClaudeCodeStatus } from './hooks/useClaudeCodeStatus' @@ -91,6 +92,9 @@ function App() { const visibleNotesRef = useRef([]) const [toastMessage, setToastMessage] = useState(null) const dialogs = useDialogs() + const [showFeedback, setShowFeedback] = useState(false) + const openFeedback = useCallback(() => setShowFeedback(true), []) + const closeFeedback = useCallback(() => setShowFeedback(false), []) // onSwitch closure captures `notes` declared below — safe because it's only // called on user interaction, never during render (refs inside the hook @@ -509,6 +513,7 @@ function App() { onCreateNoteOfType: notes.handleCreateNoteImmediate, onSave: appSave.handleSave, onOpenSettings: dialogs.openSettings, + onOpenFeedback: openFeedback, onDeleteNote: deleteActions.handleDeleteNote, onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote, onCommitPush: commitFlow.openCommitDialog, @@ -692,7 +697,7 @@ function App() { - handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} /> + handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} /> setToastMessage(null)} /> @@ -712,6 +717,7 @@ function App() { onClose={conflictFlow.handleCloseConflictResolver} /> + ({ + openExternalUrl: vi.fn().mockResolvedValue(undefined), +})) + +const { openExternalUrl } = await import('../utils/url') as typeof import('../utils/url') & { + openExternalUrl: ReturnType +} + +describe('FeedbackDialog', () => { + it('renders the instructional copy when open', () => { + render() + expect(screen.getByTestId('feedback-dialog')).toBeInTheDocument() + expect(screen.getByText('Share feedback')).toBeInTheDocument() + expect(screen.getByText(/best way to share product feedback/i)).toBeInTheDocument() + expect(screen.getByText(/check whether a similar one already exists/i)).toBeInTheDocument() + }) + + it('focuses the primary CTA when opened', async () => { + render() + const cta = screen.getByRole('button', { name: 'Go to Issues' }) + await waitFor(() => expect(cta).toHaveFocus()) + }) + + it('opens GitHub Issues without closing the modal', async () => { + const onClose = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Go to Issues' })) + + await waitFor(() => expect(openExternalUrl).toHaveBeenCalledWith(LAPUTA_GITHUB_ISSUES_URL)) + expect(onClose).not.toHaveBeenCalled() + expect(screen.getByTestId('feedback-dialog')).toBeInTheDocument() + }) + + it('closes when pressing Escape', () => { + const onClose = vi.fn() + render() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('closes when clicking Close', () => { + const onClose = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(onClose).toHaveBeenCalledOnce() + }) +}) diff --git a/src/components/FeedbackDialog.tsx b/src/components/FeedbackDialog.tsx new file mode 100644 index 00000000..1d2ebb79 --- /dev/null +++ b/src/components/FeedbackDialog.tsx @@ -0,0 +1,59 @@ +import { Megaphone } from '@phosphor-icons/react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { LAPUTA_GITHUB_ISSUES_URL } from '../constants/feedback' +import { openExternalUrl } from '../utils/url' + +interface FeedbackDialogProps { + open: boolean + onClose: () => void +} + +export function FeedbackDialog({ open, onClose }: FeedbackDialogProps) { + const handleOpenIssues = () => { + void openExternalUrl(LAPUTA_GITHUB_ISSUES_URL) + } + + return ( + { if (!next) onClose() }}> + + + + + Share feedback + + + The best way to share product feedback is through a GitHub Issue. + + + +
+

+ Before opening a new issue, please check whether a similar one already exists. + If it does, add an upvote or comment there instead of opening a duplicate. +

+

+ When you do open a new issue, include the steps to reproduce, what you expected, + and what actually happened so it is easier to triage. +

+
+ + + + + +
+
+ ) +} diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index 14a4347f..7714c06e 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -51,6 +51,19 @@ describe('StatusBar', () => { expect(screen.queryByText('main')).not.toBeInTheDocument() }) + it('shows Feedback button when callback is provided', () => { + render() + expect(screen.getByTestId('status-feedback')).toBeInTheDocument() + expect(screen.getByText('Feedback')).toBeInTheDocument() + }) + + it('calls onOpenFeedback when Feedback is clicked', () => { + const onOpenFeedback = vi.fn() + render() + fireEvent.click(screen.getByTestId('status-feedback')) + expect(onOpenFeedback).toHaveBeenCalledOnce() + }) + it('shows clickable commit hash that opens URL via openExternalUrl', () => { render( void zoomLevel?: number onZoomReset?: () => void + onOpenFeedback?: () => void buildNumber?: string onCheckForUpdates?: () => void onRemoveVault?: (path: string) => void @@ -67,6 +68,7 @@ export function StatusBar({ onOpenConflictResolver, zoomLevel = 100, onZoomReset, + onOpenFeedback, buildNumber, onCheckForUpdates, onRemoveVault, @@ -131,6 +133,7 @@ export function StatusBar({ noteCount={noteCount} zoomLevel={zoomLevel} onZoomReset={onZoomReset} + onOpenFeedback={onOpenFeedback} onOpenSettings={onOpenSettings} /> diff --git a/src/components/status-bar/StatusBarSections.tsx b/src/components/status-bar/StatusBarSections.tsx index cae60b59..eb89b71f 100644 --- a/src/components/status-bar/StatusBarSections.tsx +++ b/src/components/status-bar/StatusBarSections.tsx @@ -1,7 +1,9 @@ import { Bell, FileText, Package, Settings } from 'lucide-react' +import { Megaphone } from '@phosphor-icons/react' import type { ClaudeCodeStatus } from '../../hooks/useClaudeCodeStatus' import type { McpStatus } from '../../hooks/useMcpStatus' import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../../types' +import { Button } from '@/components/ui/button' import { ClaudeCodeBadge, CommitBadge, @@ -49,6 +51,7 @@ interface StatusBarSecondarySectionProps { noteCount: number zoomLevel: number onZoomReset?: () => void + onOpenFeedback?: () => void onOpenSettings?: () => void } @@ -127,6 +130,7 @@ export function StatusBarSecondarySection({ noteCount, zoomLevel, onZoomReset, + onOpenFeedback, onOpenSettings, }: StatusBarSecondarySectionProps) { return ( @@ -148,6 +152,20 @@ export function StatusBarSecondarySection({ {zoomLevel}% )} + {onOpenFeedback && ( + + )} diff --git a/src/constants/feedback.ts b/src/constants/feedback.ts new file mode 100644 index 00000000..1e1ea270 --- /dev/null +++ b/src/constants/feedback.ts @@ -0,0 +1 @@ +export const LAPUTA_GITHUB_ISSUES_URL = 'https://github.com/refactoringhq/laputa-app/issues' diff --git a/src/hooks/commands/settingsCommands.ts b/src/hooks/commands/settingsCommands.ts index 6e6dc3c8..c8870397 100644 --- a/src/hooks/commands/settingsCommands.ts +++ b/src/hooks/commands/settingsCommands.ts @@ -5,6 +5,7 @@ interface SettingsCommandsConfig { vaultCount?: number isGettingStartedHidden?: boolean onOpenSettings: () => void + onOpenFeedback?: () => void onOpenVault?: () => void onRemoveActiveVault?: () => void onRestoreGettingStarted?: () => void @@ -17,12 +18,13 @@ interface SettingsCommandsConfig { export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAction[] { const { mcpStatus, vaultCount, isGettingStartedHidden, - onOpenSettings, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted, + onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted, onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault, } = config return [ { id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,', keywords: ['preferences', 'config'], enabled: true, execute: onOpenSettings }, + { id: 'give-feedback', label: 'Give Feedback', group: 'Settings', keywords: ['feedback', 'issue', 'bug', 'github', 'report'], enabled: !!onOpenFeedback, execute: () => onOpenFeedback?.() }, { id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() }, { id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() }, { id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() }, diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 5451a44b..6e2a5039 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -23,6 +23,7 @@ interface AppCommandsConfig { onCreateNoteOfType: (type: string) => void onSave: () => void onOpenSettings: () => void + onOpenFeedback?: () => void onDeleteNote: (path: string) => void onArchiveNote: (path: string) => void onUnarchiveNote: (path: string) => void @@ -171,6 +172,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] { onCreateNoteOfType: config.onCreateNoteOfType, onSave: config.onSave, onOpenSettings: config.onOpenSettings, + onOpenFeedback: config.onOpenFeedback, onDeleteNote: config.onDeleteNote, onArchiveNote: config.onArchiveNote, onUnarchiveNote: config.onUnarchiveNote, diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index a6931bf5..91f734ec 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -200,6 +200,19 @@ describe('useCommandRegistry', () => { expect(findCommand(result.current, 'toggle-organized')?.shortcut).toBe('⌘E') expect(findCommand(result.current, 'archive-note')?.shortcut).toBeUndefined() }) + + it('includes Give Feedback in the Settings group when available', () => { + const onOpenFeedback = vi.fn() + const config = makeConfig({ onOpenFeedback }) + const { result } = renderHook(() => useCommandRegistry(config)) + const cmd = findCommand(result.current, 'give-feedback') + expect(cmd).toBeDefined() + expect(cmd!.group).toBe('Settings') + expect(cmd!.enabled).toBe(true) + + cmd!.execute() + expect(onOpenFeedback).toHaveBeenCalledOnce() + }) }) describe('pluralizeType', () => { diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index dc974172..b61c27db 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -39,6 +39,7 @@ interface CommandRegistryConfig { onCreateNoteOfType: (type: string) => void onSave: () => void onOpenSettings: () => void + onOpenFeedback?: () => void onOpenVault?: () => void onCreateType?: () => void onDeleteNote: (path: string) => void @@ -76,7 +77,7 @@ interface CommandRegistryConfig { export function useCommandRegistry(config: CommandRegistryConfig): import('./commands/types').CommandAction[] { const { activeTabPath, entries, modifiedCount, - onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, + onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onOpenFeedback, onDeleteNote, onArchiveNote, onUnarchiveNote, onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, activeNoteModified, @@ -124,14 +125,14 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com }), ...buildSettingsCommands({ mcpStatus, vaultCount, isGettingStartedHidden, - onOpenSettings, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted, + onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted, onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault, }), ...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect), ...buildFilterCommands({ isSectionGroup, noteListFilter, onSetNoteListFilter }), ], [ hasActiveNote, activeTabPath, isArchived, modifiedCount, activeNoteModified, - onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings, + onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings, onOpenFeedback, onDeleteNote, onArchiveNote, onUnarchiveNote, onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCheckForUpdates,