From a093ff4631eb939fff760cc14d77f53aed5dcf31 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 2 Apr 2026 20:54:06 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20Custom=20Views=20UI=20=E2=80=94?= =?UTF-8?q?=20create=20dialog,=20filter=20builder,=20sidebar=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/App.test.tsx | 1 + src/App.tsx | 35 +++++++++- src/components/CreateViewDialog.tsx | 92 ++++++++++++++++++++++++ src/components/FilterBuilder.tsx | 105 ++++++++++++++++++++++++++++ src/components/Sidebar.tsx | 31 +++++--- src/hooks/useDialogs.ts | 4 ++ 6 files changed, 257 insertions(+), 11 deletions(-) create mode 100644 src/components/CreateViewDialog.tsx create mode 100644 src/components/FilterBuilder.tsx diff --git a/src/App.test.tsx b/src/App.test.tsx index 01ce6ba4..0d278a35 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -64,6 +64,7 @@ const mockAllContent: Record = { const mockCommandResults: Record = { list_vault: mockEntries, list_vault_folders: [], + list_views: [], get_all_content: mockAllContent, get_modified_files: [], get_note_content: mockAllContent['/vault/project/test.md'] || '', diff --git a/src/App.tsx b/src/App.tsx index 04bb6775..2356fe38 100644 --- a/src/App.tsx +++ b/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() + 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 && ( <>
- +
@@ -567,6 +599,7 @@ function App() { + 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([ + { field: 'type', op: 'equals', value: '' }, + ]) + const inputRef = useRef(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 ( + { if (!isOpen) onClose() }}> + + + Create View + +
+
+
+ + setIcon(e.target.value)} + className="text-center" + maxLength={2} + /> +
+
+ + setName(e.target.value)} + /> +
+
+
+ + +
+ + + + +
+
+
+ ) +} diff --git a/src/components/FilterBuilder.tsx b/src/components/FilterBuilder.tsx new file mode 100644 index 00000000..e45f1501 --- /dev/null +++ b/src/components/FilterBuilder.tsx @@ -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(['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 ( +
+ + + {!NO_VALUE_OPS.has(condition.op) && ( + onUpdate({ ...condition, value: e.target.value })} + /> + )} + +
+ ) +} + +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 ( +
+ {conditions.length > 1 && ( + Match all of: + )} + {conditions.map((c, i) => ( + handleUpdate(i, updated)} + onRemove={() => handleRemove(i)} + /> + ))} + +
+ ) +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a0049dcc..1bbf514b 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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(null) @@ -392,7 +393,7 @@ export const Sidebar = memo(function Sidebar({ {/* Views */} - {views.length > 0 && ( + {(views.length > 0 || onCreateView) && (
Views @@ -403,14 +404,24 @@ export const Sidebar = memo(function Sidebar({ )}
{views.map((v) => ( - onSelect({ kind: 'view', filename: v.filename })} - compact - /> +
+ onSelect({ kind: 'view', filename: v.filename })} + compact + /> + {onDeleteView && ( + + )} +
))}
)} diff --git a/src/hooks/useDialogs.ts b/src/hooks/useDialogs.ts index 723eb7db..4b59c642 100644 --- a/src/hooks/useDialogs.ts +++ b/src/hooks/useDialogs.ts @@ -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, } }