Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 31 additions & 4 deletions frontend/src/components/FilterBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ function lastParams(): ListAssetsParams {
return calls.length > 0 ? (calls[calls.length - 1][0] ?? {}) : {}
}

/** Pick a tag through the autocomplete, the only way to add a tag filter. */
async function addTag(user: ReturnType<typeof userEvent.setup>, name: string) {
await user.click(await screen.findByRole('combobox', { name: 'Filter by tag' }))
await user.click(await screen.findByRole('option', { name: new RegExp(name) }))
}

beforeEach(() => {
list = vi
.spyOn(assetsApi, 'list')
Expand Down Expand Up @@ -64,19 +70,40 @@ describe('FilterBar', () => {
const user = userEvent.setup()
render(<FilterBar />)

await user.click(await screen.findByRole('button', { name: /NATO/ }))
await addTag(user, 'NATO')
await waitFor(() => expect(lastParams().tag).toEqual(['NATO']))

await user.click(screen.getByRole('button', { name: /archive/ }))
await addTag(user, 'archive')
await waitFor(() => expect(lastParams().tag).toEqual(['NATO', 'archive']))
})

it('narrows the tag menu as you type, and Enter picks the highlighted tag', async () => {
const user = userEvent.setup()
render(<FilterBar />)

const input = await screen.findByRole('combobox', { name: 'Filter by tag' })
await user.type(input, 'arch')
expect(screen.queryByRole('option', { name: /NATO/ })).not.toBeInTheDocument()

await user.keyboard('{Enter}')
await waitFor(() => expect(lastParams().tag).toEqual(['archive']))
})

it('never commits free text that is not an existing tag', async () => {
const user = userEvent.setup()
render(<FilterBar />)

const input = await screen.findByRole('combobox', { name: 'Filter by tag' })
await user.type(input, 'nonexistent{Enter}')
expect(lastParams().tag).toBeUndefined()
})

it('clears one filter from its chip without touching the others', async () => {
const user = userEvent.setup()
render(<FilterBar />)

await user.click(screen.getByRole('button', { name: 'Video' }))
await user.click(await screen.findByRole('button', { name: /NATO/ }))
await addTag(user, 'NATO')
await waitFor(() => expect(lastParams().tag).toEqual(['NATO']))

await user.click(screen.getByRole('button', { name: 'Clear NATO' }))
Expand All @@ -90,7 +117,7 @@ describe('FilterBar', () => {
render(<FilterBar />)

await user.click(screen.getByRole('button', { name: 'Video' }))
await user.click(await screen.findByRole('button', { name: /NATO/ }))
await addTag(user, 'NATO')
await waitFor(() => expect(lastParams().tag).toEqual(['NATO']))

await user.click(screen.getByRole('button', { name: 'Clear all' }))
Expand Down
19 changes: 3 additions & 16 deletions frontend/src/components/FilterBar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react'
import { Search, SlidersHorizontal, X } from 'lucide-react'
import { buildCategoryTree, type CategoryNode } from '@/api/tags'
import TypeFilterChips, { Chip } from '@/components/TypeFilterChips'
import TagFilterInput from '@/components/TagFilterInput'
import TypeFilterChips from '@/components/TypeFilterChips'
import { useLibraryStore } from '@/stores/library'
import { useTagStore } from '@/stores/tags'
import { formatDuration } from '@/utils/format'
Expand Down Expand Up @@ -188,21 +189,7 @@ export default function FilterBar() {
</div>

{tags.length > 0 && (
<div className="flex flex-wrap items-center gap-1">
<span className="mr-1 text-xs text-gray-500 dark:text-gray-400">Tags</span>
{tags.slice(0, 12).map((tag) => (
<Chip
key={tag.id}
active={tagFilter.some((t) => t.toLowerCase() === tag.name.toLowerCase())}
onClick={() => toggleTag(tag.name)}
>
{tag.name}
{tag.asset_count !== undefined && (
<span className="ml-1 tabular-nums opacity-60">{tag.asset_count}</span>
)}
</Chip>
))}
</div>
<TagFilterInput tags={tags} selected={tagFilter} onPick={toggleTag} />
)}

{showMore && (
Expand Down
121 changes: 121 additions & 0 deletions frontend/src/components/TagFilterInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { useMemo, useState } from 'react'
import type { Tag } from '@/api/tags'

/**
* An autocomplete for the tag *filter*.
*
* This replaced a row of chips cut off at the first dozen tags, which made every tag
* past the cut unfilterable-by-click. It differs from `TagInput` on purpose: that one
* creates tags by name, while a filter on a name that does not exist can only ever show
* an empty grid — so here Enter picks from the menu and free text is never committed.
* Chosen tags are shown by FilterBar's active-chip row, so they are only hidden from
* the menu rather than rendered again here.
*/

interface Props {
tags: Tag[]
/** Names currently filtered on; compared case-insensitively, as the server does. */
selected: string[]
onPick: (name: string) => void
}

// The menu scrolls, so this is a guard against rendering thousands of rows, not the
// limit the old chip row was.
const MAX_OPTIONS = 50

const LISTBOX_ID = 'tag-filter-suggestions'

export default function TagFilterInput({ tags, selected, onPick }: Props) {
const [draft, setDraft] = useState('')
const [open, setOpen] = useState(false)
const [active, setActive] = useState(0)

const matches = useMemo(() => {
const term = draft.trim().toLowerCase()
const chosen = new Set(selected.map((name) => name.toLowerCase()))
return tags
.filter((tag) => !chosen.has(tag.name.toLowerCase()))
.filter((tag) => !term || tag.name.toLowerCase().includes(term))
.slice(0, MAX_OPTIONS)
}, [tags, selected, draft])

const pick = (name: string) => {
onPick(name)
setDraft('')
setActive(0)
}

const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
setOpen(true)
setActive((i) => Math.min(i + 1, matches.length - 1))
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setActive((i) => Math.max(i - 1, 0))
} else if (event.key === 'Enter') {
event.preventDefault()
const match = matches[active]
if (open && match) pick(match.name)
} else if (event.key === 'Escape') {
setOpen(false)
}
}

return (
<div className="relative w-64 max-w-full">
<input
role="combobox"
aria-expanded={open && matches.length > 0}
aria-controls={LISTBOX_ID}
aria-autocomplete="list"
aria-label="Filter by tag"
className="input py-1 text-xs"
placeholder="Filter by tag…"
value={draft}
onChange={(e) => {
setDraft(e.target.value)
setOpen(true)
setActive(0)
}}
onFocus={() => setOpen(true)}
// A timeout, not a plain close: clicking an option blurs the input first, and
// unmounting the menu synchronously would eat the click.
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
onKeyDown={onKeyDown}
/>

{open && matches.length > 0 && (
<ul
id={LISTBOX_ID}
role="listbox"
className="absolute left-0 top-full z-20 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800"
>
{matches.map((tag, index) => (
<li key={tag.id}>
<button
type="button"
role="option"
aria-selected={index === active}
onMouseEnter={() => setActive(index)}
onClick={() => pick(tag.name)}
className={`flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-xs ${
index === active
? 'bg-blue-50 text-blue-900 dark:bg-blue-900/40 dark:text-blue-100'
: 'text-gray-700 dark:text-gray-200'
}`}
>
<span className="truncate">{tag.name}</span>
{tag.asset_count !== undefined && (
<span className="shrink-0 text-[11px] tabular-nums text-gray-400">
{tag.asset_count}
</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
)
}
42 changes: 42 additions & 0 deletions frontend/src/views/LibraryView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -633,3 +633,45 @@ describe('LibraryView generations (M8)', () => {
expect(screen.getByRole('form', { name: 'Generate an image' })).toBeVisible()
})
})

describe('LibraryView paging', () => {
afterEach(() => {
vi.unstubAllGlobals()
})

it('fetches the next page once the first has rendered', async () => {
/**
* The sentinel is not in the DOM while the first page loads, so an observer attached
* only on mount never saw it and the library stopped at page one. This observer
* reports intersecting as soon as it is given something to watch, which is what a
* real one does for an element already on screen.
*/
class VisibleObserver {
constructor(private readonly callback: IntersectionObserverCallback) {}
observe() {
this.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
this as unknown as IntersectionObserver
)
}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('IntersectionObserver', VisibleObserver)

const list = vi.spyOn(assetsApi, 'list').mockImplementation(async (params) => {
const offset = params?.offset ?? 0
return {
data: offset === 0 ? ASSETS.slice(0, 2) : ASSETS.slice(2),
total: ASSETS.length,
limit: 60,
offset,
}
})

render(<LibraryView />)

expect(await screen.findByText('Third')).toBeInTheDocument()
expect(list).toHaveBeenCalledWith(expect.objectContaining({ offset: 2 }))
})
})
11 changes: 10 additions & 1 deletion frontend/src/views/LibraryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ export default function LibraryView() {

// Infinite scroll. An observer rather than a scroll handler: it does not fire on
// every pixel, and it keeps working if the grid is put in a different container.
//
// Keyed on `loading` and the loaded count, not just `loadMore`. The sentinel only
// exists once the first page is in, so an effect that ran once on mount found no
// element and never attached — the grid stopped at page one for good. Re-attaching
// after each page matters for the same reason: an observer reports on *changes* in
// visibility, so a sentinel that is still on screen after a page lands (a tall
// window, small cards) would never ask for the next one. A fresh observer always
// reports its initial state.
const loadedCount = assets.length
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return
Expand All @@ -82,7 +91,7 @@ export default function LibraryView() {
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [loadMore])
}, [loadMore, loading, loadedCount])

// A URL import or a generation finishes in the background, so nothing else would put
// what it made in the grid. Keyed on seeing a job go from running to finished rather
Expand Down
Loading