feat(gallery): faceted refine panel UI (Phase 2 frontend)
Add a 'Refine ▾' toggle to the gallery filter bar that expands a full-width GalleryFacetPanel below it, inside the same sticky hazey chrome. The panel offers platform chips (with live counts + a 'No platform' unsourced bucket), two count-badged curation-flag toggles (Untagged / No artist), and a from/to date range bounded by the facet min/max. Store gains the platform/untagged/no_artist/date_from/date_to filter params (URL-mirrored, AND-composed) and a panel-gated, single-flighted loadFacets() that fetches /api/gallery/facets scoped to the active filter. Shared cloneFilter/filterToQuery helpers keep the bar and panel writing one URL format. The panel auto-opens on deep-link when refine filters are present and refetches counts (debounced) on every filter change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
|
||||
import GalleryFacetPanel from '../../src/components/gallery/GalleryFacetPanel.vue'
|
||||
import { useGalleryStore } from '../../src/stores/gallery.js'
|
||||
import { freshPinia, mountComponent } from '../support/mountComponent.js'
|
||||
|
||||
const FACETS = {
|
||||
total: 5,
|
||||
platforms: [
|
||||
{ value: 'patreon', count: 3 },
|
||||
{ value: null, count: 2 }, // unsourced → "No platform" bucket
|
||||
],
|
||||
untagged: 4,
|
||||
no_artist: 1,
|
||||
date_min: '2020-01-01T00:00:00+00:00',
|
||||
date_max: '2026-06-01T00:00:00+00:00',
|
||||
}
|
||||
|
||||
function stubFetchOk(body) {
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
ok: true, status: 200, statusText: 'OK',
|
||||
text: async () => JSON.stringify(body),
|
||||
}))
|
||||
}
|
||||
|
||||
describe('GalleryFacetPanel', () => {
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('renders platform options with counts and the curation flags', () => {
|
||||
const pinia = freshPinia()
|
||||
stubFetchOk(FACETS) // covers the onMounted loadFacets() call
|
||||
const s = useGalleryStore()
|
||||
s.facets = FACETS // seed so counts render synchronously
|
||||
const w = mountComponent(GalleryFacetPanel, { pinia })
|
||||
const t = w.text()
|
||||
expect(t).toContain('patreon')
|
||||
expect(t).toContain('3')
|
||||
expect(t).toContain('No platform')
|
||||
expect(t).toContain('Untagged')
|
||||
expect(t).toContain('4')
|
||||
expect(t).toContain('No artist')
|
||||
})
|
||||
|
||||
it('shows an en-dash placeholder for a flag count before facets load', () => {
|
||||
const pinia = freshPinia()
|
||||
stubFetchOk(FACETS)
|
||||
useGalleryStore().facets = null // not loaded yet
|
||||
const w = mountComponent(GalleryFacetPanel, { pinia })
|
||||
expect(w.text()).toContain('Untagged')
|
||||
expect(w.text()).toContain('–')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useGalleryStore } from '../src/stores/gallery.js'
|
||||
import { cloneFilter, filterToQuery, useGalleryStore } from '../src/stores/gallery.js'
|
||||
|
||||
function stubFetch(handler) {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
@@ -73,6 +73,55 @@ describe('gallery store: composable filter', () => {
|
||||
expect(s.artistLabel).toBe('Kubo')
|
||||
})
|
||||
|
||||
it('parses faceted refine params and loadMore forwards them', async () => {
|
||||
const s = useGalleryStore()
|
||||
const urls = []
|
||||
stubFetch((url) => { urls.push(url); return { status: 200, body: EMPTY } })
|
||||
await s.applyFilterFromQuery({
|
||||
platform: 'pixiv', untagged: '1', no_artist: '1',
|
||||
date_from: '2024-01-01', date_to: '2024-12-31',
|
||||
})
|
||||
expect(s.filter.platform).toBe('pixiv')
|
||||
expect(s.filter.untagged).toBe(true)
|
||||
expect(s.filter.no_artist).toBe(true)
|
||||
expect(s.filter.date_from).toBe('2024-01-01')
|
||||
expect(s.filter.date_to).toBe('2024-12-31')
|
||||
const scroll = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/scroll')))
|
||||
expect(scroll).toContain('platform=pixiv')
|
||||
expect(scroll).toContain('untagged=1')
|
||||
expect(scroll).toContain('no_artist=1')
|
||||
expect(scroll).toContain('date_from=2024-01-01')
|
||||
expect(scroll).toContain('date_to=2024-12-31')
|
||||
})
|
||||
|
||||
it('drops a malformed date in the query', async () => {
|
||||
const s = useGalleryStore()
|
||||
stubFetch(() => ({ status: 200, body: EMPTY }))
|
||||
await s.applyFilterFromQuery({ date_from: 'garbage' })
|
||||
expect(s.filter.date_from).toBe(null)
|
||||
})
|
||||
|
||||
it('loadFacets fetches counts scoped to the active filter', async () => {
|
||||
const s = useGalleryStore()
|
||||
const urls = []
|
||||
const FACETS = {
|
||||
total: 4, platforms: [{ value: 'patreon', count: 2 }],
|
||||
untagged: 1, no_artist: 0,
|
||||
date_min: '2020-01-01T00:00:00+00:00', date_max: '2026-06-01T00:00:00+00:00',
|
||||
}
|
||||
stubFetch((url) => {
|
||||
urls.push(url)
|
||||
if (url.includes('/api/gallery/facets')) return { status: 200, body: FACETS }
|
||||
return { status: 200, body: EMPTY }
|
||||
})
|
||||
await s.applyFilterFromQuery({ platform: 'patreon', untagged: '1' })
|
||||
await s.loadFacets()
|
||||
const facetsUrl = decodeURIComponent(urls.find((u) => u.includes('/api/gallery/facets')))
|
||||
expect(facetsUrl).toContain('platform=patreon')
|
||||
expect(facetsUrl).toContain('untagged=1')
|
||||
expect(s.facets.total).toBe(4)
|
||||
})
|
||||
|
||||
it('loadInitial issues exactly one scroll request at the initial limit', async () => {
|
||||
const s = useGalleryStore()
|
||||
const urls = []
|
||||
@@ -88,3 +137,34 @@ describe('gallery store: composable filter', () => {
|
||||
expect(scrollCalls[0]).toContain('limit=50')
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterToQuery / cloneFilter', () => {
|
||||
it('serializes faceted params incl. platform sentinel and flags', () => {
|
||||
const q = filterToQuery({
|
||||
tag_ids: [3], artist_id: null, media_type: null, sort: 'newest',
|
||||
platform: '__unsourced__', untagged: true, no_artist: false,
|
||||
date_from: '2024-01-01', date_to: null,
|
||||
})
|
||||
expect(q.tag_id).toBe('3')
|
||||
expect(q.platform).toBe('__unsourced__')
|
||||
expect(q.untagged).toBe('1')
|
||||
expect(q.no_artist).toBeUndefined()
|
||||
expect(q.date_from).toBe('2024-01-01')
|
||||
expect(q.date_to).toBeUndefined()
|
||||
expect(q.sort).toBeUndefined() // newest is the default → omitted
|
||||
})
|
||||
|
||||
it('cloneFilter copies refine fields and detaches tag_ids', () => {
|
||||
const orig = {
|
||||
tag_ids: [1], artist_id: 2, media_type: 'image', sort: 'oldest',
|
||||
platform: 'patreon', untagged: true, no_artist: true,
|
||||
date_from: '2024-01-01', date_to: '2024-02-01',
|
||||
}
|
||||
const c = cloneFilter(orig)
|
||||
c.tag_ids.push(9)
|
||||
expect(orig.tag_ids).toEqual([1]) // detached
|
||||
expect(c.platform).toBe('patreon')
|
||||
expect(c.untagged).toBe(true)
|
||||
expect(c.date_to).toBe('2024-02-01')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user