feat: allow manual saved view ordering

This commit is contained in:
lucaronin
2026-04-28 20:41:07 +02:00
parent a39b346a75
commit fd92ff5e9b
20 changed files with 964 additions and 161 deletions

View File

@@ -1384,6 +1384,33 @@ describe('Sidebar', () => {
},
]
it('renders keyboard-accessible move buttons for saved views', () => {
const onMoveView = vi.fn()
render(
<Sidebar
entries={mockEntries}
selection={defaultSelection}
onSelect={() => {}}
views={mockViews}
onMoveView={onMoveView}
/>
)
const moveUpButtons = screen.getAllByTitle('Move view up')
const moveDownButtons = screen.getAllByTitle('Move view down')
expect(moveUpButtons[0]).toBeDisabled()
expect(moveUpButtons[1]).not.toBeDisabled()
expect(moveDownButtons[0]).not.toBeDisabled()
expect(moveDownButtons[1]).toBeDisabled()
fireEvent.click(moveUpButtons[1])
expect(onMoveView).toHaveBeenCalledWith('all-topics.yml', 'up')
fireEvent.click(moveDownButtons[0])
expect(onMoveView).toHaveBeenCalledWith('active-projects.yml', 'down')
})
it('shows note count chip for each view matching the filter results', () => {
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} />

View File

@@ -24,6 +24,7 @@ import {
import { useSidebarTypeInteractions } from './sidebar/useSidebarTypeInteractions'
import type { AppLocale } from '../lib/i18n'
import type { FolderFileActions } from '../hooks/useFileActions'
import type { ViewMoveDirection } from '../utils/viewOrdering'
interface SidebarProps {
entries: VaultEntry[]
@@ -43,6 +44,8 @@ interface SidebarProps {
onCreateView?: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
onReorderViews?: (orderedFilenames: string[]) => void
onMoveView?: (filename: string, direction: ViewMoveDirection) => void
folders?: FolderNode[]
onCreateFolder?: (name: string) => Promise<boolean> | boolean
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | boolean
@@ -68,6 +71,8 @@ interface SidebarNavigationProps extends Pick<
| 'onCreateView'
| 'onEditView'
| 'onDeleteView'
| 'onReorderViews'
| 'onMoveView'
| 'folders'
| 'onCreateFolder'
| 'onRenameFolder'
@@ -106,6 +111,8 @@ function SidebarNavigation({
onCreateView,
onEditView,
onDeleteView,
onReorderViews,
onMoveView,
folders = [],
onCreateFolder,
onRenameFolder,
@@ -170,6 +177,9 @@ function SidebarNavigation({
onCreateView={onCreateView}
onEditView={onEditView}
onDeleteView={onDeleteView}
onReorderViews={onReorderViews}
onMoveView={onMoveView}
sensors={sensors}
entries={entries}
locale={locale}
/>
@@ -232,6 +242,8 @@ export const Sidebar = memo(function Sidebar({
onCreateView,
onEditView,
onDeleteView,
onReorderViews,
onMoveView,
folders = [],
onCreateFolder,
onRenameFolder,
@@ -268,6 +280,7 @@ export const Sidebar = memo(function Sidebar({
const reordered = computeReorder(sectionIds, active.id as string, over.id as string)
if (reordered) onReorderSections?.(reordered.map((typeName, order) => ({ typeName, order })))
}, [sectionIds, onReorderSections])
const viewActions = { onCreateView, onEditView, onDeleteView, onReorderViews, onMoveView }
const sectionProps: SidebarSectionProps = {
entries,
@@ -291,9 +304,7 @@ export const Sidebar = memo(function Sidebar({
onSelectFavorite={onSelectFavorite}
onReorderFavorites={onReorderFavorites}
views={views}
onCreateView={onCreateView}
onEditView={onEditView}
onDeleteView={onDeleteView}
{...viewActions}
folders={folders}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}

View File

@@ -21,8 +21,10 @@ import { TypeCustomizePopover } from '../TypeCustomizePopover'
import { useDragRegion } from '../../hooks/useDragRegion'
import { SidebarGroupHeader } from './SidebarGroupHeader'
import { SidebarViewItem } from './SidebarViewItem'
import { computeReorder } from './sidebarHooks'
import { countByFilter } from '../../utils/noteListHelpers'
import { translate, type AppLocale } from '../../lib/i18n'
import { canMoveView, type ViewMoveDirection } from '../../utils/viewOrdering'
export { SidebarTopNav } from './SidebarTopNav'
export { FavoritesSection } from './FavoritesSection'
@@ -48,6 +50,9 @@ export function ViewsSection({
onCreateView,
onEditView,
onDeleteView,
onReorderViews,
onMoveView,
sensors,
entries,
locale = 'en',
}: {
@@ -59,9 +64,35 @@ export function ViewsSection({
onCreateView?: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
onReorderViews?: (orderedFilenames: string[]) => void
onMoveView?: (filename: string, direction: ViewMoveDirection) => void
sensors: ReturnType<typeof useSensors>
entries: VaultEntry[]
locale?: AppLocale
}) {
const viewIds = views.map((view) => view.filename)
const handleViewDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const reordered = computeReorder(viewIds, active.id as string, over.id as string)
if (reordered) onReorderViews?.(reordered)
}
const renderViewItem = (view: ViewFile) => (
<SidebarViewItem
key={view.filename}
view={view}
isActive={isSelectionActive(selection, { kind: 'view', filename: view.filename })}
onSelect={() => onSelect({ kind: 'view', filename: view.filename })}
onEditView={onEditView}
onDeleteView={onDeleteView}
onMoveView={onMoveView}
canMoveUp={canMoveView(views, view.filename, 'up')}
canMoveDown={canMoveView(views, view.filename, 'down')}
entries={entries}
locale={locale}
/>
)
return (
<div className="border-b border-border" style={{ padding: '0 6px' }}>
<SidebarGroupHeader label={translate(locale, 'sidebar.group.views')} collapsed={collapsed} onToggle={onToggle}>
@@ -81,24 +112,81 @@ export function ViewsSection({
</SidebarGroupHeader>
{!collapsed && (
<div style={{ paddingBottom: 4 }}>
{views.map((view) => (
<SidebarViewItem
key={view.filename}
view={view}
isActive={isSelectionActive(selection, { kind: 'view', filename: view.filename })}
onSelect={() => onSelect({ kind: 'view', filename: view.filename })}
onEditView={onEditView}
onDeleteView={onDeleteView}
entries={entries}
locale={locale}
/>
))}
{onReorderViews ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleViewDragEnd}>
<SortableContext items={viewIds} strategy={verticalListSortingStrategy}>
{views.map((view) => (
<SortableViewItem
key={view.filename}
view={view}
views={views}
selection={selection}
onSelect={onSelect}
onEditView={onEditView}
onDeleteView={onDeleteView}
onMoveView={onMoveView}
entries={entries}
locale={locale}
/>
))}
</SortableContext>
</DndContext>
) : views.map(renderViewItem)}
</div>
)}
</div>
)
}
function SortableViewItem({
view,
views,
selection,
onSelect,
onEditView,
onDeleteView,
onMoveView,
entries,
locale,
}: {
view: ViewFile
views: ViewFile[]
selection: SidebarSelection
onSelect: (selection: SidebarSelection) => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
onMoveView?: (filename: string, direction: ViewMoveDirection) => void
entries: VaultEntry[]
locale?: AppLocale
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: view.filename })
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}}
>
<SidebarViewItem
view={view}
isActive={isSelectionActive(selection, { kind: 'view', filename: view.filename })}
onSelect={() => onSelect({ kind: 'view', filename: view.filename })}
onEditView={onEditView}
onDeleteView={onDeleteView}
onMoveView={onMoveView}
canMoveUp={canMoveView(views, view.filename, 'up')}
canMoveDown={canMoveView(views, view.filename, 'down')}
dragHandleProps={{ ...attributes, ...listeners }}
entries={entries}
locale={locale}
/>
</div>
)
}
function SortableSection({
group,
sectionProps,

View File

@@ -1,11 +1,14 @@
import { useMemo } from 'react'
import { useMemo, type HTMLAttributes } from 'react'
import type { VaultEntry, ViewFile } from '../../types'
import { evaluateView } from '../../utils/viewFilters'
import { Funnel, PencilSimple, Trash } from '@phosphor-icons/react'
import { ArrowDown, ArrowUp, GripVertical } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { NoteTitleIcon } from '../NoteTitleIcon'
import { SidebarCountPill } from '../SidebarParts'
import { SIDEBAR_ITEM_PADDING } from './sidebarStyles'
import { translate, type AppLocale } from '../../lib/i18n'
import type { ViewMoveDirection } from '../../utils/viewOrdering'
interface SidebarViewItemProps {
view: ViewFile
@@ -13,6 +16,10 @@ interface SidebarViewItemProps {
onSelect: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
onMoveView?: (filename: string, direction: ViewMoveDirection) => void
canMoveUp?: boolean
canMoveDown?: boolean
dragHandleProps?: HTMLAttributes<HTMLButtonElement>
entries: VaultEntry[]
locale?: AppLocale
}
@@ -23,6 +30,10 @@ export function SidebarViewItem({
onSelect,
onEditView,
onDeleteView,
onMoveView,
canMoveUp = false,
canMoveDown = false,
dragHandleProps,
entries,
locale = 'en',
}: SidebarViewItemProps) {
@@ -39,6 +50,20 @@ export function SidebarViewItem({
style={{ padding: showCount ? SIDEBAR_ITEM_PADDING.withCount : SIDEBAR_ITEM_PADDING.regular, borderRadius: 4 }}
onClick={onSelect}
>
{dragHandleProps && (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="-ml-1 h-5 w-4 min-w-0 cursor-grab rounded p-0 text-muted-foreground hover:bg-transparent hover:text-foreground active:cursor-grabbing"
title={translate(locale, 'sidebar.action.reorderView')}
aria-label={translate(locale, 'sidebar.action.reorderView')}
onClick={(event) => event.stopPropagation()}
{...dragHandleProps}
>
<GripVertical size={12} />
</Button>
)}
{icon}
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{view.definition.name}</span>
{showCount && (
@@ -51,25 +76,59 @@ export function SidebarViewItem({
)}
</div>
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
{onMoveView && (
<>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-5 w-5 min-w-0 rounded p-0 text-muted-foreground hover:bg-transparent hover:text-foreground disabled:opacity-30"
disabled={!canMoveUp}
onClick={(event) => { event.stopPropagation(); onMoveView(view.filename, 'up') }}
title={translate(locale, 'sidebar.action.moveViewUp')}
aria-label={translate(locale, 'sidebar.action.moveViewUp')}
>
<ArrowUp size={12} />
</Button>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-5 w-5 min-w-0 rounded p-0 text-muted-foreground hover:bg-transparent hover:text-foreground disabled:opacity-30"
disabled={!canMoveDown}
onClick={(event) => { event.stopPropagation(); onMoveView(view.filename, 'down') }}
title={translate(locale, 'sidebar.action.moveViewDown')}
aria-label={translate(locale, 'sidebar.action.moveViewDown')}
>
<ArrowDown size={12} />
</Button>
</>
)}
{onEditView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-5 w-5 min-w-0 rounded p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={(event) => { event.stopPropagation(); onEditView(view.filename) }}
title={translate(locale, 'sidebar.action.editView')}
aria-label={translate(locale, 'sidebar.action.editView')}
>
<PencilSimple size={12} />
</button>
</Button>
)}
{onDeleteView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-5 w-5 min-w-0 rounded p-0 text-muted-foreground hover:bg-transparent hover:text-destructive"
onClick={(event) => { event.stopPropagation(); onDeleteView(view.filename) }}
title={translate(locale, 'sidebar.action.deleteView')}
aria-label={translate(locale, 'sidebar.action.deleteView')}
>
<Trash size={12} />
</button>
</Button>
)}
</div>
</div>