feat(cms): admin quick-search across all content (MITHOME-120)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s

Adds a search box to the top of every Payload admin page
(admin.components.header) that searches by text across every field of
every Global and Collection, in both locales — something Payload has
no built-in equivalent for: collection list views only search their
own title/slug fields, and Globals have no list view at all.

Implementation is deliberately client-side and index-free rather than
@payloadcms/plugin-search (a server-side search collection kept in
sync via hooks): this project's entire content is 5 Globals + 2 small
Collections, so a plugin-managed search index would be disproportionate
maintenance for the actual data volume — the same reasoning already
applied to the logo editor (MITHOME-118). On first use, the component
fetches every Global/Collection doc in both locales via the existing
REST API (same-origin, admin session cookie), recursively flattens
every field to (path, value) pairs client-side, and filters by
case-insensitive substring as the user types. Each result links
straight to the right edit view (global or collection/id).

New: src/components/admin/QuickSearch.tsx. Registered via
payload.config.ts admin.components.header, which required a
generate:importmap run — the useful gotcha this surfaced: Payload
resolves component paths against admin.importMap.baseDir, which
defaults to process.cwd() (the proto/ the CLI is run from), not
dirname(payload.config.ts) — so the path needed to be
'./src/components/admin/QuickSearch#QuickSearch', not
'./components/admin/QuickSearch#QuickSearch'. Documented inline.

Verified live in the browser (not just tsc/lint): search finds matches
in both a Global (Home hero.cta.secondary.text / services description)
and a Collection (Partners name/url), shows source + field path +
locale + a snippet per result, clicking a result navigates to the
correct edit view, and the search state persists across client-side
admin navigation since the header component doesn't remount. Zero
console errors in a fresh tab. Full gate green: tsc, lint, unit tests
(51 passed), production build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Do Siki
2026-09-12 00:03:13 +02:00
co-authored by Claude Sonnet 5
parent 3f72d714df
commit 74e40329a0
3 changed files with 293 additions and 0 deletions
@@ -1,6 +1,8 @@
import { QuickSearch as QuickSearch_899db48c9ce30e524aae8643dea53f6d } from '../../../../src/components/admin/QuickSearch'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').ImportMap */ /** @type import('payload').ImportMap */
export const importMap = { export const importMap = {
"./src/components/admin/QuickSearch#QuickSearch": QuickSearch_899db48c9ce30e524aae8643dea53f6d,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
} }
+280
View File
@@ -0,0 +1,280 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
/**
* MITHOME-120 — teljes szöveges gyorskeresés a Payload admin tetején.
*
* WHY kliens-oldali, egyszerű megoldás és nem `@payloadcms/plugin-search`:
* az a plugin egy külön "search" collection-t tart karban hookokkal
* szinkronban — szerver-oldali index, extra karbantartási teher. Ennek a
* projektnek 5 Global + 2 kis Collection a teljes tartalma (lásd
* payload.config.ts) — ennyi dokumentumnál egyszerűbb és megbízhatóbb
* minden alkalommal frissen lekérdezni a REST API-t (a bejelentkezett admin
* session-jével, cookie-alapú auth, nincs külön hitelesítési logika itt),
* kliens-oldalon szöveges mezőkre lapítani, és substring-alapján szűrni.
*
* Regisztrálva: payload.config.ts `admin.components.header` — minden admin
* oldalon megjelenik. Az importMap.js-t a `payload generate:importmap`
* generálja újra, ha ez a fájl elmozdul/átnevezik.
*/
type Locale = 'hu' | 'en'
const LOCALES: Locale[] = ['hu', 'en']
type SearchTarget =
| { type: 'global'; slug: string; label: string }
| { type: 'collection'; slug: string; label: string }
// A projekt tényleges Globals/Collections listája (payload.config.ts) —
// szándékosan nincs dinamikusan introspektálva, mert ahhoz szerver-oldali
// config-hozzáférés kellene ebből a kliens komponensből.
const SEARCH_TARGETS: SearchTarget[] = [
{ type: 'global', slug: 'home', label: 'Home' },
{ type: 'global', slug: 'about', label: 'About' },
{ type: 'global', slug: 'services', label: 'Services' },
{ type: 'global', slug: 'contact', label: 'Contact' },
{ type: 'global', slug: 'common', label: 'Common' },
{ type: 'collection', slug: 'legal-pages', label: 'Legal Pages' },
{ type: 'collection', slug: 'partners', label: 'Partners' },
]
// Payload belső/rendszer mezői — nem érdekesek szöveges keresésre, és csak
// zajt jelentenének (id-k, időbélyegek, belső flag-ek).
const SKIP_KEYS = new Set([
'id',
'_id',
'createdAt',
'updatedAt',
'globalType',
'blockType',
'_status',
'sizes',
])
type SearchEntry = {
key: string
source: string
editHref: string
locale: Locale
fieldPath: string
value: string
}
function flatten(
value: unknown,
path: string,
out: { fieldPath: string; value: string }[]
): void {
if (value == null) return
if (typeof value === 'string') {
if (value.trim().length > 0) out.push({ fieldPath: path, value })
return
}
if (typeof value === 'number' || typeof value === 'boolean') return
if (Array.isArray(value)) {
value.forEach((item, index) => flatten(item, `${path}[${index}]`, out))
return
}
if (typeof value === 'object') {
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
if (SKIP_KEYS.has(key)) continue
flatten(val, path ? `${path}.${key}` : key, out)
}
}
}
async function fetchIndex(): Promise<SearchEntry[]> {
const entries: SearchEntry[] = []
await Promise.all(
SEARCH_TARGETS.flatMap((target) =>
LOCALES.map(async (locale) => {
try {
if (target.type === 'global') {
const res = await fetch(`/api/globals/${target.slug}?locale=${locale}&depth=0`, {
credentials: 'include',
})
if (!res.ok) return
const doc = await res.json()
const flat: { fieldPath: string; value: string }[] = []
flatten(doc, '', flat)
for (const f of flat) {
entries.push({
key: `${target.slug}:${locale}:${f.fieldPath}`,
source: target.label,
editHref: `/admin/globals/${target.slug}`,
locale,
fieldPath: f.fieldPath,
value: f.value,
})
}
} else {
const res = await fetch(
`/api/${target.slug}?locale=${locale}&depth=0&limit=200`,
{ credentials: 'include' }
)
if (!res.ok) return
const { docs } = await res.json()
for (const doc of docs ?? []) {
const flat: { fieldPath: string; value: string }[] = []
flatten(doc, '', flat)
for (const f of flat) {
entries.push({
key: `${target.slug}:${doc.id}:${locale}:${f.fieldPath}`,
source: `${target.label}${doc.name ?? doc.title ?? doc.id}`,
editHref: `/admin/collections/${target.slug}/${doc.id}`,
locale,
fieldPath: f.fieldPath,
value: f.value,
})
}
}
}
} catch {
// Egy célpont hibája (pl. időleges hálózati hiba) ne akassza meg a
// többi találatot — csendben kihagyjuk.
}
})
)
)
return entries
}
function snippetAround(value: string, query: string, radius = 40): string {
const idx = value.toLowerCase().indexOf(query.toLowerCase())
if (idx === -1) return value.length > 80 ? `${value.slice(0, 80)}` : value
const start = Math.max(0, idx - radius)
const end = Math.min(value.length, idx + query.length + radius)
const prefix = start > 0 ? '…' : ''
const suffix = end < value.length ? '…' : ''
return `${prefix}${value.slice(start, end)}${suffix}`
}
export function QuickSearch() {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
// WHY state és nem ref: a keresési index közvetlenül a render kimenetét
// (a találati listát) befolyásolja, tehát a react-hooks/refs szabály
// szerint is state-nek kell lennie, nem ref-nek (ref olvasása render
// közben nem váltana ki újra-renderelést, ha közben módosulna).
const [index, setIndex] = useState<SearchEntry[] | null>(null)
// Csak azt jelzi, hogy a fetch elindult-e már — ez NEM befolyásolja a
// render kimenetét, csak elkerüli a duplikált egyidejű lekérdezést, ezért
// maradhat ref.
const fetchStartedRef = useRef(false)
const containerRef = useRef<HTMLDivElement>(null)
const ensureIndex = useCallback(async () => {
if (fetchStartedRef.current) return
fetchStartedRef.current = true
setLoading(true)
try {
const entries = await fetchIndex()
setIndex(entries)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [])
const results =
query.trim().length >= 2 && index
? index.filter((e) => e.value.toLowerCase().includes(query.toLowerCase())).slice(0, 40)
: []
return (
<div
ref={containerRef}
style={{
position: 'relative',
maxWidth: 480,
margin: '0 auto',
padding: '10px 16px',
}}
>
<input
type="search"
value={query}
placeholder="🔍 Keresés a teljes tartalomban… (min. 2 karakter)"
onFocus={() => {
setOpen(true)
void ensureIndex()
}}
onChange={(e) => {
setQuery(e.target.value)
setOpen(true)
void ensureIndex()
}}
style={{
width: '100%',
boxSizing: 'border-box',
padding: '8px 12px',
fontSize: 14,
borderRadius: 4,
border: '1px solid var(--theme-elevation-150, #ccc)',
background: 'var(--theme-input-bg, #fff)',
color: 'var(--theme-text, #000)',
}}
/>
{open && query.trim().length >= 2 && (
<div
style={{
position: 'absolute',
top: '100%',
left: 16,
right: 16,
marginTop: 4,
maxHeight: 420,
overflowY: 'auto',
background: 'var(--theme-elevation-0, #fff)',
border: '1px solid var(--theme-elevation-150, #ccc)',
borderRadius: 4,
boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
zIndex: 100,
}}
>
{loading && !index && (
<div style={{ padding: 12, fontSize: 13, opacity: 0.7 }}>Tartalom betöltése</div>
)}
{index && results.length === 0 && (
<div style={{ padding: 12, fontSize: 13, opacity: 0.7 }}>Nincs találat.</div>
)}
{results.map((r) => (
<Link
key={r.key}
href={r.editHref}
onClick={() => setOpen(false)}
style={{
display: 'block',
padding: '8px 12px',
borderBottom: '1px solid var(--theme-elevation-100, #eee)',
textDecoration: 'none',
color: 'inherit',
}}
>
<div style={{ fontSize: 12, opacity: 0.65 }}>
{r.source} · {r.fieldPath} · {r.locale}
</div>
<div style={{ fontSize: 14 }}>{snippetAround(r.value, query)}</div>
</Link>
))}
</div>
)}
</div>
)
}
export default QuickSearch
+11
View File
@@ -14,6 +14,9 @@
* MITHOME-110: lokalizáció bekapcsolva (hu alapértelmezett, en). A mezőnkénti * MITHOME-110: lokalizáció bekapcsolva (hu alapértelmezett, en). A mezőnkénti
* `localized: true` retrofit a Globals/LegalPages configokon külön ticket * `localized: true` retrofit a Globals/LegalPages configokon külön ticket
* (MITHOME-111/112) — ez a ticket csak magát a mechanizmust kapcsolja be. * (MITHOME-111/112) — ez a ticket csak magát a mechanizmust kapcsolja be.
* MITHOME-120: admin gyorskeresés (QuickSearch) az admin.components.header
* slotba regisztrálva — teljes szöveges keresés minden Global/Collection
* mezőjében, mindkét locale-ban.
*/ */
import path from 'path' import path from 'path'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
@@ -40,6 +43,14 @@ export default buildConfig({
// jelszó-politika: MITHOME-90 (src/collections/Users.ts). // jelszó-politika: MITHOME-90 (src/collections/Users.ts).
admin: { admin: {
user: Users.slug, user: Users.slug,
components: {
// MITHOME-120: gyorskeresés minden admin oldal tetején.
// WHY 'src/...' és nem './components/...': a Payload komponens-útvonal
// az admin.importMap.baseDir-hez relatív, ami alapértelmezésben
// process.cwd() (a `generate:importmap` a proto/ mappából fut, NEM a
// payload.config.ts mappájából).
header: ['./src/components/admin/QuickSearch#QuickSearch'],
},
}, },
editor: lexicalEditor(), editor: lexicalEditor(),