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
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:
co-authored by
Claude Sonnet 5
parent
3f72d714df
commit
74e40329a0
@@ -1,6 +1,8 @@
|
||||
import { QuickSearch as QuickSearch_899db48c9ce30e524aae8643dea53f6d } from '../../../../src/components/admin/QuickSearch'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
/** @type import('payload').ImportMap */
|
||||
export const importMap = {
|
||||
"./src/components/admin/QuickSearch#QuickSearch": QuickSearch_899db48c9ce30e524aae8643dea53f6d,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -14,6 +14,9 @@
|
||||
* MITHOME-110: lokalizáció bekapcsolva (hu alapértelmezett, en). A mezőnkénti
|
||||
* `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-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 { fileURLToPath } from 'url'
|
||||
@@ -40,6 +43,14 @@ export default buildConfig({
|
||||
// jelszó-politika: MITHOME-90 (src/collections/Users.ts).
|
||||
admin: {
|
||||
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(),
|
||||
|
||||
Reference in New Issue
Block a user