UNPKG

@kit-data-manager/react-search-component

Version:

All-in-one component for rendering an elastic search UI for searching anything. Built-in support for visualizing related items in a graph and resolving unique identifiers.

203 lines 14.8 kB
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { RelationsGraphContext } from "../../components/graph/RelationsGraphContext"; import { ReactSearchComponentContext } from "../../components/ReactSearchComponentContext"; import { useStore } from "zustand"; import { resultCache } from "../../lib/ResultCache"; import { DateTime } from "luxon"; import { ChevronDown, Download, GitFork, LoaderCircle, Microscope, Pencil, PlusIcon, SearchIcon, TableProperties } from "lucide-react"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../../components/ui/dropdown-menu"; import { GenericResultViewTag } from "../../components/result/GenericResultViewTag"; import * as z from "zod/mini"; import { GenericResultViewImage } from "../../components/result/GenericResultViewImage"; import { GraphNodeUtils } from "../../components/graph/GraphNodeUtils"; import { Dialog, DialogContent, DialogTitle } from "../../components/ui/dialog"; import { PidComponent } from "@kit-data-manager/react-pid-component"; import { autoUnwrap, autoUnwrapArray, fieldOptionsToArray, injectMeta, toArray } from "../../lib/utils"; import { relatedItemsQuery } from "../../lib/queries"; const HTTP_REGEX = /https?:\/\/.*/; /** * Configurable result view component that can be customized for specific use cases. Will display a card for each search result from elastic. If this component * doesn't fit your needs, feel free to implement your own result view. */ export function GenericResultView({ result, titleField = "name", descriptionField = "description", imageField, invertImageInDarkMode = false, landingPageLocationField = "landingPageLocation", digitalObjectLocationField = "digitalObjectLocation", pidField = "pid", childItemPidField = "isMetadataFor", parentItemPidField = "hasMetadata", creationDateField = "creationDate", editedDateField = "editedDate", additionalIdentifierField = "identifier", relatedItemsPrefetchOptions = { searchFields: { pid: {} } }, tags = [], showOpenInFairDoScope = true, showInspectFDO = true }) { const { openOrAddToRelationsGraph } = useContext(RelationsGraphContext); const { searchTerm, searchFor, config } = useContext(ReactSearchComponentContext); const addToResultCache = useStore(resultCache, (s) => s.set); const [loadingRelatedItems, setLoadingRelatedItems] = useState(false); const [showInspectDialog, setShowInspectDialog] = useState(false); const getField = useCallback((field) => { try { const value = autoUnwrap(z.optional(z.union([z.string(), z.number(), z.object({ raw: z.union([z.string(), z.number()]) })])).parse(result[field])); return value ? value + "" : undefined; } catch (e) { console.warn(`Parsing field ${field} failed`, e); return undefined; } }, [result]); const getArrayField = useCallback((field) => { try { const value = autoUnwrapArray(z .optional(z.union([ z.array(z.string()), z.array(z.number()), z.object({ raw: z.array(z.number()) }), z.object({ raw: z.array(z.string()) }) ])) .parse(result[field])); return value ? value.map((v) => v + "") : []; } catch (e) { console.warn(`Parsing array field ${field} failed`, e); return []; } }, [result]); const getArrayOrSingleField = useCallback((field) => { const _field = result[field]; if (Array.isArray(_field) || (typeof _field === "object" && _field && "raw" in _field && Array.isArray(_field.raw))) { return getArrayField(field); } else { return getField(field); } }, [getArrayField, getField, result]); const extractPid = useCallback((pidFieldValue) => { if (pidFieldValue.startsWith("https://")) { return /https:\/\/.*?\/(.*)/.exec(pidFieldValue)?.[1] ?? pidFieldValue; } else { return pidFieldValue; } }, []); const pid = useMemo(() => { const _pid = getField(pidField ?? "pid"); return _pid ? extractPid(_pid) : _pid; }, [extractPid, getField, pidField]); const title = useMemo(() => { const maybeArray = getArrayOrSingleField(titleField ?? "name"); return Array.isArray(maybeArray) ? maybeArray.join(", ") : maybeArray; }, [getArrayOrSingleField, titleField]); const description = useMemo(() => { const maybeArray = getArrayOrSingleField(descriptionField ?? "description"); return Array.isArray(maybeArray) ? maybeArray.join("\n\r") : maybeArray; }, [descriptionField, getArrayOrSingleField]); const doLocation = useMemo(() => { const value = getField(digitalObjectLocationField ?? "digitalObjectLocation"); if (!value) return undefined; if (HTTP_REGEX.test(value)) return value; else return `https://doi.org/${value}`; }, [digitalObjectLocationField, getField]); const landingPageLocation = useMemo(() => { return getField(landingPageLocationField ?? "landingPageLocation"); }, [getField, landingPageLocationField]); const previewImage = useMemo(() => { const images = getArrayOrSingleField(imageField ?? "imageURL"); const normalized = Array.isArray(images) ? (images.length === 1 ? images[0] : images) : images; if (config.imageProxy && normalized) { return Array.isArray(normalized) ? normalized.map(config.imageProxy) : config.imageProxy(normalized); } else return normalized; }, [config, getArrayOrSingleField, imageField]); const identifier = useMemo(() => { const maybeArray = getArrayOrSingleField(additionalIdentifierField ?? "identifier"); return Array.isArray(maybeArray) ? maybeArray.join(" - ") : maybeArray; }, [getArrayOrSingleField, additionalIdentifierField]); const isMetadataFor = useMemo(() => { const val = getArrayOrSingleField(childItemPidField ?? "isMetadataFor"); return val ? toArray(val) : undefined; }, [getArrayOrSingleField, childItemPidField]); const creationDate = useMemo(() => { const value = getField(creationDateField ?? "dateCreated"); if (!value) return undefined; const dateTime = DateTime.fromISO(value); return dateTime.isValid ? dateTime.toLocaleString() : value; }, [creationDateField, getField]); const editedDate = useMemo(() => { const value = getField(editedDateField ?? "editedDate"); if (!value) return undefined; const dateTime = DateTime.fromISO(value); return dateTime.isValid ? dateTime.toLocaleString() : value; }, [editedDateField, getField]); const hasMetadata = useMemo(() => { const val = getArrayOrSingleField(parentItemPidField ?? "hasMetadata"); return val ? toArray(val) : undefined; }, [getArrayOrSingleField, parentItemPidField]); const fetchRelatedItems = useCallback(async (term, amount) => { try { const search = await relatedItemsQuery(config, { amount, term, index: config.indices.map((i) => i.name), searchFields: relatedItemsPrefetchOptions.searchFields ? fieldOptionsToArray(Object.keys(relatedItemsPrefetchOptions.searchFields)) : undefined, pidField }); if (search.hits.hits) { for (const entry of search.hits.hits) { if (entry._source) { const fields = entry._source; const pid = autoUnwrap(fields[pidField ?? "pid"]); if (!pid) continue; injectMeta(fields, entry._index); addToResultCache(pid, fields); } else { console.error("Got empty hit from elastic", entry); } } } } catch (e) { console.warn("Failed to fetch related items", e); alert("Failed to fetch related items, graph may be incomplete"); } }, [addToResultCache, config, pidField, relatedItemsPrefetchOptions.searchFields]); const showRelatedItemsGraph = useCallback(async () => { if (!pid) return; setLoadingRelatedItems(true); if (isMetadataFor) await fetchRelatedItems(isMetadataFor.join(" "), isMetadataFor.length); if (hasMetadata) await fetchRelatedItems(hasMetadata.join(" "), hasMetadata.length); setLoadingRelatedItems(false); const nodes = GraphNodeUtils.buildSequentialGraphFromIds("result", hasMetadata ?? [], pid, isMetadataFor ?? []); openOrAddToRelationsGraph(nodes, { focusedNodes: [pid] }); }, [fetchRelatedItems, hasMetadata, isMetadataFor, openOrAddToRelationsGraph, pid]); const showRelatedItemsButton = useMemo(() => { return (hasMetadata && hasMetadata.length > 0) || (isMetadataFor && isMetadataFor.length > 0); }, [hasMetadata, isMetadataFor]); const relatedItemsAmount = useMemo(() => { return (hasMetadata ? hasMetadata.length : 0) + (isMetadataFor ? isMetadataFor.length : 0); }, [hasMetadata, isMetadataFor]); const exactPidMatch = useMemo(() => { return searchTerm === pid || searchTerm === doLocation; }, [doLocation, pid, searchTerm]); const searchForThis = useCallback(() => { if (!pid) return; setTimeout(() => { searchFor(pid); }, 100); }, [pid, searchFor]); useEffect(() => { if (pid) { addToResultCache(pid, result); } }, [addToResultCache, pid, result]); return (_jsxs("div", { className: `rfs:m-2 rfs:rounded-lg rfs:border rfs:border-border rfs:bg-background rfs:p-4 rfs:group/result-view ${exactPidMatch ? "rfs:outline-primary rfs:outline" : ""}`, children: [_jsxs("div", { className: `rfs:grid ${imageField ? "rfs:grid-rows-[150px_1fr] rfs:md:grid-cols-[200px_1fr] rfs:md:grid-rows-1" : ""} rfs:gap-4 rfs:overflow-x-auto rfs:md:max-w-full`, children: [imageField && _jsx(GenericResultViewImage, { previewImage: previewImage, title: title, invertImageInDarkMode: invertImageInDarkMode }), _jsxs("div", { className: "rfs:flex rfs:flex-col rfs:overflow-x-auto rfs:md:max-w-full", children: [exactPidMatch && (_jsx("div", { className: "rfs:mb-2", children: _jsx(Badge, { children: "Exact Match" }) })), _jsxs("div", { children: [_jsx("div", { className: "rfs:font-bold rfs:md:text-xl rfs:mr-2", children: title }), _jsxs("div", { className: "rfs:flex rfs:text-sm rfs:font-normal rfs:text-muted-foreground rfs:gap-3", children: [identifier, creationDate && (_jsxs("div", { className: "rfs:flex rfs:items-center", children: [_jsx(PlusIcon, { className: "rfs:size-3 rfs:mr-0.5" }), " ", creationDate] })), editedDate && (_jsxs("div", { className: "rfs:flex rfs:items-center", children: [_jsx(Pencil, { className: "rfs:size-3 rfs:mr-0.5" }), " ", editedDate] }))] })] }), _jsx("a", { href: `https://hdl.handle.net/${pid}?noredirect`, target: "_blank", className: "rfs:mb-2 rfs:block rfs:leading-3 rfs:hover:underline", children: _jsx("span", { className: "rfs:text-sm rfs:text-muted-foreground", children: pid }) }), _jsx("div", { className: "rfs:flex rfs:flex-wrap rfs:gap-2 rfs:truncate rfs:items-center", children: tags && tags.map((tag, i) => _jsx(GenericResultViewTag, { result: result, ...tag }, i)) }), _jsx("div", { className: "rfs:grow", children: description }), _jsxs("div", { className: "rfs:mt-8 rfs:flex rfs:flex-col rfs:flex-wrap rfs:justify-end rfs:gap-2 rfs:md:flex-row rfs:md:items-center rfs:md:gap-4", children: [showRelatedItemsButton && (_jsxs("div", { className: "rfs:flex rfs:items-center", children: [_jsxs(Button, { className: "rfs:grow rfs:rounded-r-none", disabled: loadingRelatedItems, size: "sm", variant: "secondary", onClick: showRelatedItemsGraph, children: [loadingRelatedItems ? (_jsx(LoaderCircle, { className: "rfs:mr-1 rfs:size-4 rfs:animate-spin" })) : (_jsx(GitFork, { className: "rfs:mr-1 rfs:size-4" })), "Show Related Items"] }), _jsx(Button, { className: "rfs:rounded-l-none rfs:border-l rfs:border-l-border rfs:text-xs rfs:font-bold", size: "sm", variant: "secondary", onClick: showRelatedItemsGraph, children: relatedItemsAmount })] })), landingPageLocation && (_jsxs("div", { className: "rfs:flex rfs:items-center", children: [_jsx("a", { href: landingPageLocation, target: "_blank", className: "rfs:grow", children: _jsx(Button, { size: "sm", className: "rfs:w-full rfs:rounded-r-none rfs:px-4", children: "Open" }) }), _jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { size: "sm", className: "rfs:rounded-l-none rfs:border-l rfs:border-l-border", children: _jsx(ChevronDown, { className: "rfs:mr-1 rfs:size-4" }) }) }), _jsxs(DropdownMenuContent, { children: [_jsx("a", { href: doLocation, target: "_blank", children: _jsxs(DropdownMenuItem, { children: [_jsx(Download, { className: "rfs:mr-1 rfs:size-4" }), " Download Digital Object"] }) }), _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onClick: searchForThis, children: [_jsx(SearchIcon, { className: "rfs:mr-1 rfs:size-4" }), " Search for this"] }), showOpenInFairDoScope && (_jsx("a", { href: `https://kit-data-manager.github.io/fairdoscope/?pid=${pid}`, target: "_blank", children: _jsxs(DropdownMenuItem, { children: [_jsx(Microscope, { className: "rfs:mr-1 rfs:size-4" }), " Open in FAIR-DOscope"] }) })), showInspectFDO && (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onClick: () => setShowInspectDialog(true), children: [_jsx(TableProperties, { className: "rfs:size-4 rfs:mr-1" }), " Inspect FDO"] })] }))] })] })] }))] })] })] }), showInspectFDO && (_jsx(Dialog, { open: showInspectDialog, onOpenChange: setShowInspectDialog, children: _jsxs(DialogContent, { className: "rfs:max-w-[calc(100vw-40px)]! rfs:min-w-[min(1200px,calc(100vw-40px))]!", children: [_jsx(DialogTitle, { children: "Inspect Result" }), _jsx("div", { className: "rfs:overflow-auto", children: _jsx(PidComponent, { openByDefault: true, value: pid, levelOfSubcomponents: 10 }) })] }) }))] })); } //# sourceMappingURL=GenericResultView.js.map