From 8d1e8377c13d2fb0d56a1c5e707df9bd0ea92358 Mon Sep 17 00:00:00 2001 From: PlusA2M Date: Sat, 13 Jun 2026 10:35:41 -0400 Subject: [PATCH] fix(media): make the media-selector search work without nesting panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /admin/media/selector had two defects: the search input had an `id` but no `name`, so `hx-include="[name='search']"` never sent the term; and the endpoint always returned the full panel (search box + grid), while the input targets the inner `#media-selector-grid` — so each keystroke swapped a whole new panel into the grid, nesting one per keystroke. Add `name="search"` to the input and return a grid-only fragment for the HTMX search request (HX-Target: media-selector-grid), keeping the full panel for the initial modal load. Also re-query on the search input's native clear button. Co-Authored-By: Claude Opus 4.8 Signed-off-by: PlusA2M --- .../routes/admin-media-selector.test.ts | 119 ++++++++++++++++++ packages/core/src/routes/admin-media.ts | 60 +++++---- 2 files changed, 156 insertions(+), 23 deletions(-) create mode 100644 packages/core/src/__tests__/routes/admin-media-selector.test.ts diff --git a/packages/core/src/__tests__/routes/admin-media-selector.test.ts b/packages/core/src/__tests__/routes/admin-media-selector.test.ts new file mode 100644 index 000000000..253454109 --- /dev/null +++ b/packages/core/src/__tests__/routes/admin-media-selector.test.ts @@ -0,0 +1,119 @@ +/** + * Regression test: media selector search fragment + * + * The "Select Media" picker (GET /admin/media/selector) had two defects: + * 1. its search input had an `id` but no `name`, while it used + * `hx-include="[name='search']"` — so the typed term was never sent; and + * 2. the endpoint always returned the full panel (search box + grid), but the + * input's `hx-target` is the inner grid — so every keystroke swapped a + * whole new panel *into* the grid, nesting one panel per keystroke. + * + * The endpoint now returns the full panel only on the initial modal load and a + * grid-only fragment for HTMX search requests (HX-Target: media-selector-grid), + * and the input carries `name="search"`. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Hono } from 'hono' + +vi.mock('../../middleware', () => ({ + requireAuth: () => async (c: any, next: any) => { + c.set('user', { userId: 'u1', id: 'u1', email: 'admin@test.com', role: 'admin' }) + await next() + }, + requireRole: () => async (_c: any, next: any) => { + await next() + }, +})) + +import { adminMediaRoutes } from '../../routes/admin-media' + +function makeRow(i: number) { + return { + id: `m${i}`, + filename: `file-${i}.png`, + original_name: `File ${i}.png`, + mime_type: 'image/png', + size: 1234, + r2_key: `uploads/file-${i}.png`, + alt: null, + tags: null, + uploaded_at: 1_700_000_000_000, + } +} + +function createMockEnv(rows = [makeRow(0), makeRow(1)]) { + const queryLog: { sql: string; params: unknown[] }[] = [] + const db = { + prepare: vi.fn((sql: string) => { + const statement: any = { + bind: vi.fn((...params: unknown[]) => { + queryLog.push({ sql, params }) + return statement + }), + all: vi.fn(async () => ({ results: rows })), + } + return statement + }), + } + return { env: { DB: db, KV: {} }, queryLog } +} + +describe('GET /admin/media/selector', () => { + let app: Hono + + beforeEach(() => { + vi.clearAllMocks() + app = new Hono() + app.route('/admin/media', adminMediaRoutes) + }) + + it('initial load returns the full panel with a NAMED search input', async () => { + const { env } = createMockEnv() + const res = await app.fetch(new Request('https://test.com/admin/media/selector'), env as any) + expect(res.status).toBe(200) + const html = await res.text() + + // The input must have a name so hx-include actually sends the term. + expect(html).toMatch(/]*name="search"/) + // Full panel includes the grid container the search targets. + expect(html).toContain('id="media-selector-grid"') + expect(html).toContain('data-media-id="m0"') + }) + + it('HTMX search request returns ONLY the grid fragment (no nested panel)', async () => { + const { env, queryLog } = createMockEnv() + const res = await app.fetch( + new Request('https://test.com/admin/media/selector?search=file', { + headers: { 'HX-Target': 'media-selector-grid' }, + }), + env as any + ) + expect(res.status).toBe(200) + const html = await res.text() + + // Cards are present... + expect(html).toContain('data-media-id="m0"') + // ...but NOT a second search box or grid container (would nest on keystroke). + expect(html).not.toContain(' q.sql.includes('FROM media')) + expect(listQuery?.params).toEqual(['%file%', '%file%', '%file%']) + }) + + it('empty HTMX search returns the empty-state inside the grid (no input)', async () => { + const { env } = createMockEnv([]) + const res = await app.fetch( + new Request('https://test.com/admin/media/selector?search=zzz', { + headers: { 'HX-Target': 'media-selector-grid' }, + }), + env as any + ) + const html = await res.text() + expect(html).toContain('No media files found') + expect(html).toContain('col-span-full') + expect(html).not.toContain(' { isDocument: !row.mime_type.startsWith('image/') && !row.mime_type.startsWith('video/') })) - // Render media selector grid - return c.html(html` -
- -
- -
- ${raw(mediaFiles.map(file => ` + // Build the cards once; the same markup serves both the full panel (the + // initial modal load) and the grid-only fragment (HTMX search requests). + const cardsHtml = mediaFiles.map(file => `
{

- `).join(''))} - + `).join('') - ${mediaFiles.length === 0 ? html` -
+ const gridInner = mediaFiles.length === 0 + ? ` +

No media files found

-
- ` : ''} +
` + : cardsHtml + + // On the HTMX search request the input targets #media-selector-grid, so + // respond with only the inner cards. Returning the whole panel here (search + // box + grid) would nest a fresh panel inside the grid on every keystroke, + // and the search input had no `name`, so the typed term was never sent. + if (c.req.header('HX-Target') === 'media-selector-grid') { + return c.html(raw(gridInner)) + } + + // Initial modal load: full panel (search box + grid container). + return c.html(html` +
+ +
+ +
+ ${raw(gridInner)} +
`) } catch (error) { console.error('Error loading media selector:', error)