Implement JSON search through fuse.js + web worker + react hook
This commit is contained in:
+2
-1
@@ -10,4 +10,5 @@ node_modules
|
||||
/dist
|
||||
.mf
|
||||
/meta.json
|
||||
/stats.html
|
||||
/stats.html
|
||||
public/entry.worker.js
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useJsonSearchApi, useJsonSearchState } from "~/hooks/useJsonSearch";
|
||||
|
||||
export function SearchPalette() {
|
||||
const searchState = useJsonSearchState();
|
||||
const searchApi = useJsonSearchApi();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label>Search json</label>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchState.query ?? ""}
|
||||
onChange={(e) => searchApi.search(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<ul>
|
||||
{searchState.results?.map((result) => (
|
||||
<li key={result.item.path}>
|
||||
[{result.item.path}] {result.item.formattedValue}
|
||||
{" - "}
|
||||
{result.item.rawValue}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/// <reference lib="WebWorker" />
|
||||
|
||||
import Fuse from "fuse.js";
|
||||
import { createSearchIndex, JsonSearchEntry } from "./utilities/search";
|
||||
|
||||
type SearchWorker = {
|
||||
entries?: Array<JsonSearchEntry>;
|
||||
index?: Fuse.FuseIndex<JsonSearchEntry>;
|
||||
fuse?: Fuse<JsonSearchEntry>;
|
||||
};
|
||||
|
||||
export type {};
|
||||
declare let self: DedicatedWorkerGlobalScope & SearchWorker;
|
||||
|
||||
type InitializeIndexEvent = {
|
||||
type: "initialize-index";
|
||||
payload: { json: unknown; fuseOptions: Fuse.IFuseOptions<JsonSearchEntry> };
|
||||
};
|
||||
|
||||
type SearchEvent = {
|
||||
type: "search";
|
||||
payload: { query: string };
|
||||
};
|
||||
|
||||
type SearchWorkerEvent = InitializeIndexEvent | SearchEvent;
|
||||
|
||||
self.onmessage = (e: MessageEvent<SearchWorkerEvent>) => {
|
||||
const { type, payload } = e.data;
|
||||
|
||||
console.group(`SearchWorker: ${type}`);
|
||||
console.log(payload);
|
||||
console.groupEnd();
|
||||
|
||||
switch (type) {
|
||||
case "initialize-index": {
|
||||
const { json, fuseOptions } = payload;
|
||||
|
||||
const [index, entries] = createSearchIndex(json);
|
||||
|
||||
self.entries = entries;
|
||||
self.index = index;
|
||||
self.fuse = new Fuse(entries, fuseOptions, index);
|
||||
|
||||
self.postMessage({ type: "index-initialized" });
|
||||
|
||||
break;
|
||||
}
|
||||
case "search": {
|
||||
const { query } = payload;
|
||||
|
||||
if (!self.fuse) {
|
||||
throw new Error("Search index not initialized");
|
||||
}
|
||||
|
||||
const results = self.fuse.search(query);
|
||||
|
||||
self.postMessage({ type: "search-results", payload: { results } });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useJson } from "./useJson";
|
||||
import Fuse from "fuse.js";
|
||||
import { JsonSearchEntry } from "~/utilities/search";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
export type InitializeIndexEvent = {
|
||||
type: "initialize-index";
|
||||
payload: { json: unknown; fuseOptions: Fuse.IFuseOptions<JsonSearchEntry> };
|
||||
};
|
||||
|
||||
export type SearchEvent = {
|
||||
type: "search";
|
||||
payload: { query: string };
|
||||
};
|
||||
|
||||
export type SearchSendWorkerEvent = InitializeIndexEvent | SearchEvent;
|
||||
|
||||
export type IndexInitializedEvent = {
|
||||
type: "index-initialized";
|
||||
};
|
||||
|
||||
export type SearchResultsEvent = {
|
||||
type: "search-results";
|
||||
payload: { results: Fuse.FuseResult<JsonSearchEntry>[] };
|
||||
};
|
||||
|
||||
export type SearchReceiveWorkerEvent =
|
||||
| IndexInitializedEvent
|
||||
| SearchResultsEvent;
|
||||
|
||||
export type JsonSearchApi = {
|
||||
search: (query: string) => void;
|
||||
};
|
||||
|
||||
const JsonSearchStateContext = createContext<JsonSearchState>(
|
||||
{} as JsonSearchState
|
||||
);
|
||||
|
||||
const JsonSearchApiContext = createContext<JsonSearchApi>({} as JsonSearchApi);
|
||||
|
||||
export type JsonSearchState = {
|
||||
status: "initializing" | "idle" | "searching";
|
||||
query?: string;
|
||||
results?: Fuse.FuseResult<JsonSearchEntry>[];
|
||||
};
|
||||
|
||||
type SearchAction = {
|
||||
type: "search";
|
||||
payload: { query: string };
|
||||
};
|
||||
|
||||
type JsonSearchAction = SearchReceiveWorkerEvent | SearchAction;
|
||||
|
||||
function reducer(
|
||||
state: JsonSearchState,
|
||||
action: JsonSearchAction
|
||||
): JsonSearchState {
|
||||
switch (action.type) {
|
||||
case "index-initialized":
|
||||
return {
|
||||
...state,
|
||||
status: "idle",
|
||||
results: undefined,
|
||||
};
|
||||
case "search-results":
|
||||
return {
|
||||
...state,
|
||||
status: "idle",
|
||||
results: action.payload.results,
|
||||
};
|
||||
case "search":
|
||||
return {
|
||||
...state,
|
||||
status: "searching",
|
||||
query: action.payload.query,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function JsonSearchProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [json] = useJson();
|
||||
|
||||
const [state, dispatch] = useReducer<
|
||||
React.Reducer<JsonSearchState, JsonSearchAction>
|
||||
>(reducer, { status: "initializing" });
|
||||
|
||||
const search = useCallback(
|
||||
(query: string) => {
|
||||
dispatch({ type: "search", payload: { query } });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleWorkerMessage = useCallback(
|
||||
(e: MessageEvent<SearchReceiveWorkerEvent>) => dispatch(e.data),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const workerRef = useRef<Worker | null>();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.Worker === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (workerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const worker = new Worker("/entry.worker.js");
|
||||
worker.onmessage = handleWorkerMessage;
|
||||
|
||||
workerRef.current = worker;
|
||||
|
||||
workerRef.current.postMessage({
|
||||
type: "initialize-index",
|
||||
payload: {
|
||||
json,
|
||||
fuseOptions: {
|
||||
includeScore: true,
|
||||
includeMatches: true,
|
||||
minMatchCharLength: 1,
|
||||
isCaseSensitive: false,
|
||||
threshold: 0.6,
|
||||
distance: 200,
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [json, workerRef.current]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status !== "searching") {
|
||||
return;
|
||||
}
|
||||
|
||||
workerRef.current?.postMessage({
|
||||
type: "search",
|
||||
payload: { query: state.query },
|
||||
});
|
||||
}, [state.status, workerRef.current]);
|
||||
|
||||
return (
|
||||
<JsonSearchStateContext.Provider value={state}>
|
||||
<JsonSearchApiContext.Provider value={{ search }}>
|
||||
{children}
|
||||
</JsonSearchApiContext.Provider>
|
||||
</JsonSearchStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useJsonSearchState(): JsonSearchState {
|
||||
return useContext(JsonSearchStateContext);
|
||||
}
|
||||
|
||||
export function useJsonSearchApi(): JsonSearchApi {
|
||||
return useContext(JsonSearchApiContext);
|
||||
}
|
||||
+27
-24
@@ -21,6 +21,7 @@ import { JsonSchemaProvider } from "~/hooks/useJsonSchema";
|
||||
import { JsonView } from "~/components/JsonView";
|
||||
import safeFetch from "~/utilities/safeFetch";
|
||||
import { JsonTreeViewProvider } from "~/hooks/useJsonTree";
|
||||
import { JsonSearchProvider } from "~/hooks/useJsonSearch";
|
||||
|
||||
export const loader: LoaderFunction = async ({ params, request }) => {
|
||||
invariant(params.id, "expected params.id");
|
||||
@@ -114,34 +115,36 @@ export default function JsonDocumentRoute() {
|
||||
<JsonProvider initialJson={loaderData.json}>
|
||||
<JsonSchemaProvider>
|
||||
<JsonColumnViewProvider>
|
||||
<JsonTreeViewProvider overscan={25}>
|
||||
<div>
|
||||
<div className="h-screen flex flex-col">
|
||||
<Header />
|
||||
<div className="bg-slate-50 flex-grow transition dark:bg-slate-900">
|
||||
<div className="main-container flex justify-items-stretch h-full">
|
||||
<SideBar />
|
||||
<JsonView>
|
||||
<Outlet />
|
||||
</JsonView>
|
||||
<JsonSearchProvider>
|
||||
<JsonTreeViewProvider overscan={25}>
|
||||
<div>
|
||||
<div className="h-screen flex flex-col">
|
||||
<Header />
|
||||
<div className="bg-slate-50 flex-grow transition dark:bg-slate-900">
|
||||
<div className="main-container flex justify-items-stretch h-full">
|
||||
<SideBar />
|
||||
<JsonView>
|
||||
<Outlet />
|
||||
</JsonView>
|
||||
|
||||
<Resizable
|
||||
isHorizontal={true}
|
||||
initialSize={500}
|
||||
minimumSize={280}
|
||||
maximumSize={900}
|
||||
>
|
||||
<div className="info-panel flex-grow h-full">
|
||||
<InfoPanel />
|
||||
</div>
|
||||
</Resizable>
|
||||
<Resizable
|
||||
isHorizontal={true}
|
||||
initialSize={500}
|
||||
minimumSize={280}
|
||||
maximumSize={900}
|
||||
>
|
||||
<div className="info-panel flex-grow h-full">
|
||||
<InfoPanel />
|
||||
</div>
|
||||
</Resizable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer></Footer>
|
||||
<Footer></Footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</JsonTreeViewProvider>
|
||||
</JsonTreeViewProvider>
|
||||
</JsonSearchProvider>
|
||||
</JsonColumnViewProvider>
|
||||
</JsonSchemaProvider>
|
||||
</JsonProvider>
|
||||
|
||||
@@ -220,7 +220,7 @@ export function useColumnView({
|
||||
selectedNodes,
|
||||
highlightedNodeId,
|
||||
highlightedPath,
|
||||
columns,
|
||||
columns: columns ?? [],
|
||||
getColumnViewProps,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
|
||||
@@ -26,9 +26,20 @@ export function formatRawValue(type: JSONValueType): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function formatValue(type: JSONValueType): string | undefined {
|
||||
export type FormatValueOptions = {
|
||||
leafNodesOnly?: boolean;
|
||||
};
|
||||
|
||||
export function formatValue(
|
||||
type: JSONValueType,
|
||||
options?: FormatValueOptions
|
||||
): string | undefined {
|
||||
switch (type.name) {
|
||||
case "array": {
|
||||
if (options?.leafNodesOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type.value.length == 0) {
|
||||
return formatRawValue(type);
|
||||
} else if (type.value.length === 1) {
|
||||
@@ -38,6 +49,10 @@ export function formatValue(type: JSONValueType): string | undefined {
|
||||
}
|
||||
}
|
||||
case "object": {
|
||||
if (options?.leafNodesOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(type.value).length == 0) {
|
||||
return formatRawValue(type);
|
||||
} else if (Object.keys(type.value).length === 1) {
|
||||
|
||||
@@ -26,8 +26,6 @@ import {
|
||||
} from "@heroicons/react/outline";
|
||||
import { inferType, JSONValueType } from "@jsonhero/json-infer-types";
|
||||
import { JSONHeroPath, PathComponent } from "@jsonhero/path";
|
||||
import { ArrayIcon } from "~/components/Icons/ArrayIcon";
|
||||
import { ObjectIcon } from "~/components/Icons/ObjectIcon";
|
||||
import { StringIcon } from "~/components/Icons/StringIcon";
|
||||
import { ColumnViewNode, IconComponent } from "~/useColumnView";
|
||||
import { formatValue } from "./formatter";
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { JSONValueType, inferType } from "@jsonhero/json-infer-types";
|
||||
import { JSONHeroPath } from "@jsonhero/path";
|
||||
import Fuse from "fuse.js";
|
||||
import { formatValue } from "./formatter";
|
||||
|
||||
export interface JsonSearchEntry {
|
||||
path: string;
|
||||
rawValue?: string;
|
||||
formattedValue?: string;
|
||||
}
|
||||
|
||||
export function createSearchIndex(
|
||||
json: unknown
|
||||
): [Fuse.FuseIndex<JsonSearchEntry>, Array<JsonSearchEntry>] {
|
||||
const entries = createSearchEntries(json);
|
||||
|
||||
const index = Fuse.createIndex<JsonSearchEntry>(
|
||||
["path", "rawValue", "formattedValue"],
|
||||
entries
|
||||
);
|
||||
|
||||
return [index, entries];
|
||||
}
|
||||
|
||||
export function createSearchEntries(json: unknown): Array<JsonSearchEntry> {
|
||||
return createSearchEntryChildren(inferType(json), new JSONHeroPath("$"));
|
||||
}
|
||||
|
||||
function createSearchEntryChildren(
|
||||
info: JSONValueType,
|
||||
path: JSONHeroPath
|
||||
): Array<JsonSearchEntry> {
|
||||
if (info.name === "array" && info.value) {
|
||||
return info.value.flatMap((value, index) => {
|
||||
const childPath = path.child(index.toString());
|
||||
const childInfo = inferType(value);
|
||||
|
||||
const children = createSearchEntryChildren(childInfo, childPath);
|
||||
|
||||
return [
|
||||
{
|
||||
path: childPath.toString(),
|
||||
rawValue: getRawValue(childInfo),
|
||||
formattedValue: formatValue(childInfo, { leafNodesOnly: true }),
|
||||
},
|
||||
...children,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
if (info.name === "object" && info.value) {
|
||||
return Object.entries(info.value).flatMap(([key, value]) => {
|
||||
const childPath = path.child(key);
|
||||
const childInfo = inferType(value);
|
||||
|
||||
const children = createSearchEntryChildren(childInfo, childPath);
|
||||
|
||||
return [
|
||||
{
|
||||
path: childPath.toString(),
|
||||
rawValue: getRawValue(childInfo),
|
||||
formattedValue: formatValue(childInfo, { leafNodesOnly: true }),
|
||||
},
|
||||
...children,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getRawValue(type: JSONValueType): string | undefined {
|
||||
switch (type.name) {
|
||||
case "string":
|
||||
return type.value;
|
||||
case "int":
|
||||
return type.value.toString();
|
||||
case "float":
|
||||
return type.value.toString();
|
||||
case "bool":
|
||||
return type.value ? "true" : "false";
|
||||
case "null":
|
||||
return "null";
|
||||
}
|
||||
}
|
||||
Generated
+21
-7
@@ -27,6 +27,7 @@
|
||||
"color": "^4.2.1",
|
||||
"fathom-client": "^3.4.1",
|
||||
"framer-motion": "^6.2.4",
|
||||
"fuse.js": "^6.5.3",
|
||||
"json-source-map": "^0.6.1",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
@@ -58,7 +59,7 @@
|
||||
"rimraf": "^3.0.2",
|
||||
"tailwindcss": "^3.0.22",
|
||||
"ts-jest": "^27.1.3",
|
||||
"typescript": "^4.1.2"
|
||||
"typescript": "^4.6.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -5925,6 +5926,14 @@
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
|
||||
},
|
||||
"node_modules/fuse.js": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.5.3.tgz",
|
||||
"integrity": "sha512-sA5etGE7yD/pOqivZRBvUBd/NaL2sjAu6QuSaFoe1H2BrJSkH/T/UXAJ8CdXdw7DvY3Hs8CXKYkDWX7RiP5KOg==",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -12791,9 +12800,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "4.5.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz",
|
||||
"integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==",
|
||||
"version": "4.6.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz",
|
||||
"integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -18048,6 +18057,11 @@
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
|
||||
},
|
||||
"fuse.js": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.5.3.tgz",
|
||||
"integrity": "sha512-sA5etGE7yD/pOqivZRBvUBd/NaL2sjAu6QuSaFoe1H2BrJSkH/T/UXAJ8CdXdw7DvY3Hs8CXKYkDWX7RiP5KOg=="
|
||||
},
|
||||
"gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -23005,9 +23019,9 @@
|
||||
}
|
||||
},
|
||||
"typescript": {
|
||||
"version": "4.5.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz",
|
||||
"integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==",
|
||||
"version": "4.6.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz",
|
||||
"integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==",
|
||||
"dev": true
|
||||
},
|
||||
"unbox-primitive": {
|
||||
|
||||
+7
-4
@@ -5,14 +5,16 @@
|
||||
"license": "apache-2.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist",
|
||||
"build": "npm run build:css && remix build",
|
||||
"build": "npm run build:css && npm run build:search && remix build",
|
||||
"build:css": "tailwindcss -i ./styles/tailwind.css -o ./app/tailwind.css --minify",
|
||||
"build:worker": "esbuild --define:process.env.NODE_ENV='\"production\"' --minify --bundle --sourcemap --outdir=dist ./worker",
|
||||
"build:worker:analyze": "esbuild --define:process.env.NODE_ENV='\"production\"' --minify --bundle --sourcemap --metafile=meta.json --outdir=dist ./worker",
|
||||
"build:visualize": "npm run clean && npm run build && npm run build:worker:analyze && esbuild-visualizer --metadata ./meta.json --exclude *.png",
|
||||
"build:search": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"production\"'",
|
||||
"dev:search": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"development\"' --watch",
|
||||
"dev:worker": "esbuild --define:process.env.NODE_ENV='\"development\"' --bundle --sourcemap --outdir=dist ./worker",
|
||||
"start:worker": "miniflare --env .env --build-command \"npm run dev:worker\" --watch",
|
||||
"dev": "concurrently \"npm run dev:css\" \"remix watch\"",
|
||||
"dev": "concurrently \"npm run dev:css\" \"npm run dev:search\" \"remix watch\"",
|
||||
"dev:css": "tailwindcss -i ./styles/tailwind.css -o ./app/tailwind.css --watch",
|
||||
"postinstall": "remix setup cloudflare-workers",
|
||||
"start": "concurrently \"npm run dev\" \"npm run start:worker\"",
|
||||
@@ -44,6 +46,7 @@
|
||||
"color": "^4.2.1",
|
||||
"fathom-client": "^3.4.1",
|
||||
"framer-motion": "^6.2.4",
|
||||
"fuse.js": "^6.5.3",
|
||||
"json-source-map": "^0.6.1",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
@@ -75,7 +78,7 @@
|
||||
"rimraf": "^3.0.2",
|
||||
"tailwindcss": "^3.0.22",
|
||||
"ts-jest": "^27.1.3",
|
||||
"typescript": "^4.1.2"
|
||||
"typescript": "^4.6.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -100,4 +103,4 @@
|
||||
},
|
||||
"sideEffects": false,
|
||||
"main": "dist/worker.js"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import Fuse from "fuse.js";
|
||||
import {
|
||||
createSearchEntries,
|
||||
createSearchIndex,
|
||||
} from "../app/utilities/search";
|
||||
|
||||
const json = {
|
||||
records: [
|
||||
{
|
||||
id: "1",
|
||||
createdAt: "2020-01-01T00:00:00.000Z",
|
||||
updatedAt: "2020-12-02T11:34:00.000Z",
|
||||
name: "John Doe",
|
||||
email: "john@doe.com",
|
||||
website: "https://john.doe.com",
|
||||
orders: [
|
||||
{
|
||||
id: "1",
|
||||
productName: "Product 1",
|
||||
quantity: 1,
|
||||
price: 10.0,
|
||||
currency: "USD",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
productName: "Product 2",
|
||||
quantity: 100,
|
||||
price: 999.0,
|
||||
currency: "USD",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
createdAt: "2020-01-02T00:00:00.000Z",
|
||||
updatedAt: "2020-12-03T07:55:32.000Z",
|
||||
name: "Jane Doe",
|
||||
email: "jane@icloud.com",
|
||||
website: "https://jane.doe.co.uk",
|
||||
orders: [
|
||||
{
|
||||
id: "3",
|
||||
productName: "Product 3",
|
||||
quantity: 1,
|
||||
price: 10.0,
|
||||
currency: "GBP",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
productName: "Product 1",
|
||||
quantity: 2,
|
||||
price: 76.45,
|
||||
currency: "GBP",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("createSearchIndex", () => {
|
||||
it("creates a search index that can search keys, raw values, and formatted values", () => {
|
||||
const [index, entries] = createSearchIndex(json);
|
||||
|
||||
const fuse = new Fuse(
|
||||
entries,
|
||||
{
|
||||
includeScore: true,
|
||||
includeMatches: true,
|
||||
minMatchCharLength: 1,
|
||||
isCaseSensitive: false,
|
||||
threshold: 0.6,
|
||||
distance: 200,
|
||||
},
|
||||
index
|
||||
);
|
||||
|
||||
const searchResults = fuse.search("john doe");
|
||||
|
||||
expect(searchResults[0].item.path).toBe("$.records.0.name");
|
||||
expect(searchResults[1].item.path).toBe("$.records.0.email");
|
||||
expect(searchResults[2].item.path).toBe("$.records.0.website");
|
||||
expect(searchResults[3].item.path).toBe("$.records.1.name");
|
||||
|
||||
const searchResultsPaths = fuse.search("currency");
|
||||
|
||||
expect(searchResultsPaths[0].item.path).toBe(
|
||||
"$.records.0.orders.0.currency"
|
||||
);
|
||||
expect(searchResultsPaths[1].item.path).toBe(
|
||||
"$.records.0.orders.1.currency"
|
||||
);
|
||||
|
||||
expect(searchResultsPaths[2].item.path).toBe(
|
||||
"$.records.1.orders.0.currency"
|
||||
);
|
||||
|
||||
expect(searchResultsPaths[3].item.path).toBe(
|
||||
"$.records.1.orders.1.currency"
|
||||
);
|
||||
|
||||
const searchResultsFormattedValues = fuse.search("dec 2");
|
||||
|
||||
expect(searchResultsFormattedValues[0].item.path).toBe(
|
||||
"$.records.0.updatedAt"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSearchEntries", () => {
|
||||
it("creates searchable entries for the passed in json", () => {
|
||||
expect(createSearchEntries(json)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"formattedValue": undefined,
|
||||
"path": "$.records",
|
||||
"rawValue": undefined,
|
||||
},
|
||||
Object {
|
||||
"formattedValue": undefined,
|
||||
"path": "$.records.0",
|
||||
"rawValue": undefined,
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "1",
|
||||
"path": "$.records.0.id",
|
||||
"rawValue": "1",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "Jan 1, 2020, 12:00:00 AM GMT",
|
||||
"path": "$.records.0.createdAt",
|
||||
"rawValue": "2020-01-01T00:00:00.000Z",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "Dec 2, 2020, 11:34:00 AM GMT",
|
||||
"path": "$.records.0.updatedAt",
|
||||
"rawValue": "2020-12-02T11:34:00.000Z",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "John Doe",
|
||||
"path": "$.records.0.name",
|
||||
"rawValue": "John Doe",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "john@doe.com",
|
||||
"path": "$.records.0.email",
|
||||
"rawValue": "john@doe.com",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "https://john.doe.com",
|
||||
"path": "$.records.0.website",
|
||||
"rawValue": "https://john.doe.com",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": undefined,
|
||||
"path": "$.records.0.orders",
|
||||
"rawValue": undefined,
|
||||
},
|
||||
Object {
|
||||
"formattedValue": undefined,
|
||||
"path": "$.records.0.orders.0",
|
||||
"rawValue": undefined,
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "1",
|
||||
"path": "$.records.0.orders.0.id",
|
||||
"rawValue": "1",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "Product 1",
|
||||
"path": "$.records.0.orders.0.productName",
|
||||
"rawValue": "Product 1",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "1",
|
||||
"path": "$.records.0.orders.0.quantity",
|
||||
"rawValue": "1",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "10",
|
||||
"path": "$.records.0.orders.0.price",
|
||||
"rawValue": "10",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "USD",
|
||||
"path": "$.records.0.orders.0.currency",
|
||||
"rawValue": "USD",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": undefined,
|
||||
"path": "$.records.0.orders.1",
|
||||
"rawValue": undefined,
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "2",
|
||||
"path": "$.records.0.orders.1.id",
|
||||
"rawValue": "2",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "Product 2",
|
||||
"path": "$.records.0.orders.1.productName",
|
||||
"rawValue": "Product 2",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "100",
|
||||
"path": "$.records.0.orders.1.quantity",
|
||||
"rawValue": "100",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "999",
|
||||
"path": "$.records.0.orders.1.price",
|
||||
"rawValue": "999",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "USD",
|
||||
"path": "$.records.0.orders.1.currency",
|
||||
"rawValue": "USD",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": undefined,
|
||||
"path": "$.records.1",
|
||||
"rawValue": undefined,
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "2",
|
||||
"path": "$.records.1.id",
|
||||
"rawValue": "2",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "Jan 2, 2020, 12:00:00 AM GMT",
|
||||
"path": "$.records.1.createdAt",
|
||||
"rawValue": "2020-01-02T00:00:00.000Z",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "Dec 3, 2020, 7:55:32 AM GMT",
|
||||
"path": "$.records.1.updatedAt",
|
||||
"rawValue": "2020-12-03T07:55:32.000Z",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "Jane Doe",
|
||||
"path": "$.records.1.name",
|
||||
"rawValue": "Jane Doe",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "jane@icloud.com",
|
||||
"path": "$.records.1.email",
|
||||
"rawValue": "jane@icloud.com",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "https://jane.doe.co.uk",
|
||||
"path": "$.records.1.website",
|
||||
"rawValue": "https://jane.doe.co.uk",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": undefined,
|
||||
"path": "$.records.1.orders",
|
||||
"rawValue": undefined,
|
||||
},
|
||||
Object {
|
||||
"formattedValue": undefined,
|
||||
"path": "$.records.1.orders.0",
|
||||
"rawValue": undefined,
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "3",
|
||||
"path": "$.records.1.orders.0.id",
|
||||
"rawValue": "3",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "Product 3",
|
||||
"path": "$.records.1.orders.0.productName",
|
||||
"rawValue": "Product 3",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "1",
|
||||
"path": "$.records.1.orders.0.quantity",
|
||||
"rawValue": "1",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "10",
|
||||
"path": "$.records.1.orders.0.price",
|
||||
"rawValue": "10",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "GBP",
|
||||
"path": "$.records.1.orders.0.currency",
|
||||
"rawValue": "GBP",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": undefined,
|
||||
"path": "$.records.1.orders.1",
|
||||
"rawValue": undefined,
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "4",
|
||||
"path": "$.records.1.orders.1.id",
|
||||
"rawValue": "4",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "Product 1",
|
||||
"path": "$.records.1.orders.1.productName",
|
||||
"rawValue": "Product 1",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "2",
|
||||
"path": "$.records.1.orders.1.quantity",
|
||||
"rawValue": "2",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "76.45",
|
||||
"path": "$.records.1.orders.1.price",
|
||||
"rawValue": "76.45",
|
||||
},
|
||||
Object {
|
||||
"formattedValue": "GBP",
|
||||
"path": "$.records.1.orders.1.currency",
|
||||
"rawValue": "GBP",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user