-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+
-
-
+
+
diff --git a/app/useColumnView/index.ts b/app/useColumnView/index.ts
index 4aa1b67..5a85024 100644
--- a/app/useColumnView/index.ts
+++ b/app/useColumnView/index.ts
@@ -220,7 +220,7 @@ export function useColumnView({
selectedNodes,
highlightedNodeId,
highlightedPath,
- columns,
+ columns: columns ?? [],
getColumnViewProps,
canGoBack,
canGoForward,
diff --git a/app/utilities/formatter.ts b/app/utilities/formatter.ts
index ff5a940..1ab469d 100644
--- a/app/utilities/formatter.ts
+++ b/app/utilities/formatter.ts
@@ -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) {
diff --git a/app/utilities/jsonColumnView.ts b/app/utilities/jsonColumnView.ts
index 7666677..412f81a 100644
--- a/app/utilities/jsonColumnView.ts
+++ b/app/utilities/jsonColumnView.ts
@@ -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";
diff --git a/app/utilities/search.ts b/app/utilities/search.ts
new file mode 100644
index 0000000..d208f1a
--- /dev/null
+++ b/app/utilities/search.ts
@@ -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
, Array] {
+ const entries = createSearchEntries(json);
+
+ const index = Fuse.createIndex(
+ ["path", "rawValue", "formattedValue"],
+ entries
+ );
+
+ return [index, entries];
+}
+
+export function createSearchEntries(json: unknown): Array {
+ return createSearchEntryChildren(inferType(json), new JSONHeroPath("$"));
+}
+
+function createSearchEntryChildren(
+ info: JSONValueType,
+ path: JSONHeroPath
+): Array {
+ 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";
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 01dde43..72bd2f0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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": {
diff --git a/package.json b/package.json
index 8a746f9..00311c5 100644
--- a/package.json
+++ b/package.json
@@ -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"
-}
+}
\ No newline at end of file
diff --git a/tests/search.test.ts b/tests/search.test.ts
new file mode 100644
index 0000000..0253b9c
--- /dev/null
+++ b/tests/search.test.ts
@@ -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",
+ },
+]
+`);
+ });
+});