Skip to content
Open
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
119 changes: 119 additions & 0 deletions packages/core/src/__tests__/routes/admin-media-selector.test.ts
Original file line number Diff line number Diff line change
@@ -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(/<input[^>]*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('<input')
expect(html).not.toContain('id="media-selector-grid"')

// The search term was bound into the query.
const listQuery = queryLog.find((q) => 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('<input')
})
})
60 changes: 37 additions & 23 deletions packages/core/src/routes/admin-media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,23 +209,9 @@ adminMediaRoutes.get('/selector', async (c) => {
isDocument: !row.mime_type.startsWith('image/') && !row.mime_type.startsWith('video/')
}))

// Render media selector grid
return c.html(html`
<div class="mb-4">
<input
type="search"
id="media-selector-search"
placeholder="Search files..."
class="w-full rounded-lg bg-white dark:bg-zinc-800 px-4 py-2 text-sm text-zinc-950 dark:text-white shadow-sm ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-950 dark:focus:ring-white transition-shadow"
hx-get="/admin/media/selector"
hx-trigger="keyup changed delay:300ms"
hx-target="#media-selector-grid"
hx-include="[name='search']"
>
</div>

<div id="media-selector-grid" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4 max-h-96 overflow-y-auto">
${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 => `
<div
class="relative group cursor-pointer rounded-lg overflow-hidden bg-zinc-50 dark:bg-zinc-800 shadow-sm hover:shadow-md transition-shadow"
data-media-id="${file.id}"
Expand Down Expand Up @@ -275,17 +261,45 @@ adminMediaRoutes.get('/selector', async (c) => {
</p>
</div>
</div>
`).join(''))}
</div>
`).join('')

${mediaFiles.length === 0 ? html`
<div class="text-center py-12 text-zinc-500 dark:text-zinc-400">
const gridInner = mediaFiles.length === 0
? `
<div class="col-span-full text-center py-12 text-zinc-500 dark:text-zinc-400">
<svg class="mx-auto h-12 w-12 text-zinc-400 dark:text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
<p class="mt-2">No media files found</p>
</div>
` : ''}
</div>`
: 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`
<div class="mb-4">
<input
type="search"
id="media-selector-search"
name="search"
placeholder="Search files..."
class="w-full rounded-lg bg-white dark:bg-zinc-800 px-4 py-2 text-sm text-zinc-950 dark:text-white shadow-sm ring-1 ring-inset ring-zinc-950/10 dark:ring-white/10 placeholder:text-zinc-400 dark:placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-zinc-950 dark:focus:ring-white transition-shadow"
hx-get="/admin/media/selector"
hx-trigger="keyup changed delay:300ms, search"
hx-target="#media-selector-grid"
hx-include="[name='search']"
>
</div>

<div id="media-selector-grid" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4 max-h-96 overflow-y-auto">
${raw(gridInner)}
</div>
`)
} catch (error) {
console.error('Error loading media selector:', error)
Expand Down