fix: inbox sidebar ordering, filter chip wrapping, and editor banner architecture

- Move Inbox to first position in sidebar (before All Notes)
- Add whitespace-nowrap to filter pills + flex-wrap on container so chips
  wrap as whole units instead of breaking text internally
- Editor now reads trashed/archived state from fresh vault entries instead
  of potentially stale tab entry, ensuring banners appear regardless of
  navigation context
- Update tests to pass correct entries prop alongside tabs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-20 22:36:59 +01:00
parent e84d21d36d
commit e74d4c8392
6 changed files with 47 additions and 18 deletions

View File

@@ -300,7 +300,7 @@ describe('Editor', () => {
const trashedTab = { entry: trashedEntry, content: mockContent }
function renderTrashed(overrides: Partial<Parameters<typeof Editor>[0]> = {}) {
return render(<Editor {...defaultProps} tabs={[trashedTab]} activeTabPath={trashedEntry.path} {...overrides} />)
return render(<Editor {...defaultProps} entries={[trashedEntry]} tabs={[trashedTab]} activeTabPath={trashedEntry.path} {...overrides} />)
}
it('shows banner and read-only editor when note is trashed', () => {
@@ -336,9 +336,10 @@ describe('Editor', () => {
)
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
const updatedTab = { entry: { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }, content: mockContent }
const trashedEntryUpdated = { ...mockEntry, trashed: true, trashedAt: Date.now() / 1000 }
const updatedTab = { entry: trashedEntryUpdated, content: mockContent }
rerender(
<Editor {...defaultProps} tabs={[updatedTab]} activeTabPath={mockEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
<Editor {...defaultProps} entries={[trashedEntryUpdated]} tabs={[updatedTab]} activeTabPath={mockEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
)
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'false')
@@ -346,13 +347,14 @@ describe('Editor', () => {
it('removes trash banner immediately when entry is restored (reactive)', () => {
const { rerender } = render(
<Editor {...defaultProps} tabs={[trashedTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
<Editor {...defaultProps} entries={[trashedEntry]} tabs={[trashedTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
)
expect(screen.getByTestId('trashed-note-banner')).toBeInTheDocument()
const restoredTab = { entry: { ...trashedEntry, trashed: false, trashedAt: null }, content: mockContent }
const restoredEntry = { ...trashedEntry, trashed: false, trashedAt: null }
const restoredTab = { entry: restoredEntry, content: mockContent }
rerender(
<Editor {...defaultProps} tabs={[restoredTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
<Editor {...defaultProps} entries={[restoredEntry]} tabs={[restoredTab]} activeTabPath={trashedEntry.path} onRestoreNote={vi.fn()} onDeleteNote={vi.fn()} />
)
expect(screen.queryByTestId('trashed-note-banner')).not.toBeInTheDocument()
expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true')
@@ -408,6 +410,7 @@ describe('click empty editor space', () => {
render(
<Editor
{...defaultProps}
entries={[trashedEntry]}
tabs={[{ entry: trashedEntry, content: mockContent }]}
activeTabPath={trashedEntry.path}
/>
@@ -429,9 +432,10 @@ describe('archived note behavior', () => {
)
expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument()
const archivedTab = { entry: { ...mockEntry, archived: true }, content: mockContent }
const archivedEntry = { ...mockEntry, archived: true }
const archivedTab = { entry: archivedEntry, content: mockContent }
rerender(
<Editor {...defaultProps} tabs={[archivedTab]} activeTabPath={mockEntry.path} onUnarchiveNote={vi.fn()} />
<Editor {...defaultProps} entries={[archivedEntry]} tabs={[archivedTab]} activeTabPath={mockEntry.path} onUnarchiveNote={vi.fn()} />
)
expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument()
})
@@ -440,13 +444,14 @@ describe('archived note behavior', () => {
const archivedEntry: VaultEntry = { ...mockEntry, archived: true }
const archivedTab = { entry: archivedEntry, content: mockContent }
const { rerender } = render(
<Editor {...defaultProps} tabs={[archivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
<Editor {...defaultProps} entries={[archivedEntry]} tabs={[archivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
)
expect(screen.getByTestId('archived-note-banner')).toBeInTheDocument()
const unarchivedTab = { entry: { ...archivedEntry, archived: false }, content: mockContent }
const unarchivedEntry = { ...archivedEntry, archived: false }
const unarchivedTab = { entry: unarchivedEntry, content: mockContent }
rerender(
<Editor {...defaultProps} tabs={[unarchivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
<Editor {...defaultProps} entries={[unarchivedEntry]} tabs={[unarchivedTab]} activeTabPath={archivedEntry.path} onUnarchiveNote={vi.fn()} />
)
expect(screen.queryByTestId('archived-note-banner')).not.toBeInTheDocument()
})

View File

@@ -161,7 +161,11 @@ export function EditorContent({
isConflicted, onKeepMine, onKeepTheirs,
...breadcrumbProps
}: EditorContentProps) {
const isTrashed = activeTab?.entry.trashed ?? false
// Look up trashed/archived from the latest vault entries, not the tab snapshot,
// so the banner appears regardless of navigation context.
const freshEntry = activeTab ? entries.find(e => e.path === activeTab.entry.path) : undefined
const isTrashed = freshEntry?.trashed ?? activeTab?.entry.trashed ?? false
const isArchived = freshEntry?.archived ?? activeTab?.entry.archived ?? false
const showEditor = !diffMode && !rawMode
const entryIcon = activeTab?.entry.icon ?? null
const emojiIcon = entryIcon && isEmoji(entryIcon) ? entryIcon : null
@@ -188,7 +192,7 @@ export function EditorContent({
onDeletePermanently={() => onDeleteNote?.(activeTab.entry.path)}
/>
)}
{activeTab?.entry.archived && breadcrumbProps.onUnarchiveNote && (
{activeTab && isArchived && breadcrumbProps.onUnarchiveNote && (
<ArchivedNoteBanner onUnarchive={() => breadcrumbProps.onUnarchiveNote!(activeTab.entry.path)} />
)}
{activeTab && isConflicted && (

View File

@@ -1038,4 +1038,24 @@ describe('Sidebar', () => {
const mondaySections = screen.getAllByText(/Monday Ideas/i)
expect(mondaySections).toHaveLength(1)
})
it('renders Inbox as the first item in the top nav', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} inboxCount={5} />)
const topNav = screen.getByTestId('sidebar-top-nav')
const items = topNav.children
expect(items[0].textContent).toContain('Inbox')
expect(items[1].textContent).toContain('All Notes')
})
it('displays inbox count badge', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} inboxCount={12} />)
expect(screen.getByText('12')).toBeInTheDocument()
})
it('calls onSelect with inbox filter when clicking Inbox', () => {
const onSelect = vi.fn()
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={onSelect} inboxCount={3} />)
fireEvent.click(screen.getByText('Inbox'))
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'inbox' })
})
})

View File

@@ -306,10 +306,10 @@ export const Sidebar = memo(function Sidebar({
<nav className="flex-1 overflow-y-auto">
{/* Top nav */}
<div className="border-b border-border" data-testid="sidebar-top-nav" style={{ padding: '4px 6px' }}>
<NavItem icon={Tray} label="Inbox" count={inboxCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'inbox' })} />
<NavItem icon={FileText} label="All Notes" count={activeCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })} badgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'all' })} />
<NavItem icon={Archive} label="Archive" count={archivedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'archived' })} />
<NavItem icon={Trash} label="Trash" count={trashedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'trash' })} activeClassName="bg-destructive/10 text-destructive" badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
<NavItem icon={Tray} label="Inbox" count={inboxCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'inbox' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'inbox' })} />
</div>
{/* Sections header + visibility popover */}

View File

@@ -15,14 +15,14 @@ const PILLS: { value: NoteListFilter; label: string }[] = [
function FilterPillsInner({ active, counts, onChange }: FilterPillsProps) {
return (
<div className="flex h-[45px] shrink-0 items-center gap-1 border-b border-border px-4" data-testid="filter-pills">
<div className="flex h-auto min-h-[45px] shrink-0 flex-wrap items-center gap-1 border-b border-border px-4 py-1.5" data-testid="filter-pills">
{PILLS.map(({ value, label }) => (
<button
key={value}
type="button"
role="tab"
aria-selected={active === value}
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
className={`inline-flex whitespace-nowrap items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
active === value
? 'border-foreground/20 bg-foreground/10 text-foreground'
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'

View File

@@ -16,14 +16,14 @@ const PILLS: { value: InboxPeriod; label: string }[] = [
function InboxFilterPillsInner({ active, counts, onChange }: InboxFilterPillsProps) {
return (
<div className="flex h-[45px] shrink-0 items-center gap-1 border-b border-border px-4" data-testid="inbox-filter-pills">
<div className="flex h-auto min-h-[45px] shrink-0 flex-wrap items-center gap-1 border-b border-border px-4 py-1.5" data-testid="inbox-filter-pills">
{PILLS.map(({ value, label }) => (
<button
key={value}
type="button"
role="tab"
aria-selected={active === value}
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
className={`inline-flex whitespace-nowrap items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
active === value
? 'border-foreground/20 bg-foreground/10 text-foreground'
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'