feat: implement permission-aware search baseline
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
import { Search, X } from "lucide-react";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent
|
||||
} from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import {
|
||||
useGuardedNavigate,
|
||||
usePlatformUiCapabilities,
|
||||
type GlobalSearchProps,
|
||||
type SearchContextContribution,
|
||||
type SearchContextsUiCapability
|
||||
} from "@govoplan/core-webui";
|
||||
import { search, type SearchResult } from "../api/search";
|
||||
|
||||
|
||||
const MIN_SUGGESTION_LENGTH = 2;
|
||||
|
||||
function currentContext(
|
||||
pathname: string,
|
||||
contexts: SearchContextContribution[]
|
||||
): SearchContextContribution | null {
|
||||
return [...contexts].
|
||||
filter((context) =>
|
||||
context.pathPrefixes.some((prefix) =>
|
||||
pathname === prefix || pathname.startsWith(`${prefix}/`)
|
||||
)
|
||||
).
|
||||
sort((left, right) => {
|
||||
const leftLength = Math.max(...left.pathPrefixes.map((item) => item.length));
|
||||
const rightLength = Math.max(...right.pathPrefixes.map((item) => item.length));
|
||||
return rightLength - leftLength || (left.order ?? 100) - (right.order ?? 100);
|
||||
})[0] ?? null;
|
||||
}
|
||||
|
||||
export default function GlobalSearch({ settings }: GlobalSearchProps) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const location = useLocation();
|
||||
const contextCapabilities =
|
||||
usePlatformUiCapabilities<SearchContextsUiCapability>("search.contexts");
|
||||
const contexts = useMemo(
|
||||
() => contextCapabilities.flatMap((capability) => capability.contexts),
|
||||
[contextCapabilities]
|
||||
);
|
||||
const context = useMemo(
|
||||
() => currentContext(location.pathname, contexts),
|
||||
[contexts, location.pathname]
|
||||
);
|
||||
const [value, setValue] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function focusSearch(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", focusSearch);
|
||||
return () => window.removeEventListener("keydown", focusSearch);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function closeOutside(event: MouseEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
window.addEventListener("mousedown", closeOutside);
|
||||
return () => window.removeEventListener("mousedown", closeOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const query = value.trim();
|
||||
if (query.length < MIN_SUGGESTION_LENGTH) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => {
|
||||
search(
|
||||
settings,
|
||||
{
|
||||
query,
|
||||
modules: context ? [context.moduleId] : undefined,
|
||||
resourceTypes: context?.resourceTypes,
|
||||
contextKind: context ? "module" : "global",
|
||||
contextId: context?.id,
|
||||
limit: 6
|
||||
},
|
||||
controller.signal
|
||||
).
|
||||
then((response) => {
|
||||
setResults(response.results);
|
||||
setOpen(true);
|
||||
setActiveIndex(-1);
|
||||
}).
|
||||
catch((error) => {
|
||||
if ((error as Error).name !== "AbortError") {
|
||||
setResults([]);
|
||||
}
|
||||
});
|
||||
}, 220);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [context, settings, value]);
|
||||
|
||||
function openResult(result: SearchResult) {
|
||||
setOpen(false);
|
||||
setValue("");
|
||||
navigate(result.url);
|
||||
}
|
||||
|
||||
function openResultsPage() {
|
||||
const query = value.trim();
|
||||
if (!query) return;
|
||||
const params = new URLSearchParams({ q: query });
|
||||
if (context) {
|
||||
params.set("module", context.moduleId);
|
||||
params.set("context", context.id);
|
||||
for (const resourceType of context.resourceTypes ?? []) {
|
||||
params.append("resource_type", resourceType);
|
||||
}
|
||||
}
|
||||
setOpen(false);
|
||||
navigate(`/search?${params.toString()}`);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown" && results.length) {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
setActiveIndex((current) => (current + 1) % results.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp" && results.length) {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
setActiveIndex((current) =>
|
||||
current <= 0 ? results.length - 1 : current - 1
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (activeIndex >= 0 && results[activeIndex]) {
|
||||
openResult(results[activeIndex]);
|
||||
} else {
|
||||
openResultsPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="global-search" ref={rootRef}>
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={value}
|
||||
placeholder={context?.placeholder ?? "Search"}
|
||||
aria-label={context ? `Search ${context.label}` : "Search"}
|
||||
aria-expanded={open}
|
||||
aria-controls="global-search-results"
|
||||
onFocus={() => setOpen(results.length > 0)}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{value &&
|
||||
<button
|
||||
type="button"
|
||||
className="global-search-clear"
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
setValue("");
|
||||
setResults([]);
|
||||
inputRef.current?.focus();
|
||||
}}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
}
|
||||
{open && results.length > 0 &&
|
||||
<div id="global-search-results" className="global-search-results" role="listbox">
|
||||
{results.map((result, index) =>
|
||||
<button
|
||||
key={`${result.module_id}:${result.resource_type}:${result.resource_id}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
className={index === activeIndex ? "active" : ""}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => openResult(result)}>
|
||||
<span className="global-search-result-title">{result.title}</span>
|
||||
<span>{result.module_id} · {result.resource_type}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="global-search-all"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={openResultsPage}>
|
||||
<Search size={14} />
|
||||
<span>All results</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user