feat: add feedback modal flow

This commit is contained in:
Test
2026-04-10 12:45:37 +02:00
parent 42b1e18cc4
commit 2271cb70e1
11 changed files with 176 additions and 5 deletions

View File

@@ -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<VaultEntry[]>([])
const [toastMessage, setToastMessage] = useState<string | null>(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() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => 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} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => 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} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
@@ -712,6 +717,7 @@ function App() {
onClose={conflictFlow.handleCloseConflictResolver}
/>
<SettingsPanel open={dialogs.showSettings} settings={settings} onSave={saveSettings} onClose={dialogs.closeSettings} />
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
<GitHubVaultModal
open={dialogs.showGitHubVault}
githubToken={settings.github_token}

View File

@@ -0,0 +1,53 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { FeedbackDialog } from './FeedbackDialog'
import { LAPUTA_GITHUB_ISSUES_URL } from '../constants/feedback'
vi.mock('../utils/url', () => ({
openExternalUrl: vi.fn().mockResolvedValue(undefined),
}))
const { openExternalUrl } = await import('../utils/url') as typeof import('../utils/url') & {
openExternalUrl: ReturnType<typeof vi.fn>
}
describe('FeedbackDialog', () => {
it('renders the instructional copy when open', () => {
render(<FeedbackDialog open={true} onClose={vi.fn()} />)
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(<FeedbackDialog open={true} onClose={vi.fn()} />)
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(<FeedbackDialog open={true} onClose={onClose} />)
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(<FeedbackDialog open={true} onClose={onClose} />)
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('closes when clicking Close', () => {
const onClose = vi.fn()
render(<FeedbackDialog open={true} onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalledOnce()
})
})

View File

@@ -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 (
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[460px]" data-testid="feedback-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Megaphone size={18} weight="duotone" />
Share feedback
</DialogTitle>
<DialogDescription>
The best way to share product feedback is through a GitHub Issue.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm leading-6 text-muted-foreground">
<p>
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.
</p>
<p>
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.
</p>
</div>
<DialogFooter className="sm:justify-between">
<Button type="button" variant="outline" onClick={onClose}>
Close
</Button>
<Button type="button" autoFocus onClick={handleOpenIssues}>
Go to Issues
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -51,6 +51,19 @@ describe('StatusBar', () => {
expect(screen.queryByText('main')).not.toBeInTheDocument()
})
it('shows Feedback button when callback is provided', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenFeedback={vi.fn()} />)
expect(screen.getByTestId('status-feedback')).toBeInTheDocument()
expect(screen.getByText('Feedback')).toBeInTheDocument()
})
it('calls onOpenFeedback when Feedback is clicked', () => {
const onOpenFeedback = vi.fn()
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onOpenFeedback={onOpenFeedback} />)
fireEvent.click(screen.getByTestId('status-feedback'))
expect(onOpenFeedback).toHaveBeenCalledOnce()
})
it('shows clickable commit hash that opens URL via openExternalUrl', () => {
render(
<StatusBar

View File

@@ -34,6 +34,7 @@ interface StatusBarProps {
onOpenConflictResolver?: () => 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}
/>
</footer>

View File

@@ -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}%
</span>
)}
{onOpenFeedback && (
<Button
type="button"
variant="ghost"
size="xs"
className="h-6 px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground"
onClick={onOpenFeedback}
title="Share feedback"
data-testid="status-feedback"
>
<Megaphone size={14} />
Feedback
</Button>
)}
<span style={DISABLED_STYLE} title="Coming soon">
<Bell size={14} />
</span>

View File

@@ -0,0 +1 @@
export const LAPUTA_GITHUB_ISSUES_URL = 'https://github.com/refactoringhq/laputa-app/issues'

View File

@@ -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?.() },

View File

@@ -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,

View File

@@ -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', () => {

View File

@@ -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,