@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
JavaScript
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