Skip to content

Commit e368db8

Browse files
committed
Implement JSON search through fuse.js + web worker + react hook
1 parent 8caa11b commit e368db8

File tree

12 files changed

+739
-41
lines changed

12 files changed

+739
-41
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ node_modules
1010
/dist
1111
.mf
1212
/meta.json
13-
/stats.html
13+
/stats.html
14+
public/entry.worker.js

app/components/SearchPalette.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { useJsonSearchApi, useJsonSearchState } from "~/hooks/useJsonSearch";
2+
3+
export function SearchPalette() {
4+
const searchState = useJsonSearchState();
5+
const searchApi = useJsonSearchApi();
6+
7+
return (
8+
<div>
9+
<label>Search json</label>
10+
<div>
11+
<input
12+
type="text"
13+
value={searchState.query ?? ""}
14+
onChange={(e) => searchApi.search(e.currentTarget.value)}
15+
/>
16+
</div>
17+
<ul>
18+
{searchState.results?.map((result) => (
19+
<li key={result.item.path}>
20+
[{result.item.path}] {result.item.formattedValue}
21+
{" - "}
22+
{result.item.rawValue}
23+
</li>
24+
))}
25+
</ul>
26+
</div>
27+
);
28+
}

app/entry.worker.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/// <reference lib="WebWorker" />
2+
3+
import Fuse from "fuse.js";
4+
import { createSearchIndex, JsonSearchEntry } from "./utilities/search";
5+
6+
type SearchWorker = {
7+
entries?: Array<JsonSearchEntry>;
8+
index?: Fuse.FuseIndex<JsonSearchEntry>;
9+
fuse?: Fuse<JsonSearchEntry>;
10+
};
11+
12+
export type {};
13+
declare let self: DedicatedWorkerGlobalScope & SearchWorker;
14+
15+
type InitializeIndexEvent = {
16+
type: "initialize-index";
17+
payload: { json: unknown; fuseOptions: Fuse.IFuseOptions<JsonSearchEntry> };
18+
};
19+
20+
type SearchEvent = {
21+
type: "search";
22+
payload: { query: string };
23+
};
24+
25+
type SearchWorkerEvent = InitializeIndexEvent | SearchEvent;
26+
27+
self.onmessage = (e: MessageEvent<SearchWorkerEvent>) => {
28+
const { type, payload } = e.data;
29+
30+
console.group(`SearchWorker: ${type}`);
31+
console.log(payload);
32+
console.groupEnd();
33+
34+
switch (type) {
35+
case "initialize-index": {
36+
const { json, fuseOptions } = payload;
37+
38+
const [index, entries] = createSearchIndex(json);
39+
40+
self.entries = entries;
41+
self.index = index;
42+
self.fuse = new Fuse(entries, fuseOptions, index);
43+
44+
self.postMessage({ type: "index-initialized" });
45+
46+
break;
47+
}
48+
case "search": {
49+
const { query } = payload;
50+
51+
if (!self.fuse) {
52+
throw new Error("Search index not initialized");
53+
}
54+
55+
const results = self.fuse.search(query);
56+
57+
self.postMessage({ type: "search-results", payload: { results } });
58+
}
59+
}
60+
};

app/hooks/useJsonSearch.tsx

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { useJson } from "./useJson";
2+
import Fuse from "fuse.js";
3+
import { JsonSearchEntry } from "~/utilities/search";
4+
import {
5+
createContext,
6+
useCallback,
7+
useContext,
8+
useEffect,
9+
useReducer,
10+
useRef,
11+
} from "react";
12+
13+
export type InitializeIndexEvent = {
14+
type: "initialize-index";
15+
payload: { json: unknown; fuseOptions: Fuse.IFuseOptions<JsonSearchEntry> };
16+
};
17+
18+
export type SearchEvent = {
19+
type: "search";
20+
payload: { query: string };
21+
};
22+
23+
export type SearchSendWorkerEvent = InitializeIndexEvent | SearchEvent;
24+
25+
export type IndexInitializedEvent = {
26+
type: "index-initialized";
27+
};
28+
29+
export type SearchResultsEvent = {
30+
type: "search-results";
31+
payload: { results: Fuse.FuseResult<JsonSearchEntry>[] };
32+
};
33+
34+
export type SearchReceiveWorkerEvent =
35+
| IndexInitializedEvent
36+
| SearchResultsEvent;
37+
38+
export type JsonSearchApi = {
39+
search: (query: string) => void;
40+
};
41+
42+
const JsonSearchStateContext = createContext<JsonSearchState>(
43+
{} as JsonSearchState
44+
);
45+
46+
const JsonSearchApiContext = createContext<JsonSearchApi>({} as JsonSearchApi);
47+
48+
export type JsonSearchState = {
49+
status: "initializing" | "idle" | "searching";
50+
query?: string;
51+
results?: Fuse.FuseResult<JsonSearchEntry>[];
52+
};
53+
54+
type SearchAction = {
55+
type: "search";
56+
payload: { query: string };
57+
};
58+
59+
type JsonSearchAction = SearchReceiveWorkerEvent | SearchAction;
60+
61+
function reducer(
62+
state: JsonSearchState,
63+
action: JsonSearchAction
64+
): JsonSearchState {
65+
switch (action.type) {
66+
case "index-initialized":
67+
return {
68+
...state,
69+
status: "idle",
70+
results: undefined,
71+
};
72+
case "search-results":
73+
return {
74+
...state,
75+
status: "idle",
76+
results: action.payload.results,
77+
};
78+
case "search":
79+
return {
80+
...state,
81+
status: "searching",
82+
query: action.payload.query,
83+
};
84+
default:
85+
return state;
86+
}
87+
}
88+
89+
export function JsonSearchProvider({
90+
children,
91+
}: {
92+
children: React.ReactNode;
93+
}) {
94+
const [json] = useJson();
95+
96+
const [state, dispatch] = useReducer<
97+
React.Reducer<JsonSearchState, JsonSearchAction>
98+
>(reducer, { status: "initializing" });
99+
100+
const search = useCallback(
101+
(query: string) => {
102+
dispatch({ type: "search", payload: { query } });
103+
},
104+
[dispatch]
105+
);
106+
107+
const handleWorkerMessage = useCallback(
108+
(e: MessageEvent<SearchReceiveWorkerEvent>) => dispatch(e.data),
109+
[dispatch]
110+
);
111+
112+
const workerRef = useRef<Worker | null>();
113+
114+
useEffect(() => {
115+
if (typeof window === "undefined" || typeof window.Worker === "undefined") {
116+
return;
117+
}
118+
119+
if (workerRef.current) {
120+
return;
121+
}
122+
123+
const worker = new Worker("/entry.worker.js");
124+
worker.onmessage = handleWorkerMessage;
125+
126+
workerRef.current = worker;
127+
128+
workerRef.current.postMessage({
129+
type: "initialize-index",
130+
payload: {
131+
json,
132+
fuseOptions: {
133+
includeScore: true,
134+
includeMatches: true,
135+
minMatchCharLength: 1,
136+
isCaseSensitive: false,
137+
threshold: 0.6,
138+
distance: 200,
139+
},
140+
},
141+
});
142+
}, [json, workerRef.current]);
143+
144+
useEffect(() => {
145+
if (state.status !== "searching") {
146+
return;
147+
}
148+
149+
workerRef.current?.postMessage({
150+
type: "search",
151+
payload: { query: state.query },
152+
});
153+
}, [state.status, workerRef.current]);
154+
155+
return (
156+
<JsonSearchStateContext.Provider value={state}>
157+
<JsonSearchApiContext.Provider value={{ search }}>
158+
{children}
159+
</JsonSearchApiContext.Provider>
160+
</JsonSearchStateContext.Provider>
161+
);
162+
}
163+
164+
export function useJsonSearchState(): JsonSearchState {
165+
return useContext(JsonSearchStateContext);
166+
}
167+
168+
export function useJsonSearchApi(): JsonSearchApi {
169+
return useContext(JsonSearchApiContext);
170+
}

app/routes/j/$id.tsx

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { JsonSchemaProvider } from "~/hooks/useJsonSchema";
2121
import { JsonView } from "~/components/JsonView";
2222
import safeFetch from "~/utilities/safeFetch";
2323
import { JsonTreeViewProvider } from "~/hooks/useJsonTree";
24+
import { JsonSearchProvider } from "~/hooks/useJsonSearch";
2425

2526
export const loader: LoaderFunction = async ({ params, request }) => {
2627
invariant(params.id, "expected params.id");
@@ -114,34 +115,36 @@ export default function JsonDocumentRoute() {
114115
<JsonProvider initialJson={loaderData.json}>
115116
<JsonSchemaProvider>
116117
<JsonColumnViewProvider>
117-
<JsonTreeViewProvider overscan={25}>
118-
<div>
119-
<div className="h-screen flex flex-col">
120-
<Header />
121-
<div className="bg-slate-50 flex-grow transition dark:bg-slate-900">
122-
<div className="main-container flex justify-items-stretch h-full">
123-
<SideBar />
124-
<JsonView>
125-
<Outlet />
126-
</JsonView>
127-
128-
<Resizable
129-
isHorizontal={true}
130-
initialSize={500}
131-
minimumSize={280}
132-
maximumSize={900}
133-
>
134-
<div className="info-panel flex-grow h-full">
135-
<InfoPanel />
136-
</div>
137-
</Resizable>
118+
<JsonSearchProvider>
119+
<JsonTreeViewProvider overscan={25}>
120+
<div>
121+
<div className="h-screen flex flex-col">
122+
<Header />
123+
<div className="bg-slate-50 flex-grow transition dark:bg-slate-900">
124+
<div className="main-container flex justify-items-stretch h-full">
125+
<SideBar />
126+
<JsonView>
127+
<Outlet />
128+
</JsonView>
129+
130+
<Resizable
131+
isHorizontal={true}
132+
initialSize={500}
133+
minimumSize={280}
134+
maximumSize={900}
135+
>
136+
<div className="info-panel flex-grow h-full">
137+
<InfoPanel />
138+
</div>
139+
</Resizable>
140+
</div>
138141
</div>
139-
</div>
140142

141-
<Footer></Footer>
143+
<Footer></Footer>
144+
</div>
142145
</div>
143-
</div>
144-
</JsonTreeViewProvider>
146+
</JsonTreeViewProvider>
147+
</JsonSearchProvider>
145148
</JsonColumnViewProvider>
146149
</JsonSchemaProvider>
147150
</JsonProvider>

app/useColumnView/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ export function useColumnView({
220220
selectedNodes,
221221
highlightedNodeId,
222222
highlightedPath,
223-
columns,
223+
columns: columns ?? [],
224224
getColumnViewProps,
225225
canGoBack,
226226
canGoForward,

app/utilities/formatter.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,20 @@ export function formatRawValue(type: JSONValueType): string {
2626
}
2727
}
2828

29-
export function formatValue(type: JSONValueType): string | undefined {
29+
export type FormatValueOptions = {
30+
leafNodesOnly?: boolean;
31+
};
32+
33+
export function formatValue(
34+
type: JSONValueType,
35+
options?: FormatValueOptions
36+
): string | undefined {
3037
switch (type.name) {
3138
case "array": {
39+
if (options?.leafNodesOnly) {
40+
return;
41+
}
42+
3243
if (type.value.length == 0) {
3344
return formatRawValue(type);
3445
} else if (type.value.length === 1) {
@@ -38,6 +49,10 @@ export function formatValue(type: JSONValueType): string | undefined {
3849
}
3950
}
4051
case "object": {
52+
if (options?.leafNodesOnly) {
53+
return;
54+
}
55+
4156
if (Object.keys(type.value).length == 0) {
4257
return formatRawValue(type);
4358
} else if (Object.keys(type.value).length === 1) {

app/utilities/jsonColumnView.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ import {
2626
} from "@heroicons/react/outline";
2727
import { inferType, JSONValueType } from "@jsonhero/json-infer-types";
2828
import { JSONHeroPath, PathComponent } from "@jsonhero/path";
29-
import { ArrayIcon } from "~/components/Icons/ArrayIcon";
30-
import { ObjectIcon } from "~/components/Icons/ObjectIcon";
3129
import { StringIcon } from "~/components/Icons/StringIcon";
3230
import { ColumnViewNode, IconComponent } from "~/useColumnView";
3331
import { formatValue } from "./formatter";

0 commit comments

Comments
 (0)