diff --git a/.gitignore b/.gitignore index 086306b..235185c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ node_modules /dist .mf /meta.json -/stats.html \ No newline at end of file +/stats.html +public/entry.worker.js \ No newline at end of file diff --git a/app/components/SearchPalette.tsx b/app/components/SearchPalette.tsx new file mode 100644 index 0000000..5051560 --- /dev/null +++ b/app/components/SearchPalette.tsx @@ -0,0 +1,28 @@ +import { useJsonSearchApi, useJsonSearchState } from "~/hooks/useJsonSearch"; + +export function SearchPalette() { + const searchState = useJsonSearchState(); + const searchApi = useJsonSearchApi(); + + return ( +
+ +
+ searchApi.search(e.currentTarget.value)} + /> +
+ +
+ ); +} diff --git a/app/entry.worker.ts b/app/entry.worker.ts new file mode 100644 index 0000000..7a9d5e5 --- /dev/null +++ b/app/entry.worker.ts @@ -0,0 +1,60 @@ +/// + +import Fuse from "fuse.js"; +import { createSearchIndex, JsonSearchEntry } from "./utilities/search"; + +type SearchWorker = { + entries?: Array; + index?: Fuse.FuseIndex; + fuse?: Fuse; +}; + +export type {}; +declare let self: DedicatedWorkerGlobalScope & SearchWorker; + +type InitializeIndexEvent = { + type: "initialize-index"; + payload: { json: unknown; fuseOptions: Fuse.IFuseOptions }; +}; + +type SearchEvent = { + type: "search"; + payload: { query: string }; +}; + +type SearchWorkerEvent = InitializeIndexEvent | SearchEvent; + +self.onmessage = (e: MessageEvent) => { + 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 } }); + } + } +}; diff --git a/app/hooks/useJsonSearch.tsx b/app/hooks/useJsonSearch.tsx new file mode 100644 index 0000000..911a17e --- /dev/null +++ b/app/hooks/useJsonSearch.tsx @@ -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 }; +}; + +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[] }; +}; + +export type SearchReceiveWorkerEvent = + | IndexInitializedEvent + | SearchResultsEvent; + +export type JsonSearchApi = { + search: (query: string) => void; +}; + +const JsonSearchStateContext = createContext( + {} as JsonSearchState +); + +const JsonSearchApiContext = createContext({} as JsonSearchApi); + +export type JsonSearchState = { + status: "initializing" | "idle" | "searching"; + query?: string; + results?: Fuse.FuseResult[]; +}; + +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 + >(reducer, { status: "initializing" }); + + const search = useCallback( + (query: string) => { + dispatch({ type: "search", payload: { query } }); + }, + [dispatch] + ); + + const handleWorkerMessage = useCallback( + (e: MessageEvent) => dispatch(e.data), + [dispatch] + ); + + const workerRef = useRef(); + + 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 ( + + + {children} + + + ); +} + +export function useJsonSearchState(): JsonSearchState { + return useContext(JsonSearchStateContext); +} + +export function useJsonSearchApi(): JsonSearchApi { + return useContext(JsonSearchApiContext); +} diff --git a/app/routes/j/$id.tsx b/app/routes/j/$id.tsx index 8df55a3..e77a47b 100644 --- a/app/routes/j/$id.tsx +++ b/app/routes/j/$id.tsx @@ -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() { - -
-
-
-
-
- - - - + + +
+
+
+
+
+ + + + - -
- -
-
+ +
+ +
+
+
-
-
+
+
-
- + + 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", + }, +] +`); + }); +});