feat: add edit button (pencil) on hover for sidebar view items

Opens the Create View dialog in edit mode with pre-populated fields
(name, icon, filters). Saving updates the existing .yml file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-04-04 02:50:49 +02:00
parent 51ce27f781
commit f37de38d21
6 changed files with 146 additions and 28 deletions

View File

@@ -0,0 +1,45 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { CreateViewDialog } from './CreateViewDialog'
import type { ViewDefinition } from '../types'
describe('CreateViewDialog', () => {
const defaultProps = {
open: true,
onClose: vi.fn(),
onCreate: vi.fn(),
availableFields: ['type', 'status', 'title'],
}
it('shows "Create View" title in create mode', () => {
render(<CreateViewDialog {...defaultProps} />)
expect(screen.getByText('Create View')).toBeInTheDocument()
expect(screen.getByText('Create')).toBeInTheDocument()
})
it('shows "Edit View" title when editingView is provided', () => {
const editingView: ViewDefinition = {
name: 'Active Projects',
icon: '🚀',
color: null,
sort: null,
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
}
render(<CreateViewDialog {...defaultProps} editingView={editingView} />)
expect(screen.getByText('Edit View')).toBeInTheDocument()
expect(screen.getByText('Save')).toBeInTheDocument()
})
it('pre-populates name field in edit mode', () => {
const editingView: ViewDefinition = {
name: 'Active Projects',
icon: '🚀',
color: null,
sort: null,
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
}
render(<CreateViewDialog {...defaultProps} editingView={editingView} />)
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
expect(input).toHaveValue('Active Projects')
})
})

View File

@@ -13,9 +13,11 @@ interface CreateViewDialogProps {
availableFields: string[]
/** Returns known values for a given field (for autocomplete). */
valueSuggestions?: (field: string) => string[]
/** When provided, the dialog operates in edit mode with pre-populated fields. */
editingView?: ViewDefinition | null
}
export function CreateViewDialog({ open, onClose, onCreate, availableFields, valueSuggestions }: CreateViewDialogProps) {
export function CreateViewDialog({ open, onClose, onCreate, availableFields, valueSuggestions, editingView }: CreateViewDialogProps) {
const [name, setName] = useState('')
const [icon, setIcon] = useState('')
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
@@ -23,16 +25,23 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, val
all: [{ field: 'type', op: 'equals', value: '' }],
})
const inputRef = useRef<HTMLInputElement>(null)
const isEditing = !!editingView
useEffect(() => {
if (open) {
setName('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setIcon('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
if (editingView) {
setName(editingView.name) // eslint-disable-line react-hooks/set-state-in-effect -- populate on dialog open
setIcon(editingView.icon ?? '') // eslint-disable-line react-hooks/set-state-in-effect -- populate on dialog open
setFilters(editingView.filters) // eslint-disable-line react-hooks/set-state-in-effect -- populate on dialog open
} else {
setName('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setIcon('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setFilters({ all: [{ field: availableFields[0] ?? 'type', op: 'equals', value: '' }] }) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
}
setShowEmojiPicker(false) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setFilters({ all: [{ field: availableFields[0] ?? 'type', op: 'equals', value: '' }] }) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open, availableFields])
}, [open, availableFields, editingView])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
@@ -62,7 +71,7 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, val
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<DialogContent showCloseButton={false} className="flex max-h-[80vh] flex-col sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Create View</DialogTitle>
<DialogTitle>{isEditing ? 'Edit View' : 'Create View'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
<div className="flex gap-2">
@@ -101,7 +110,7 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, val
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={!name.trim()}>Create</Button>
<Button type="submit" disabled={!name.trim()}>{isEditing ? 'Save' : 'Create'}</Button>
</DialogFooter>
</form>
</DialogContent>

View File

@@ -971,4 +971,42 @@ describe('Sidebar', () => {
expect(borderContainer!.contains(projectsSection)).toBe(true)
})
})
describe('view edit button', () => {
const mockViews = [
{
filename: 'active-projects.yml',
definition: {
name: 'Active Projects',
icon: '🚀',
color: null,
sort: null,
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Project' }] },
},
},
]
it('renders edit button for each view item when onEditView is provided', () => {
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} onEditView={() => {}} onDeleteView={() => {}} />
)
expect(screen.getByTitle('Edit view')).toBeInTheDocument()
})
it('does not render edit button when onEditView is not provided', () => {
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} onDeleteView={() => {}} />
)
expect(screen.queryByTitle('Edit view')).not.toBeInTheDocument()
})
it('calls onEditView with correct filename when clicked', () => {
const onEditView = vi.fn()
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} onEditView={onEditView} onDeleteView={() => {}} />
)
fireEvent.click(screen.getByTitle('Edit view'))
expect(onEditView).toHaveBeenCalledWith('active-projects.yml')
})
})
})

View File

@@ -12,7 +12,7 @@ import {
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
FileText, Trash, Archive, CaretLeft, Tray, CaretRight, CaretDown, Plus, Funnel,
FileText, Trash, Archive, CaretLeft, Tray, CaretRight, CaretDown, Plus, Funnel, PencilSimple,
} from '@phosphor-icons/react'
import { isEmoji } from '../utils/emoji'
import { arrayMove } from '@dnd-kit/sortable'
@@ -40,6 +40,7 @@ interface SidebarProps {
onReorderFavorites?: (orderedPaths: string[]) => void
views?: ViewFile[]
onCreateView?: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
folders?: FolderNode[]
onCreateFolder?: (name: string) => void
@@ -331,7 +332,7 @@ export const Sidebar = memo(function Sidebar({
entries, selection, onSelect,
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
onToggleTypeVisibility, onSelectFavorite, onReorderFavorites,
views = [], onCreateView, onDeleteView,
views = [], onCreateView, onEditView, onDeleteView,
folders = [], onCreateFolder, inboxCount = 0, onCollapse,
}: SidebarProps) {
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
@@ -459,15 +460,26 @@ export const Sidebar = memo(function Sidebar({
isActive={isSelectionActive(selection, { kind: 'view', filename: v.filename })}
onClick={() => onSelect({ kind: 'view', filename: v.filename })}
/>
{onDeleteView && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); onDeleteView(v.filename) }}
title="Delete view"
>
<Trash size={12} />
</button>
)}
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{onEditView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
onClick={(e) => { e.stopPropagation(); onEditView(v.filename) }}
title="Edit view"
>
<PencilSimple size={12} />
</button>
)}
{onDeleteView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
onClick={(e) => { e.stopPropagation(); onDeleteView(v.filename) }}
title="Delete view"
>
<Trash size={12} />
</button>
)}
</div>
</div>
))}
</div>