blob: d9dcde363144da17edaf907ab71413f4cbd9a8ff [file] [log] [blame]
/*
This example requires Tailwind CSS v3.0+
This example requires some changes to your config:
```
// tailwind.config.js
module.exports = {
// ...
plugins: [
// ...
require('@tailwindcss/forms'),
],
}
```
*/
import React, {
Fragment,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { Combobox, Dialog, Transition } from "@headlessui/react";
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import {
ExclamationTriangleIcon,
LifebuoyIcon,
} from "@heroicons/react/24/outline";
import { Link, useNavigate } from "react-router-dom";
import Fuse from "fuse.js";
import {
SearchableItem,
extractSearchables,
getSearchableCategories,
} from "./searchables";
import {
DAGTemplateWithData,
Project,
useAllProjects,
useCatalogView,
useDAGTemplatesByID,
} from "../../../state/api/friendlyApi";
import { CatalogResponse } from "../../../state/api/backendApiRaw";
import { useURLParams } from "../../../state/urlState";
import { skipToken } from "@reduxjs/toolkit/dist/query";
function classNames(...classes: string[]) {
return classes.filter(Boolean).join(" ");
}
type BaseSearchBarProps = {
open: boolean;
setOpen: (open: boolean) => void;
};
type SearchBarProps = BaseSearchBarProps & {
projectId: number | undefined;
allProjects: Project[] | undefined;
currentProjectVersions: DAGTemplateWithData[] | undefined;
catalogData: CatalogResponse | undefined;
};
export const SearchBarWithData: React.FC<SearchBarProps> = (props) => {
const [rawQuery, setRawQuery] = useState("");
// initSearchableState();
// const projects = useProjects({});
const navigate = useNavigate();
const handleKeyPress = useCallback((event: KeyboardEvent) => {
if (event.metaKey && event.key === "k") {
props.setOpen(true);
event.preventDefault();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const searchableCategories = getSearchableCategories();
// if we haven't selected a project yet, we can't search through anything but the basic windows available
const searchableItems = extractSearchables(
props.projectId,
props.allProjects,
props.currentProjectVersions,
props.catalogData
);
const fuse = useMemo(
() =>
new Fuse(searchableItems, {
keys: ["searchableText"],
threshold: 0.3,
}),
[searchableItems]
);
useEffect(() => {
// attach the event listener
document.addEventListener("keydown", handleKeyPress);
// remove the event listener
return () => {
document.removeEventListener("keydown", handleKeyPress);
};
}, [handleKeyPress]);
let query = rawQuery;
searchableCategories.forEach(({ shortcut }) => {
query = query.startsWith(shortcut) ? query.slice(1, query.length) : query;
});
query = query.trim();
// const query = rawQuery.replace(/^[#>]/, "");
// Current categories are the categories that match the first character of the query or
// all categories if the query is empty/its first one does not match the current one...
const matchedCategories = searchableCategories.find(
(item) => item.shortcut == rawQuery[0]
);
const filteredCategories = new Set(
(matchedCategories ? [matchedCategories] : searchableCategories).map(
(item) => item.name
)
);
let searchResults = fuse.search(query);
if (query.trim().length === 0) {
searchResults = searchableItems.map(
(item) =>
({
item,
refIndex: 0,
score: 0,
} as Fuse.FuseResult<SearchableItem>)
);
}
const filteredResults = new Map<string, SearchableItem[]>();
searchResults
.filter(({ item }) => filteredCategories.has(item.category.name))
.forEach((result) => {
const { item } = result;
const { category } = item;
if (!filteredResults.has(category.name)) {
filteredResults.set(category.name, []);
}
filteredResults.get(category.name)?.push(item);
});
const anyResults = searchResults.length > 0;
return (
<Transition.Root
show={props.open}
as={Fragment}
afterLeave={() => setRawQuery("")}
appear
>
<Dialog as="div" className="relative z-50" onClose={props.setOpen}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="mx-auto max-w-xl transform divide-y divide-gray-100 overflow-hidden rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
<Combobox
onChange={(item: SearchableItem) => {
navigate(item.href);
props.setOpen(false);
}}
>
<div className="relative">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-400"
aria-hidden="true"
/>
<Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-800 placeholder-gray-400 focus:ring-0 sm:text-sm"
placeholder="Search..."
onChange={(event) => setRawQuery(event.target.value)}
/>
</div>
{anyResults && (
<Combobox.Options
static
className="max-h-80 scroll-py-10 scroll-pb-2 space-y-4 overflow-y-auto p-4 pb-2"
>
{Array.from(filteredResults.entries()).map(
([category, items], index) => {
return (
items.length > 0 && (
<li key={index}>
<h2 className="text-xs font-semibold text-gray-900">
{category}
</h2>
<ul className="-mx-4 mt-2 text-sm text-gray-700">
{items.map((item, index) => (
<Link to={item.href} key={index}>
<Combobox.Option
onKeyUp={(event: React.KeyboardEvent) => {
if (event.key === "Enter") {
navigate(item.href);
event.preventDefault();
}
}}
key={`${item.name}-${index}-${category}`}
value={item}
className={({ active }) =>
classNames(
"flex cursor-default select-none items-center px-4 py-2",
active
? "bg-indigo-600 text-white"
: ""
)
}
>
{({ active }) => (
<>
<item.icon
key={`${index}-icon`}
className={classNames(
"h-6 w-6 flex-none",
active
? "text-white"
: "text-gray-400"
)}
aria-hidden="true"
/>
<span
className="ml-3 flex-auto truncate"
key={`${index}-span`}
>
{item.name}
</span>
</>
)}
</Combobox.Option>
</Link>
))}
</ul>
</li>
)
);
}
)}
</Combobox.Options>
)}
{rawQuery === "?" && (
<div className="py-14 px-6 text-center text-sm sm:px-14">
<LifebuoyIcon
className="mx-auto h-6 w-6 text-gray-400"
aria-hidden="true"
/>
<p className="mt-4 font-semibold text-gray-900">
Help with searching
</p>
<p className="mt-2 text-gray-500">
Search across your projects, nodes, functions, and
DAGWorks offerings.
</p>
</div>
)}
{query !== "" && rawQuery !== "?" && !anyResults && (
<div className="py-14 px-6 text-center text-sm sm:px-14">
<ExclamationTriangleIcon
className="mx-auto h-6 w-6 text-gray-400"
aria-hidden="true"
/>
<p className="mt-4 font-semibold text-gray-900">
No results found
</p>
<p className="mt-2 text-gray-500">
We couldnt find anything with that term. Please try
again.
</p>
</div>
)}
<div className="flex flex-wrap items-center bg-gray-50 py-2.5 px-4 text-xs text-gray-700 gap-0.5">
<>
Type {""}
{searchableCategories.map((category, index) => {
return (
<>
<kbd
key={index}
className={classNames(
"mx-0.5 flex h-5 w-5 items-center justify-center rounded border bg-white font-semibold",
rawQuery.startsWith(category.shortcut)
? "border-indigo-600 text-indigo-600"
: "border-gray-400 text-gray-900"
)}
>
{category.shortcut}
</kbd>
<span className="inline" key={`${index}-span`}>
for {category.name}
{", "}
</span>
</>
);
})}
<kbd
className={classNames(
"mx-0.5 flex h-5 w-5 items-center justify-center rounded border bg-white font-semibold",
rawQuery.startsWith("?")
? "border-indigo-600 text-indigo-600"
: "border-gray-400 text-gray-900"
)}
>
{"?"}
</kbd>
<span className="inline">for help.</span>
</>
</div>
</Combobox>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
};
/**
* Search bar.
* Currently this fetches its own data, but we might want to share the data from the project
* so we don't worry about potential inneficiencies due to caching when loading, especially for the more
* complex queries of data.
*
* TODO -- unify these queries so SearchBar is passed stuff rather than querying it directly...
* Option 1 (worse, but expedient) -- just unify these functions so we can call them, perhaps in friendlyAPI
* Option 2 (better) -- pass the data in from the project
* @param props
* @returns
*/
export const SearchBar: React.FC<BaseSearchBarProps> = (props) => {
const { projectId, versionIds } = useURLParams();
const allProjectsResult = useAllProjects({
limit: 1000,
offset: 0,
attributeTypes: "documentation_loom", // We don't really need this, but I'm keeping it there for caching...
});
// Parameters for caching
const dagTemplateVersions = useDAGTemplatesByID(
versionIds !== undefined
? {
dagTemplateIds: versionIds.join(","),
}
: skipToken
);
// Parameters for caching
const catalogData = useCatalogView(
projectId !== undefined ? { projectId: projectId, limit: 10000 } : skipToken
);
return (
<SearchBarWithData
open={props.open}
setOpen={props.setOpen}
projectId={projectId}
allProjects={allProjectsResult.data} // TODO -- surface errors in some way?
currentProjectVersions={dagTemplateVersions.currentData}
catalogData={catalogData.data}
/>
);
};