diff --git a/src/App.tsx b/src/App.tsx index 107546c0..d1184167 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -336,15 +336,23 @@ function App() { setToastMessage(`Type "${name}" created`) }, [notes]) - const handleCreateView = useCallback(async (definition: import('./types').ViewDefinition) => { - const filename = definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml' + const handleCreateOrUpdateView = useCallback(async (definition: import('./types').ViewDefinition) => { + const editing = dialogs.editingView + const filename = editing + ? editing.filename + : definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml' const target = isTauri() ? invoke : mockInvoke await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition }) - trackEvent('view_created') + trackEvent(editing ? 'view_updated' : 'view_created') await vault.reloadViews() - setToastMessage(`View "${definition.name}" created`) + setToastMessage(editing ? `View "${definition.name}" updated` : `View "${definition.name}" created`) handleSetSelection({ kind: 'view', filename }) - }, [resolvedPath, vault, handleSetSelection]) + }, [resolvedPath, vault, handleSetSelection, dialogs.editingView]) + + const handleEditView = useCallback((filename: string) => { + const view = vault.views.find((v) => v.filename === filename) + if (view) dialogs.openEditView(filename, view.definition) + }, [vault.views, dialogs]) const handleDeleteView = useCallback(async (filename: string) => { const target = isTauri() ? invoke : mockInvoke @@ -545,7 +553,7 @@ function App() { {sidebarVisible && ( <>
- +
@@ -624,7 +632,7 @@ function App() { - + { + const defaultProps = { + open: true, + onClose: vi.fn(), + onCreate: vi.fn(), + availableFields: ['type', 'status', 'title'], + } + + it('shows "Create View" title in create mode', () => { + render() + 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() + 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() + const input = screen.getByPlaceholderText(/Active Projects|Reading List/i) + expect(input).toHaveValue('Active Projects') + }) +}) diff --git a/src/components/CreateViewDialog.tsx b/src/components/CreateViewDialog.tsx index 4913c20d..f7f33ca2 100644 --- a/src/components/CreateViewDialog.tsx +++ b/src/components/CreateViewDialog.tsx @@ -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(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 { if (!isOpen) onClose() }}> - Create View + {isEditing ? 'Edit View' : 'Create View'}
@@ -101,7 +110,7 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, val
- +
diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index 3cdf4d99..dc6b7378 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -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( + {}} views={mockViews} onEditView={() => {}} onDeleteView={() => {}} /> + ) + expect(screen.getByTitle('Edit view')).toBeInTheDocument() + }) + + it('does not render edit button when onEditView is not provided', () => { + render( + {}} views={mockViews} onDeleteView={() => {}} /> + ) + expect(screen.queryByTitle('Edit view')).not.toBeInTheDocument() + }) + + it('calls onEditView with correct filename when clicked', () => { + const onEditView = vi.fn() + render( + {}} views={mockViews} onEditView={onEditView} onDeleteView={() => {}} /> + ) + fireEvent.click(screen.getByTitle('Edit view')) + expect(onEditView).toHaveBeenCalledWith('active-projects.yml') + }) + }) }) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b4705a45..ae8037f9 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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(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 && ( - - )} +
+ {onEditView && ( + + )} + {onDeleteView && ( + + )} +
))} diff --git a/src/hooks/useDialogs.ts b/src/hooks/useDialogs.ts index 4b59c642..b62e3495 100644 --- a/src/hooks/useDialogs.ts +++ b/src/hooks/useDialogs.ts @@ -1,4 +1,5 @@ import { useState, useCallback } from 'react' +import type { ViewDefinition } from '../types' export function useDialogs() { const [showCreateTypeDialog, setShowCreateTypeDialog] = useState(false) @@ -10,6 +11,7 @@ export function useDialogs() { const [showSearch, setShowSearch] = useState(false) const [showConflictResolver, setShowConflictResolver] = useState(false) const [showCreateViewDialog, setShowCreateViewDialog] = useState(false) + const [editingView, setEditingView] = useState<{ filename: string; definition: ViewDefinition } | null>(null) const openCreateType = useCallback(() => setShowCreateTypeDialog(true), []) const closeCreateType = useCallback(() => setShowCreateTypeDialog(false), []) @@ -26,8 +28,12 @@ export function useDialogs() { const closeSearch = useCallback(() => setShowSearch(false), []) const openConflictResolver = useCallback(() => setShowConflictResolver(true), []) const closeConflictResolver = useCallback(() => setShowConflictResolver(false), []) - const openCreateView = useCallback(() => setShowCreateViewDialog(true), []) - const closeCreateView = useCallback(() => setShowCreateViewDialog(false), []) + const openCreateView = useCallback(() => { setEditingView(null); setShowCreateViewDialog(true) }, []) + const closeCreateView = useCallback(() => { setShowCreateViewDialog(false); setEditingView(null) }, []) + const openEditView = useCallback((filename: string, definition: ViewDefinition) => { + setEditingView({ filename, definition }) + setShowCreateViewDialog(true) + }, []) return { showCreateTypeDialog, openCreateType, closeCreateType, @@ -38,6 +44,6 @@ export function useDialogs() { showGitHubVault, openGitHubVault, closeGitHubVault, showSearch, openSearch, closeSearch, showConflictResolver, openConflictResolver, closeConflictResolver, - showCreateViewDialog, openCreateView, closeCreateView, + showCreateViewDialog, openCreateView, closeCreateView, editingView, openEditView, } }