feat: add Custom Views UI — create dialog, filter builder, sidebar integration
CreateViewDialog with FilterBuilder for constructing filter conditions (field/operator/value rows). Wire onCreateView and onDeleteView in App.tsx with Tauri command integration. Sidebar VIEWS section shows "+" button for discoverability even when empty, and delete buttons on hover. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,7 @@ const mockAllContent: Record<string, string> = {
|
||||
const mockCommandResults: Record<string, unknown> = {
|
||||
list_vault: mockEntries,
|
||||
list_vault_folders: [],
|
||||
list_views: [],
|
||||
get_all_content: mockAllContent,
|
||||
get_modified_files: [],
|
||||
get_note_content: mockAllContent['/vault/project/test.md'] || '',
|
||||
|
||||
35
src/App.tsx
35
src/App.tsx
@@ -4,6 +4,7 @@ import { NoteList } from './components/NoteList'
|
||||
import { Editor } from './components/Editor'
|
||||
import { ResizeHandle } from './components/ResizeHandle'
|
||||
import { CreateTypeDialog } from './components/CreateTypeDialog'
|
||||
import { CreateViewDialog } from './components/CreateViewDialog'
|
||||
import { QuickOpenPalette } from './components/QuickOpenPalette'
|
||||
import { CommandPalette } from './components/CommandPalette'
|
||||
import { SearchPanel } from './components/SearchPanel'
|
||||
@@ -326,6 +327,37 @@ 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, '')
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition })
|
||||
await vault.reloadViews()
|
||||
setToastMessage(`View "${definition.name}" created`)
|
||||
handleSetSelection({ kind: 'view', filename })
|
||||
}, [resolvedPath, vault, handleSetSelection])
|
||||
|
||||
const handleDeleteView = useCallback(async (filename: string) => {
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
|
||||
await vault.reloadViews()
|
||||
if (selection.kind === 'view' && selection.filename === filename) {
|
||||
handleSetSelection({ kind: 'filter', filter: 'all' })
|
||||
}
|
||||
setToastMessage('View deleted')
|
||||
}, [resolvedPath, vault, selection, handleSetSelection])
|
||||
|
||||
const availableFields = useMemo(() => {
|
||||
const builtIn = ['type', 'status', 'title', 'favorite']
|
||||
if (!vault.entries?.length) return builtIn
|
||||
const customProps = new Set<string>()
|
||||
for (const e of vault.entries) {
|
||||
if (e.properties) {
|
||||
for (const key of Object.keys(e.properties)) customProps.add(key)
|
||||
}
|
||||
}
|
||||
return [...builtIn, ...Array.from(customProps).sort()]
|
||||
}, [vault.entries])
|
||||
|
||||
const bulkActions = useBulkActions(entryActions, setToastMessage)
|
||||
|
||||
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
|
||||
@@ -488,7 +520,7 @@ function App() {
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={notes.handleSelectNote} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} inboxCount={inboxCount} />
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={notes.handleSelectNote} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onCreateView={dialogs.openCreateView} onDeleteView={handleDeleteView} inboxCount={inboxCount} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -567,6 +599,7 @@ function App() {
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateView} availableFields={availableFields} />
|
||||
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} suggestedMessage={suggestedCommitMessage} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
|
||||
<ConflictResolverModal
|
||||
open={dialogs.showConflictResolver}
|
||||
|
||||
92
src/components/CreateViewDialog.tsx
Normal file
92
src/components/CreateViewDialog.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { FilterBuilder } from './FilterBuilder'
|
||||
import type { FilterCondition, ViewDefinition } from '../types'
|
||||
|
||||
interface CreateViewDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreate: (definition: ViewDefinition) => void
|
||||
availableFields: string[]
|
||||
}
|
||||
|
||||
export function CreateViewDialog({ open, onClose, onCreate, availableFields }: CreateViewDialogProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [icon, setIcon] = useState('')
|
||||
const [conditions, setConditions] = useState<FilterCondition[]>([
|
||||
{ field: 'type', op: 'equals', value: '' },
|
||||
])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
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
|
||||
setConditions([{ 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])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
const validConditions = conditions.filter((c) => c.field)
|
||||
const definition: ViewDefinition = {
|
||||
name: trimmed,
|
||||
icon: icon || null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: validConditions },
|
||||
}
|
||||
onCreate(definition)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create View</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-16 space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Icon</label>
|
||||
<Input
|
||||
placeholder="📋"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
className="text-center"
|
||||
maxLength={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Name</label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="e.g. Active Projects, Reading List..."
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Filters</label>
|
||||
<FilterBuilder
|
||||
conditions={conditions}
|
||||
onChange={setConditions}
|
||||
availableFields={availableFields}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={!name.trim()}>Create</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
105
src/components/FilterBuilder.tsx
Normal file
105
src/components/FilterBuilder.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Plus, X } from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { FilterCondition, FilterOp } from '../types'
|
||||
|
||||
const OPERATORS: { value: FilterOp; label: string }[] = [
|
||||
{ value: 'equals', label: 'equals' },
|
||||
{ value: 'not_equals', label: 'does not equal' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
{ value: 'not_contains', label: 'does not contain' },
|
||||
{ value: 'is_empty', label: 'is empty' },
|
||||
{ value: 'is_not_empty', label: 'is not empty' },
|
||||
]
|
||||
|
||||
const NO_VALUE_OPS = new Set<FilterOp>(['is_empty', 'is_not_empty'])
|
||||
|
||||
interface FilterBuilderProps {
|
||||
conditions: FilterCondition[]
|
||||
onChange: (conditions: FilterCondition[]) => void
|
||||
availableFields: string[]
|
||||
}
|
||||
|
||||
function FilterRow({ condition, fields, onUpdate, onRemove }: {
|
||||
condition: FilterCondition
|
||||
fields: string[]
|
||||
onUpdate: (c: FilterCondition) => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
|
||||
value={condition.field}
|
||||
onChange={(e) => onUpdate({ ...condition, field: e.target.value })}
|
||||
>
|
||||
{fields.map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
|
||||
value={condition.op}
|
||||
onChange={(e) => onUpdate({ ...condition, op: e.target.value as FilterOp })}
|
||||
>
|
||||
{OPERATORS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{!NO_VALUE_OPS.has(condition.op) && (
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0"
|
||||
placeholder="value"
|
||||
value={String(condition.value ?? '')}
|
||||
onChange={(e) => onUpdate({ ...condition, value: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
onClick={onRemove}
|
||||
title="Remove filter"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FilterBuilder({ conditions, onChange, availableFields }: FilterBuilderProps) {
|
||||
const fields = availableFields.length > 0 ? availableFields : ['type']
|
||||
|
||||
const handleUpdate = (index: number, updated: FilterCondition) => {
|
||||
const next = [...conditions]
|
||||
next[index] = updated
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChange(conditions.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
onChange([...conditions, { field: fields[0], op: 'equals', value: '' }])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{conditions.length > 1 && (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">Match all of:</span>
|
||||
)}
|
||||
{conditions.map((c, i) => (
|
||||
<FilterRow
|
||||
key={i}
|
||||
condition={c}
|
||||
fields={fields}
|
||||
onUpdate={(updated) => handleUpdate(i, updated)}
|
||||
onRemove={() => handleRemove(i)}
|
||||
/>
|
||||
))}
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs" onClick={handleAdd}>
|
||||
<Plus size={12} className="mr-1" /> Add filter
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -40,6 +40,7 @@ interface SidebarProps {
|
||||
onReorderFavorites?: (orderedPaths: string[]) => void
|
||||
views?: ViewFile[]
|
||||
onCreateView?: () => void
|
||||
onDeleteView?: (filename: string) => void
|
||||
folders?: FolderNode[]
|
||||
onCreateFolder?: (name: string) => void
|
||||
inboxCount?: number
|
||||
@@ -303,7 +304,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
entries, selection, onSelect,
|
||||
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
|
||||
onToggleTypeVisibility, onSelectFavorite, onReorderFavorites,
|
||||
views = [], onCreateView,
|
||||
views = [], onCreateView, onDeleteView,
|
||||
folders = [], onCreateFolder, inboxCount = 0, onCollapse,
|
||||
}: SidebarProps) {
|
||||
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
|
||||
@@ -392,7 +393,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
<FavoritesSection entries={entries} selection={selection} onSelect={onSelect} onSelectNote={onSelectFavorite} onReorder={onReorderFavorites} />
|
||||
|
||||
{/* Views */}
|
||||
{views.length > 0 && (
|
||||
{(views.length > 0 || onCreateView) && (
|
||||
<div style={{ padding: '4px 6px' }}>
|
||||
<div className="flex w-full select-none items-center justify-between" style={{ padding: '4px 16px' }}>
|
||||
<span className="text-[11px] font-medium text-muted-foreground">Views</span>
|
||||
@@ -403,14 +404,24 @@ export const Sidebar = memo(function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
{views.map((v) => (
|
||||
<NavItem
|
||||
key={v.filename}
|
||||
icon={Funnel}
|
||||
label={v.definition.name}
|
||||
isActive={isSelectionActive(selection, { kind: 'view', filename: v.filename })}
|
||||
onClick={() => onSelect({ kind: 'view', filename: v.filename })}
|
||||
compact
|
||||
/>
|
||||
<div key={v.filename} className="group relative">
|
||||
<NavItem
|
||||
icon={Funnel}
|
||||
label={v.definition.name}
|
||||
isActive={isSelectionActive(selection, { kind: 'view', filename: v.filename })}
|
||||
onClick={() => onSelect({ kind: 'view', filename: v.filename })}
|
||||
compact
|
||||
/>
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ export function useDialogs() {
|
||||
const [showGitHubVault, setShowGitHubVault] = useState(false)
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const [showConflictResolver, setShowConflictResolver] = useState(false)
|
||||
const [showCreateViewDialog, setShowCreateViewDialog] = useState(false)
|
||||
|
||||
const openCreateType = useCallback(() => setShowCreateTypeDialog(true), [])
|
||||
const closeCreateType = useCallback(() => setShowCreateTypeDialog(false), [])
|
||||
@@ -25,6 +26,8 @@ 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), [])
|
||||
|
||||
return {
|
||||
showCreateTypeDialog, openCreateType, closeCreateType,
|
||||
@@ -35,5 +38,6 @@ export function useDialogs() {
|
||||
showGitHubVault, openGitHubVault, closeGitHubVault,
|
||||
showSearch, openSearch, closeSearch,
|
||||
showConflictResolver, openConflictResolver, closeConflictResolver,
|
||||
showCreateViewDialog, openCreateView, closeCreateView,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user