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'
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* 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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user