Squashed commit of the following:

commit 09bed34946
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 8 14:03:50 2022 +0100

    Fixed the layout of a search list item

commit 90126d43ad
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Apr 1 17:25:13 2022 +0100

    Insanely complicated path search results highlighitng and truncating

commit a4bbd5521e
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 1 14:05:01 2022 +0100

    Improved the title editing in the header

commit 5814053968
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 1 13:38:33 2022 +0100

    Improved text highlight colour

commit 110de37242
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 1 13:36:57 2022 +0100

    Icon hover state now uses isHighlighted

commit fb6da943d9
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 1 13:31:11 2022 +0100

    Light mode styling

commit 2c378d5158
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 1 12:24:43 2022 +0100

    Spacing between search items no longer a hack

commit 85d90bb0ae
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Apr 1 12:19:10 2022 +0100

    Added ellipsis to search matches if they are windowed

commit 91f3e70676
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Apr 1 12:08:00 2022 +0100

    Fixed search item icons

commit e384c87c6f
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 1 11:52:58 2022 +0100

    Improved hover state

commit f4681edc07
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 1 11:47:33 2022 +0100

    Removed focus state on button

commit faa2d0663c
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Apr 1 11:26:42 2022 +0100

    made the search palette a little bigger

commit df8bd521fe
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Apr 1 11:26:14 2022 +0100

    Remove onOverlayClick prop

commit ecd046b3b2
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Apr 1 11:18:46 2022 +0100

    Close search when the overlay is clicked

commit fba330b5d9
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Apr 1 11:10:35 2022 +0100

    hitting esc when search input is focused and empty should close the search

commit fc2fe8ae6f
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Apr 1 11:00:08 2022 +0100

    Highlight the window of the best search match in the search results

commit 45193ec92c
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 1 10:45:01 2022 +0100

    Now you can arrow down to the last item in the list

commit 0de5fef7e9
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Apr 1 10:37:20 2022 +0100

    Added a hacky margin between items and remove the transition to make it feel snappier

commit 167b548343
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Apr 1 09:30:33 2022 +0100

    WIP implementing search

commit 5655631f12
Author: James Ritchie <james@jamesritchie.co.uk>
Date:   Fri Mar 25 15:28:19 2022 +0000

    Build and styled search modal

commit e368db81c8
Author: Eric Allam <eallam@icloud.com>
Date:   Fri Mar 25 14:42:03 2022 +0000

    Implement JSON search through fuse.js + web worker + react hook

commit 8caa11ba1c
Author: Eric Allam <eallam@icloud.com>
Date:   Wed Mar 23 10:21:33 2022 +0000

    Start of the Command Palette using radix-ui Dialog

commit 904b64d781
Author: Eric Allam <eallam@icloud.com>
Date:   Wed Mar 23 09:58:52 2022 +0000

    Uncommented out the search bar field
This commit is contained in:
James Ritchie
2022-04-08 14:07:39 +01:00
parent ae563ba544
commit 0632e82a4b
21 changed files with 2521 additions and 238 deletions
+2 -1
View File
@@ -10,4 +10,5 @@ node_modules
/dist /dist
.mf .mf
/meta.json /meta.json
/stats.html /stats.html
public/entry.worker.js
+16 -18
View File
@@ -27,26 +27,24 @@ export function DocumentTitle() {
className="flex justify-center items-center w-full" className="flex justify-center items-center w-full"
title={doc.title} title={doc.title}
> >
<input <label className="relative block group">
ref={ref} <PencilAltIcon className="h-5 w-5 absolute top-1/2 transform -translate-y-1/2 left-3 text-white opacity-0 transition pointer-events-none group-hover:opacity-80 group-focus:opacity-80" />
className={ <input
"min-w-[15vw] border-none text-center text-ellipsis text-slate-300 px-2 pl-3 py-1 rounded-sm bg-transparent placeholder:text-slate-400 focus:bg-black focus:bg-opacity-30 focus:outline-none focus:border-none hover:bg-black hover:bg-opacity-30 hover:cursor-text transition dark:bg-transparent dark:text-slate-200 dark:placeholder:text-slate-400 dark:focus:bg-black dark:focus:bg-opacity-10 dark:hover:bg-black dark:hover:bg-opacity-10" ref={ref}
} className={
type="text" "min-w-[15vw] border-none text-ellipsis text-slate-300 px-2 pl-10 py-1 rounded-sm bg-transparent placeholder:text-slate-400 focus:bg-black/30 focus:outline-none focus:border-none hover:bg-black hover:bg-opacity-30 hover:cursor-text transition dark:bg-transparent dark:text-slate-200 dark:placeholder:text-slate-400 dark:focus:bg-black dark:focus:bg-opacity-10 dark:hover:bg-black dark:hover:bg-opacity-10"
name="title" }
spellCheck="false" type="text"
placeholder="Name your JSON file" name="title"
value={editedTitle} spellCheck="false"
onChange={(e) => setEditedTitle(e.target.value)} placeholder="Name your JSON file"
/> value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
/>
</label>
{match(editedTitle) {match(editedTitle)
.with(doc.title, () => ( .with(doc.title, () => <p className="ml-2 text-transparent">Save</p>)
<PencilAltIcon
className="h-5 w-5 text-black text-opacity-50"
onClick={startEditing}
/>
))
.with("", () => ( .with("", () => (
<button <button
className="ml-2 text-lime-500 hover:text-lime-600 transition" className="ml-2 text-lime-500 hover:text-lime-600 transition"
@@ -0,0 +1,23 @@
export function ArrowKeysUpDownIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
className={props.className}
width="28"
height="14"
viewBox="0 0 30 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="14" height="14" rx="1.53846" fill="currentColor" />
<path
d="M6.60956 4.48804C6.80972 4.23784 7.19026 4.23784 7.39043 4.48804L10.3501 8.18765C10.612 8.51503 10.3789 9 9.95969 9H4.04031C3.62106 9 3.38797 8.51503 3.64988 8.18765L6.60956 4.48804Z"
fill="black"
/>
<rect x="16" width="14" height="14" rx="1.53846" fill="currentColor" />
<path
d="M23.3904 9.51196C23.1903 9.76216 22.8097 9.76216 22.6096 9.51196L19.6499 5.81235C19.388 5.48496 19.6211 5 20.0403 5L25.9597 5C26.3789 5 26.612 5.48497 26.3501 5.81235L23.3904 9.51196Z"
fill="black"
/>
</svg>
);
}
+27
View File
@@ -0,0 +1,27 @@
export function LoadingIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
className={props.className}
xmlns="http://www.w3.org/2000/svg"
width="26"
height="26"
viewBox="0 0 26 26"
fill="none"
>
<circle
cx="13"
cy="13"
r="10"
stroke="black"
strokeOpacity="0.3"
strokeWidth="4"
/>
<path
d="M13 23C7.47715 23 3 18.5228 3 13"
stroke="#4338CA"
strokeWidth="4"
strokeLinecap="round"
/>
</svg>
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ export function JsonView({ children }: { children: React.ReactNode }) {
<div className="flex-1 pr-2 min-w-0"> <div className="flex-1 pr-2 min-w-0">
<PathBar /> <PathBar />
</div> </div>
{/* <SearchBar /> */} <SearchBar />
</div> </div>
{children} {children}
+58 -18
View File
@@ -1,26 +1,66 @@
import { SearchIcon } from "@heroicons/react/outline"; import { SearchIcon } from "@heroicons/react/outline";
import { ShortcutIcon } from "./Icons/ShortcutIcon"; import { ShortcutIcon } from "./Icons/ShortcutIcon";
import { Body } from "./Primitives/Body"; import { Body } from "./Primitives/Body";
import { Dialog, DialogTrigger, DialogContent } from "./UI/Dialog";
export type SearchBarProps = { import classnames from "~/utilities/classnames";
className?: string; import { SearchPalette } from "./SearchPalette";
}; import { useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { useJsonColumnViewAPI } from "~/hooks/useJsonColumnView";
export function SearchBar() {
const [isOpen, setIsOpen] = useState(false);
const { goToNodeId } = useJsonColumnViewAPI();
useHotkeys(
"cmd+k",
(e) => {
e.preventDefault();
setIsOpen(true);
},
[setIsOpen]
);
export function SearchBar({ className }: SearchBarProps) {
return ( return (
<div className="flex justify-between items-center group w-44 rounded-sm bg-slate-300 transition hover:bg-slate-400 hover:bg-opacity-50 dark:bg-slate-800 dark:text-slate-400 hover:cursor-pointer hover:dark:bg-slate-700 hover:dark:bg-opacity-70"> <Dialog open={isOpen}>
<div className="flex items-center pl-2"> <DialogTrigger
<SearchIcon className="w-4 h-4 mr-1" /> className="focus:outline-none focus-visible:outline-none"
<Body>Search</Body> onClick={() => setIsOpen(true)}
</div> >
<div className="flex items-center gap-1 pr-2"> <div className="flex justify-between items-center group w-44 py-[3px] rounded-sm bg-slate-300 transition hover:bg-slate-400 hover:bg-opacity-50 dark:bg-slate-800 dark:text-slate-400 hover:cursor-pointer hover:dark:bg-slate-700 hover:dark:bg-opacity-70">
<ShortcutIcon className="w-4 h-4 text-sm bg-slate-200 transition group-hover:bg-slate-100 dark:bg-slate-700 dark:group-hover:bg-slate-600"> <div className="flex items-center pl-1">
<SearchIcon className="w-4 h-4 mr-1" />
</ShortcutIcon> <Body>Search</Body>
<ShortcutIcon className="w-4 h-4 text-sm bg-slate-200 transition group-hover:bg-slate-100 dark:bg-slate-700 dark:group-hover:bg-slate-600"> </div>
K <div className="flex items-center gap-1 pr-1">
</ShortcutIcon> <ShortcutIcon className="w-4 h-4 text-sm bg-slate-200 transition group-hover:bg-slate-100 dark:bg-slate-700 dark:group-hover:bg-slate-600">
</div>
</div> </ShortcutIcon>
<ShortcutIcon className="w-4 h-4 text-sm bg-slate-200 transition group-hover:bg-slate-100 dark:bg-slate-700 dark:group-hover:bg-slate-600">
K
</ShortcutIcon>
</div>
</div>
</DialogTrigger>
<DialogContent
onOverlayClick={() => setIsOpen(false)}
className={classnames(
"fixed z-50",
"w-[95vw] max-w-2xl rounded-lg",
"top-0 left-[50%] -translate-x-[50%]",
"mt-[60px]",
"bg-white border-[1px] border-slate-500 dark:border-slate-700 dark:bg-slate-800"
)}
>
<SearchPalette
onClose={() => setIsOpen(false)}
onSelect={(entry) => {
setIsOpen(false);
goToNodeId(entry.path, "search");
}}
/>
</DialogContent>
</Dialog>
); );
} }
+428
View File
@@ -0,0 +1,428 @@
import { useJsonSearchApi, useJsonSearchState } from "~/hooks/useJsonSearch";
import {
ChevronRightIcon,
ExclamationIcon,
SearchIcon,
} from "@heroicons/react/outline";
import { EscapeKeyIcon } from "./Icons/EscapeKeyIcon";
import { ArrowKeysUpDownIcon } from "./Icons/ArrowKeysUpDownIcon";
import { LoadingIcon } from "./Icons/LoadingIcon";
import { Body } from "./Primitives/Body";
import { ShortcutIcon } from "./Icons/ShortcutIcon";
import { Mono } from "./Primitives/Mono";
import {
useCombobox,
UseComboboxState,
UseComboboxStateChangeOptions,
} from "downshift";
import {
getComponentSlices,
getStringSlices,
JsonSearchEntry,
} from "~/utilities/search";
import Fuse from "fuse.js";
import classnames from "~/utilities/classnames";
import { iconForValue } from "~/utilities/icons";
import { useRef, useCallback } from "react";
import { useVirtual } from "react-virtual";
import { sortedLastIndex, truncate } from "lodash-es";
import { JSONHeroPath } from "@jsonhero/path";
import { useHotkeys } from "react-hotkeys-hook";
import { useJson } from "~/hooks/useJson";
export function SearchPalette({
onSelect,
onClose,
}: {
onSelect?: (entry: JsonSearchEntry) => void;
onClose?: () => void;
}) {
const searchState = useJsonSearchState();
const searchApi = useJsonSearchApi();
useHotkeys(
"esc",
(e) => {
e.preventDefault();
searchApi.reset();
onClose?.();
},
[onClose]
);
const listRef = useRef<HTMLElement>(null);
const rowVirtualizer = useVirtual({
size: (searchState.results ?? []).length,
parentRef: listRef,
estimateSize: useCallback(() => 70, []),
overscan: 6,
});
function comboboxReducer(
state: UseComboboxState<Fuse.FuseResult<JsonSearchEntry>>,
actionAndChanges: UseComboboxStateChangeOptions<
Fuse.FuseResult<JsonSearchEntry>
>
): Partial<UseComboboxState<Fuse.FuseResult<JsonSearchEntry>>> {
const { changes, ...action } = actionAndChanges;
// Don't update the input field when selecting an item
switch (action.type) {
case useCombobox.stateChangeTypes.ItemClick:
case useCombobox.stateChangeTypes.InputKeyDownEnter: {
return {
...changes,
inputValue: state.inputValue,
};
}
default:
return changes;
}
}
const cb = useCombobox({
items: searchState.results ?? [],
stateReducer: comboboxReducer,
circularNavigation: false,
scrollIntoView: () => {},
onSelectedItemChange: ({ selectedItem }) => {
if (selectedItem) {
onSelect?.(selectedItem.item);
searchApi.reset();
}
},
onHighlightedIndexChange: ({ highlightedIndex }) =>
highlightedIndex && rowVirtualizer.scrollToIndex(highlightedIndex),
onInputValueChange: ({ inputValue }) =>
inputValue ? searchApi.search(inputValue) : searchApi.reset(),
});
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape" && onClose && cb.inputValue.length === 0) {
searchApi.reset();
onClose?.();
}
},
[onClose, cb.inputValue]
);
return (
<>
<div
{...cb.getComboboxProps()}
className="max-h-[60vh] px-4 pt-4 overflow-hidden"
>
<label
{...cb.getLabelProps()}
className="relative text-slate-400 focus-within:text-slate-600 block"
>
<SearchIcon className="absolute w-7 h-7 top-1/2 transform -translate-y-1/2 left-3 text-slate-700 transition dark:text-white pointer-events-none" />
<input
{...cb.getInputProps({ onKeyDown: handleInputKeyDown })}
type="text"
placeholder="Search the JSON…"
className="w-full pl-12 pr-4 py-4 rounded-sm text-slate-900 bg-slate-100 text-2xl caret-indigo-700 border-indigo-700 transition dark:text-white dark:bg-slate-900 focus:outline-none focus:ring focus:ring-indigo-700"
/>
</label>
<div className="flex flex-col mt-4 mb-2">
<div className="results flex">
{searchState.status !== "idle" &&
(!searchState.results || searchState.results.length === 0) && (
<div className="results-loading flex">
<LoadingIcon className="animate-spin h-5 w-5 mr-1"></LoadingIcon>
<Body className="text-slate-400">Loading</Body>
</div>
)}
{searchState.results && searchState.results.length > 0 && (
<div className="results-returned">
<Body className="text-slate-400">
{searchState.results.length === 1
? "1 result"
: `${searchState.results.length} results`}
</Body>
</div>
)}
{searchState.status === "idle" &&
searchState.query &&
searchState.query.length > 1 &&
(!searchState.results || searchState.results.length === 0) && (
<div className="results-none flex">
<ExclamationIcon className="h-5 w-5 mr-1 text-white"></ExclamationIcon>
<Body className="text-slate-400">
No results for "{cb.inputValue}"
</Body>
</div>
)}
</div>
</div>
<ul
{...cb.getMenuProps({ ref: listRef })}
className="w-full max-h-[calc(60vh-120px)] overflow-y-auto relative"
>
<li
key="total-size"
style={{ height: rowVirtualizer.totalSize }}
className="mb-[1rem]"
/>
{rowVirtualizer.virtualItems.map((virtualRow) => {
const result = (searchState.results ?? [])[virtualRow.index];
return (
<SearchItem
key={result.item.path}
itemProps={cb.getItemProps({
item: result,
index: virtualRow.index,
style: {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`,
},
})}
result={result}
isHighlighted={virtualRow.index === cb.highlightedIndex}
/>
);
})}
</ul>
</div>
<div className="flex items-center w-full gap-4 px-3 py-2 border-t-[1px] bg-slate-100 border-slate-200 rounded-br-lg rounded-bl-lg transition dark:bg-slate-900 dark:border-slate-700">
<div className="flex items-center gap-1">
<ShortcutIcon className="w-4 h-4 text-sm text-slate-900 bg-slate-300 transition duration-75 group-hover:bg-slate-100 dark:bg-slate-500 dark:group-hover:bg-slate-600">
</ShortcutIcon>
<Body className="text-slate-700 dakr:text-slate-500">to select</Body>
</div>
<div className="flex items-center gap-1">
<ArrowKeysUpDownIcon className="transition text-slate-300 dark:text-slate-500" />
<Body className="text-slate-700 dakr:text-slate-500">
to navigate
</Body>
</div>
<div className="flex items-center gap-1">
<EscapeKeyIcon className="transition text-slate-300 dark:text-slate-500" />
<Body className="text-slate-700 dakr:text-slate-500">to close</Body>
</div>
</div>
</>
);
}
type SearchItemProps = {
itemProps: React.HTMLAttributes<HTMLLIElement>;
result: Fuse.FuseResult<JsonSearchEntry>;
isHighlighted: boolean;
};
export function SearchItem({
itemProps,
result,
isHighlighted,
}: SearchItemProps) {
const heroPath = new JSONHeroPath(result.item.path);
const [json] = useJson();
const itemValue = heroPath.first(json);
const ItemIcon = iconForValue(itemValue);
return (
<li
{...itemProps}
className={classnames("flex w-full hover:cursor-pointer")}
>
<div
className={classnames(
"w-full h-[calc(100%-4px)] mb-2 rounded-sm group",
isHighlighted ? "bg-indigo-700" : "bg-slate-100 dark:bg-slate-900"
)}
>
<div className="flex items-center w-full p-2 pl-4 pr-3">
<ItemIcon
className={classnames(
"h-6 w-6",
isHighlighted
? "text-white"
: "text-slate-500 dark:text-slate-400"
)}
></ItemIcon>
<div className="flex flex-col ml-3">
<div className="flex w-full items-center">
<SearchPathResult
path={heroPath}
searchResult={result}
isHighlighted={isHighlighted}
/>
</div>
<div className="key-value flex justify-between">
{result.item.rawValue && (
<SearchResultValue
isHighlighted={isHighlighted}
keyName="rawValue"
stringValue={result.item.rawValue}
searchResult={result}
/>
)}
{result.item.formattedValue &&
result.item.formattedValue !== result.item.rawValue && (
<SearchResultValue
isHighlighted={isHighlighted}
keyName="formattedValue"
stringValue={result.item.formattedValue}
searchResult={result}
/>
)}
</div>
</div>
</div>
</div>
</li>
);
}
// Outputs the following pair for each component except for the last one:
// <Body className="text-lg">{component}</Body>,
// <ChevronRightIcon className="w-4 h-4" />,
//
// Highlights parts of the component that match the search query.
// The match indices match against the stringified version of the path (e.g. $.foo.bar.0.details.description)
//
// If combined component strings are too long, then we need to choose some components to hide behind an ellipsis, making sure we don't hide matches
function SearchPathResult({
path,
searchResult,
isHighlighted,
maxWeight = 90,
}: {
path: JSONHeroPath;
isHighlighted: boolean;
searchResult: Fuse.FuseResult<JsonSearchEntry>;
maxWeight?: number;
}) {
const components = path.components.slice(1);
const match = (searchResult.matches ?? []).find(
(match) => match.key === "path" && match.indices.length > 0
);
const matchingIndices = (match?.indices ?? []) as [number, number][];
const displayPath = components.join(".");
const slices = getComponentSlices(
displayPath,
matchingIndices.map(([start, end]) => [start - 2, end - 2]),
maxWeight
);
return (
<>
{slices.map((slice, i) =>
slice.type === "component" ? (
<span
key={i}
className={
slice.slice.isMatch
? classnames(
"font-sans text-xl",
isHighlighted
? "text-white underline underline-offset-1"
: "text-indigo-600 dark:text-indigo-400"
)
: classnames(
"font-sans text-xl",
isHighlighted
? "text-white"
: "text-slate-800 dark:text-white"
)
}
>
{slice.slice.slice}
</span>
) : slice.type === "ellipsis" ? (
<Body key={i} className="text-lg mx-1">
</Body>
) : (
<ChevronRightIcon
key={i}
className={classnames(
"w-4 h-4 mx-1",
isHighlighted ? "text-white" : "text-slate-800 dark:text-white"
)}
/>
)
)}
</>
);
}
function SearchResultValue({
isHighlighted,
keyName,
stringValue,
searchResult,
}: {
isHighlighted: boolean;
keyName: string;
stringValue: string;
searchResult: Fuse.FuseResult<JsonSearchEntry>;
}) {
const match = (searchResult.matches ?? []).find(
(match) => match.key === keyName
);
const output = createOutputForMatch(stringValue, isHighlighted, match);
return (
<Mono
className={classnames(
"",
isHighlighted ? "text-white" : "text-slate-600 dark:text-slate-500"
)}
>
{output}
</Mono>
);
}
function createOutputForMatch(
stringValue: string,
isHighlighted: boolean,
match?: Fuse.FuseResultMatch,
maxLength: number = 68
): JSX.Element {
if (!match) {
return <>{truncate(stringValue, { length: maxLength })}</>;
}
const stringSlices = getStringSlices(stringValue, match.indices, maxLength);
return (
<>
{stringSlices.map((s, index) => {
return (
<span
key={index}
className={
s.isMatch
? classnames(
"",
isHighlighted
? "text-white underline underline-offset-1"
: "text-indigo-600 dark:text-indigo-400"
)
: ""
}
>
{s.slice}
</span>
);
})}
</>
);
}
+30
View File
@@ -0,0 +1,30 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import React, { ComponentPropsWithoutRef } from "react";
import { omit } from "lodash-es";
export const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
onOverlayClick?: () => void;
}
>(({ children, ...props }, forwardedRef) => (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
forceMount
className="fixed inset-0 z-20 bg-black/60"
onClick={() => {
props.onOverlayClick && props.onOverlayClick();
}}
/>
<DialogPrimitive.Content
forceMount
{...omit(props, "onOverlayClick")}
ref={forwardedRef}
>
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
));
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
+66
View File
@@ -0,0 +1,66 @@
/// <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);
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);
console.log("results", results);
self.postMessage({
type: "search-results",
payload: { results, query },
});
}
}
console.groupEnd();
};
+258
View File
@@ -0,0 +1,258 @@
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>[]; query: string };
};
export type SearchReceiveWorkerEvent =
| IndexInitializedEvent
| SearchResultsEvent;
export type JsonSearchApi = {
search: (query: string) => void;
reset: () => 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 ResetAction = {
type: "reset";
};
type JsonSearchAction = SearchReceiveWorkerEvent | SearchAction | ResetAction;
function reducer(
state: JsonSearchState,
action: JsonSearchAction
): JsonSearchState {
switch (state.status) {
case "initializing": {
if (action.type === "index-initialized") {
return {
...state,
status: "idle",
results: undefined,
};
}
return state;
}
case "idle": {
if (action.type === "reset") {
return {
...state,
query: undefined,
results: undefined,
};
}
if (action.type === "search") {
return {
...state,
status: "searching",
query: action.payload.query,
};
}
return state;
}
case "searching": {
if (action.type === "reset") {
return {
...state,
status: "idle",
query: undefined,
results: undefined,
};
}
if (
action.type === "search-results" &&
state.query === action.payload.query
) {
return {
...state,
status: "idle",
results: action.payload.results,
};
}
return state;
}
}
}
let lastAction: any | undefined;
function wrapReducer<S, A extends { type: string }>(
name: string,
reducer: React.Reducer<S, A>
): React.Reducer<S, A> {
return (state, action) => {
const next = reducer(state, action);
if (process.env.NODE_ENV !== "production") {
if (!lastAction) {
console.groupCollapsed(
`%cAction: %c${
name + " " + action.type
} %cat ${getCurrentTimeFormatted()}`,
"color: lightgreen; font-weight: bold;",
"color: white; font-weight: bold;",
"color: lightblue; font-weight: lighter;"
);
console.log(
"%cPrevious State:",
"color: #9E9E9E; font-weight: 700;",
state
);
console.log("%cAction:", "color: #00A7F7; font-weight: 700;", action);
console.log("%cNext State:", "color: #47B04B; font-weight: 700;", next);
console.groupEnd();
lastAction = action;
} else {
lastAction = undefined;
}
}
return next;
};
}
const getCurrentTimeFormatted = () => {
const currentTime = new Date();
const hours = currentTime.getHours();
const minutes = currentTime.getMinutes();
const seconds = currentTime.getSeconds();
const milliseconds = currentTime.getMilliseconds();
return `${hours}:${minutes}:${seconds}.${milliseconds}`;
};
export function JsonSearchProvider({
children,
}: {
children: React.ReactNode;
}) {
const [json] = useJson();
const [state, dispatch] = useReducer<
React.Reducer<JsonSearchState, JsonSearchAction>
>(wrapReducer("jsonSearch", reducer), { status: "initializing" });
const search = useCallback(
(query: string) => {
dispatch({ type: "search", payload: { query } });
},
[dispatch]
);
const reset = useCallback(() => {
dispatch({ type: "reset" });
}, [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: {
ignoreLocation: true,
includeScore: true,
includeMatches: true,
minMatchCharLength: 2,
isCaseSensitive: false,
ignoreFieldNorm: true,
},
},
});
}, [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, reset }}>
{children}
</JsonSearchApiContext.Provider>
</JsonSearchStateContext.Provider>
);
}
export function useJsonSearchState(): JsonSearchState {
return useContext(JsonSearchStateContext);
}
export function useJsonSearchApi(): JsonSearchApi {
return useContext(JsonSearchApiContext);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { inferType, JSONValueType } from "@jsonhero/json-infer-types";
import { JSONHeroPath } from "@jsonhero/path"; import { JSONHeroPath } from "@jsonhero/path";
import { IconComponent } from "~/useColumnView"; import { IconComponent } from "~/useColumnView";
import { formatValue } from "~/utilities/formatter"; import { formatValue } from "~/utilities/formatter";
import { iconForType } from "~/utilities/jsonColumnView"; import { iconForType } from "~/utilities/icons";
import { import {
createContext, createContext,
ReactNode, ReactNode,
+27 -24
View File
@@ -21,6 +21,7 @@ import { JsonSchemaProvider } from "~/hooks/useJsonSchema";
import { JsonView } from "~/components/JsonView"; import { JsonView } from "~/components/JsonView";
import safeFetch from "~/utilities/safeFetch"; import safeFetch from "~/utilities/safeFetch";
import { JsonTreeViewProvider } from "~/hooks/useJsonTree"; import { JsonTreeViewProvider } from "~/hooks/useJsonTree";
import { JsonSearchProvider } from "~/hooks/useJsonSearch";
export const loader: LoaderFunction = async ({ params, request }) => { export const loader: LoaderFunction = async ({ params, request }) => {
invariant(params.id, "expected params.id"); invariant(params.id, "expected params.id");
@@ -114,34 +115,36 @@ export default function JsonDocumentRoute() {
<JsonProvider initialJson={loaderData.json}> <JsonProvider initialJson={loaderData.json}>
<JsonSchemaProvider> <JsonSchemaProvider>
<JsonColumnViewProvider> <JsonColumnViewProvider>
<JsonTreeViewProvider overscan={25}> <JsonSearchProvider>
<div> <JsonTreeViewProvider overscan={25}>
<div className="h-screen flex flex-col"> <div>
<Header /> <div className="h-screen flex flex-col">
<div className="bg-slate-50 flex-grow transition dark:bg-slate-900"> <Header />
<div className="main-container flex justify-items-stretch h-full"> <div className="bg-slate-50 flex-grow transition dark:bg-slate-900">
<SideBar /> <div className="main-container flex justify-items-stretch h-full">
<JsonView> <SideBar />
<Outlet /> <JsonView>
</JsonView> <Outlet />
</JsonView>
<Resizable <Resizable
isHorizontal={true} isHorizontal={true}
initialSize={500} initialSize={500}
minimumSize={280} minimumSize={280}
maximumSize={900} maximumSize={900}
> >
<div className="info-panel flex-grow h-full"> <div className="info-panel flex-grow h-full">
<InfoPanel /> <InfoPanel />
</div> </div>
</Resizable> </Resizable>
</div>
</div> </div>
</div>
<Footer></Footer> <Footer></Footer>
</div>
</div> </div>
</div> </JsonTreeViewProvider>
</JsonTreeViewProvider> </JsonSearchProvider>
</JsonColumnViewProvider> </JsonColumnViewProvider>
</JsonSchemaProvider> </JsonSchemaProvider>
</JsonProvider> </JsonProvider>
+2 -2
View File
@@ -220,7 +220,7 @@ export function useColumnView({
selectedNodes, selectedNodes,
highlightedNodeId, highlightedNodeId,
highlightedPath, highlightedPath,
columns, columns: columns ?? [],
getColumnViewProps, getColumnViewProps,
canGoBack, canGoBack,
canGoForward, canGoForward,
@@ -256,7 +256,7 @@ export type ResetSelectionNodeAction = {
export type GoAction = { export type GoAction = {
type: "GO"; type: "GO";
direction: number; direction: -1 | 1;
}; };
export type ColumnViewAction = export type ColumnViewAction =
+16 -1
View File
@@ -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) { switch (type.name) {
case "array": { case "array": {
if (options?.leafNodesOnly) {
return;
}
if (type.value.length == 0) { if (type.value.length == 0) {
return formatRawValue(type); return formatRawValue(type);
} else if (type.value.length === 1) { } else if (type.value.length === 1) {
@@ -38,6 +49,10 @@ export function formatValue(type: JSONValueType): string | undefined {
} }
} }
case "object": { case "object": {
if (options?.leafNodesOnly) {
return;
}
if (Object.keys(type.value).length == 0) { if (Object.keys(type.value).length == 0) {
return formatRawValue(type); return formatRawValue(type);
} else if (Object.keys(type.value).length === 1) { } else if (Object.keys(type.value).length === 1) {
+154
View File
@@ -0,0 +1,154 @@
import {
CubeIcon,
CollectionIcon,
EyeOffIcon,
CheckCircleIcon,
AnnotationIcon,
CalendarIcon,
AtSymbolIcon,
GlobeAltIcon,
PhotographIcon,
CodeIcon,
PhoneIcon,
DocumentIcon,
ColorSwatchIcon,
CreditCardIcon,
CurrencyDollarIcon,
ClockIcon,
GlobeIcon,
EmojiHappyIcon,
ChatAlt2Icon,
ArchiveIcon,
IdentificationIcon,
KeyIcon,
DocumentTextIcon,
HashtagIcon,
} from "@heroicons/react/outline";
import { inferType, JSONValueType } from "@jsonhero/json-infer-types";
import { StringIcon } from "~/components/Icons/StringIcon";
import { IconComponent } from "~/useColumnView";
export function iconForValue(value: unknown): IconComponent {
return iconForType(inferType(value));
}
export function iconForType(type: JSONValueType): IconComponent {
switch (type.name) {
case "object": {
return CubeIcon;
}
case "array": {
return CollectionIcon;
}
case "null": {
return EyeOffIcon;
}
case "bool": {
return CheckCircleIcon;
}
case "int":
case "float": {
return HashtagIcon;
}
case "string": {
if (type.format == null) {
return StringIcon;
}
switch (type.format.name) {
case "timestamp": {
return CalendarIcon;
}
case "datetime": {
switch (type.format.parts) {
case "time":
return ClockIcon;
}
return ClockIcon;
}
case "email": {
return AtSymbolIcon;
}
case "hostname":
case "tld":
case "ip":
return GlobeAltIcon;
case "uri": {
switch (type.format.contentType) {
case "image/jpeg":
case "image/png":
case "image/gif":
case "image/webm":
return PhotographIcon;
case "application/json":
return CodeIcon;
default:
return GlobeAltIcon;
}
}
case "phoneNumber": {
return PhoneIcon;
}
case "currency": {
return CurrencyDollarIcon;
}
case "country": {
return GlobeIcon;
}
case "emoji": {
return EmojiHappyIcon;
}
case "color": {
return ColorSwatchIcon;
}
case "language": {
return ChatAlt2Icon;
}
case "filesize": {
return ArchiveIcon;
}
case "uuid": {
return IdentificationIcon;
}
case "json":
case "jsonPointer": {
return CodeIcon;
}
case "jwt": {
return KeyIcon;
}
case "semver": {
return DocumentTextIcon;
}
case "creditcard": {
switch (type.format.variant) {
case "visa": {
return CreditCardIcon;
}
case "mastercard": {
return CreditCardIcon;
}
case "amex": {
return CreditCardIcon;
}
case "discover": {
return CreditCardIcon;
}
case "dinersclub": {
return CreditCardIcon;
}
default: {
return CreditCardIcon;
}
}
}
default: {
return AnnotationIcon;
}
}
}
default: {
return DocumentIcon;
}
}
}
+2 -151
View File
@@ -1,36 +1,8 @@
import {
CubeIcon,
CollectionIcon,
EyeOffIcon,
CheckCircleIcon,
AnnotationIcon,
CalendarIcon,
AtSymbolIcon,
GlobeAltIcon,
PhotographIcon,
CodeIcon,
PhoneIcon,
DocumentIcon,
ColorSwatchIcon,
CreditCardIcon,
CurrencyDollarIcon,
ClockIcon,
GlobeIcon,
EmojiHappyIcon,
ChatAlt2Icon,
ArchiveIcon,
IdentificationIcon,
KeyIcon,
DocumentTextIcon,
HashtagIcon,
} from "@heroicons/react/outline";
import { inferType, JSONValueType } from "@jsonhero/json-infer-types"; import { inferType, JSONValueType } from "@jsonhero/json-infer-types";
import { JSONHeroPath, PathComponent } from "@jsonhero/path"; import { JSONHeroPath, PathComponent } from "@jsonhero/path";
import { ArrayIcon } from "~/components/Icons/ArrayIcon"; import { ColumnViewNode } from "~/useColumnView";
import { ObjectIcon } from "~/components/Icons/ObjectIcon";
import { StringIcon } from "~/components/Icons/StringIcon";
import { ColumnViewNode, IconComponent } from "~/useColumnView";
import { formatValue } from "./formatter"; import { formatValue } from "./formatter";
import { iconForType } from "./icons";
export function generateColumnViewNode(json: unknown): ColumnViewNode { export function generateColumnViewNode(json: unknown): ColumnViewNode {
const info = inferType(json); const info = inferType(json);
@@ -119,127 +91,6 @@ export function generateNodesToPath(
return nodes; return nodes;
} }
export function iconForType(type: JSONValueType): IconComponent {
switch (type.name) {
case "object": {
return CubeIcon;
}
case "array": {
return CollectionIcon;
}
case "null": {
return EyeOffIcon;
}
case "bool": {
return CheckCircleIcon;
}
case "int":
case "float": {
return HashtagIcon;
}
case "string": {
if (type.format == null) {
return StringIcon;
}
switch (type.format.name) {
case "timestamp": {
return CalendarIcon;
}
case "datetime": {
switch (type.format.parts) {
case "time":
return ClockIcon;
}
return ClockIcon;
}
case "email": {
return AtSymbolIcon;
}
case "hostname":
case "tld":
case "ip":
return GlobeAltIcon;
case "uri": {
switch (type.format.contentType) {
case "image/jpeg":
case "image/png":
case "image/gif":
case "image/webm":
return PhotographIcon;
case "application/json":
return CodeIcon;
default:
return GlobeAltIcon;
}
}
case "phoneNumber": {
return PhoneIcon;
}
case "currency": {
return CurrencyDollarIcon;
}
case "country": {
return GlobeIcon;
}
case "emoji": {
return EmojiHappyIcon;
}
case "color": {
return ColorSwatchIcon;
}
case "language": {
return ChatAlt2Icon;
}
case "filesize": {
return ArchiveIcon;
}
case "uuid": {
return IdentificationIcon;
}
case "json":
case "jsonPointer": {
return CodeIcon;
}
case "jwt": {
return KeyIcon;
}
case "semver": {
return DocumentTextIcon;
}
case "creditcard": {
switch (type.format.variant) {
case "visa": {
return CreditCardIcon;
}
case "mastercard": {
return CreditCardIcon;
}
case "amex": {
return CreditCardIcon;
}
case "discover": {
return CreditCardIcon;
}
case "dinersclub": {
return CreditCardIcon;
}
default: {
return CreditCardIcon;
}
}
}
default: {
return AnnotationIcon;
}
}
}
default: {
return DocumentIcon;
}
}
}
export function firstChildToDescendant( export function firstChildToDescendant(
ancestor: JSONHeroPath, ancestor: JSONHeroPath,
descendant: JSONHeroPath descendant: JSONHeroPath
+577
View File
@@ -0,0 +1,577 @@
import { JSONValueType, inferType } from "@jsonhero/json-infer-types";
import { JSONHeroPath } from "@jsonhero/path";
import { Array, Dict } from "@swan-io/boxed";
import Fuse from "fuse.js";
import { groupBy, replace, uniq } from "lodash-es";
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";
}
}
type StringSlice = {
isMatch: boolean;
slice: string;
};
// getStringSlices should scope to the largest match
// For example, if the windowSize is 56 and the stringValue is "This is a very long string and the largest matched range is outside of the window, so we should try and get only slices of the string that focus on the largest match"
// and the matches are [[10, 15], [80, 90], [100, 105]]
// then we should return slices:
// [
// { isMatch: false, slice: "…" }
// { isMatch: false, slice: "the string," },
// { isMatch: true, slice: ", we should" },
// { isMatch: false, slice: "d only retu" },
// { isMatch: true, slice: "urn sl" },
// { isMatch: false, slice: "lices that are within" },
// { isMatch: false, slice: "…" },
//
// If stringValue length is less than the windowSize, then we should return all the string slices of the string
export function getStringSlices(
stringValue: string,
matchingIndices: ReadonlyArray<[number, number]>,
windowSize: number
): Array<StringSlice> {
const slices: StringSlice[] = [];
const addSlice = (isMatch: boolean, slice: string) => {
if (slice.length > 0) {
slices.push({ isMatch, slice });
}
};
const addEllipsis = () => {
addSlice(false, "…");
};
const calculateWindow = (): { start: number; end: number } => {
if (stringValue.length <= windowSize) {
return { start: 0, end: stringValue.length };
}
const largestMatch = matchingIndices.reduce(
(largestMatch, match) => {
if (match[1] - match[0] > largestMatch[1] - largestMatch[0]) {
return match;
}
return largestMatch;
},
[0, 0]
);
const largestMatchLength = largestMatch[1] - largestMatch[0];
const start =
largestMatch[0] - Math.floor(windowSize / 2 - largestMatchLength / 2);
const end =
largestMatch[1] + Math.floor(windowSize / 2 - largestMatchLength / 2);
return {
start: Math.max(start, 0),
end: Math.min(end, stringValue.length),
};
};
const window = calculateWindow();
let currentIndex = window.start;
if (window.start > 0) {
addEllipsis();
}
for (const [start, end] of matchingIndices) {
if (start < window.start && end <= window.start) {
continue;
} else if (start >= window.end) {
continue;
} else if (start < window.start && end > window.start) {
addSlice(true, stringValue.slice(window.start, end + 1));
currentIndex = end + 1;
} else if (start >= window.start && end <= window.end) {
if (start > 0) {
addSlice(false, stringValue.slice(currentIndex, start));
}
addSlice(true, stringValue.slice(start, end + 1));
currentIndex = end + 1;
}
}
addSlice(false, stringValue.slice(currentIndex, window.end + 1).trimEnd());
if (window.end < stringValue.length - 1) {
addEllipsis();
}
return slices;
}
type EllispisSlice = {
type: "ellipsis";
};
type ComponentSlice = {
type: "component";
componentIndex: number;
slice: StringSlice;
};
type JoinSlice = {
type: "join";
};
export type PathSlice = EllispisSlice | ComponentSlice | JoinSlice;
// getComponentSlices returns slices that are either ellipsis or component
// and the component slices are the slices of the component string that, depending on the matchingIndices
//
// If the "weight" of the path is more than the window size, then we need to "hide" some of the component strings behind an ellipsis
// But the hidden components shouldn't be ones that are matched, sorted by the largest match first.
//
// The "weight" of the path is calculated by summing the length of the component strings + (8 * the number of components)
//
// Ellipsis is a special case where the weight is 2
//
// Example:
// path = records.0.users.9.addresses.0.street_address.street_name
// maxWeight = 60
// matchingIndices = [ [ 0, 1 ], [ 11, 14 ], [ 30, 35 ], [ 45, 50 ] ]
//
// Weight Calculation:
// records = 7
// . = 8
// 0 = 1
// . = 8
// users = 5
// . = 8
// 9 = 1
// . = 8
// addresses = 9
// . = 8
// 0 = 1
// . = 8
// street_address = 14
// . = 8
// street_name = 11
//
// Total Weight: 7 + 8 + 1 + 8 + 5 + 8 + 1 + 8 + 9 + 8 + 1 + 8 + 14 + 8 + 11 = 105
//
//
// To get below the maxWeight, we need to hide the components that are not the largest match
//
// Using the example from above, the result should be:
// records = 7
// . = 8
// … = 2
// . = 8
// street_address = 14
// . = 8
// street_name = 11
//
// Total Weight: 7 + 8 + 2 + 8 + 14 + 8 + 11 = 58
//
// So the result from getComponentSlices for the above example should be:
// [
// { type: "component", slice: { isMatch: true, slice: "re" } },
// { type: "component", slice: { isMatch: false, slice: "cords" } },
// { type: "join" },
// { type: "ellipsis" },
// { type: "join" },
// { type: "component", slice: { isMatch: true, slice: "street" } },
// { type: "component", slice: { isMatch: false, slice: "_address" } },
// { type: "join" },
// { type: "component", slice: { isMatch: true, slice: "street" } },
// { type: "component", slice: { isMatch: false, slice: "_name" } },
// ]
//
// Some rules:
// 1. We never have more than one ellipsis
// 2. We never hide the first component behind an ellipsis
// 3. We try to get the final weight to be as close to the maxWeight as possible
//
export function getComponentSlices(
path: string,
matchingIndices: ReadonlyArray<[number, number]>,
maxWeight: number
): Array<PathSlice> {
const calculateWeight = (pathSlices: PathSlice[]): number => {
let weight = 0;
for (const slice of pathSlices) {
weight += calculateSliceWeight(slice);
}
return weight;
};
const calculateSliceWeight = (slice: PathSlice): number => {
if (slice.type === "component") {
return slice.slice.slice.length;
} else if (slice.type === "ellipsis") {
return 2;
} else {
return 8;
}
};
const calculateLongestMatch = (componentSlices: ComponentSlice[]): number => {
return componentSlices.reduce(
(longestMatch, slice) =>
slice.slice.isMatch
? Math.max(longestMatch, slice.slice.slice.length)
: longestMatch,
0
);
};
const addEllipsisToSlices = (
slices: PathSlice[],
mostImportantComponentIndex: number
): PathSlice[] => {
// This should take an array of slices like this:
// [
// { type: "component", slice: { isMatch: true, slice: "re" } },
// { type: "component", slice: { isMatch: false, slice: "cords" } },
// { type: "join" },
// { type: "ellipsis" },
// { type: "join" },
// { type: "ellipsis" },
// { type: "ellipsis" },
// { type: "join" },
// { type: "component", slice: { isMatch: true, slice: "street" } },
// { type: "component", slice: { isMatch: false, slice: "_address" } },
// ]
//
//
// And should return this:
// [
// { type: "component", slice: { isMatch: true, slice: "re" } },
// { type: "component", slice: { isMatch: false, slice: "cords" } },
// { type: "join" },
// { type: "ellipsis" },
// { type: "join" },
// { type: "component", slice: { isMatch: true, slice: "street" } },
// { type: "component", slice: { isMatch: false, slice: "_address" } },
// ]
const combineAdjacentEllipsis = (toCombine: PathSlice[]): PathSlice[] => {
const combined: PathSlice[] = [];
let inEllipsis = false;
for (let i = 0; i < toCombine.length; i++) {
const slice = toCombine[i];
if (slice.type === "ellipsis") {
if (!inEllipsis) {
inEllipsis = true;
combined.push(slice);
}
}
if (slice.type === "join") {
if (!inEllipsis) {
combined.push(slice);
}
}
if (slice.type === "component") {
if (inEllipsis) {
combined.push({ type: "join" });
inEllipsis = false;
}
combined.push(slice);
}
}
return combined;
};
const replaceComponentIndexWithEllipsis = (
ellipsisComponentIndex: number
): PathSlice[] => {
const ellipsisSliceIndices = slices
.map((slice, index) => [slice, index] as [PathSlice, number])
.filter(
([slice, index]) =>
slice.type === "component" &&
slice.componentIndex === ellipsisComponentIndex
)
.map(([, index]) => index);
const newEllipsis = slices.map((slice, index) => {
if (ellipsisSliceIndices.includes(index)) {
return {
type: "ellipsis",
};
} else {
return slice;
}
}) as PathSlice[];
return combineAdjacentEllipsis(newEllipsis);
};
const componentSlices = Array.keepMap(slices, (slice) =>
slice.type === "component" ? slice : null
);
const componentIndexes = uniq(
componentSlices.map((slice) => slice.componentIndex)
);
const ellipsisIndex = slices.findIndex(
(slice) => slice.type === "ellipsis"
);
if (ellipsisIndex === -1) {
let ellipsisComponentIndex = 0;
// There are no ellipsis yet, so we need to figure out where to put the first one
if (mostImportantComponentIndex === 0) {
ellipsisComponentIndex = componentIndexes[componentIndexes.length - 2];
} else if (mostImportantComponentIndex === componentIndexes.length - 1) {
ellipsisComponentIndex = componentIndexes[1];
} else {
const halfWay = Math.floor(componentIndexes.length / 2);
if (mostImportantComponentIndex < halfWay) {
ellipsisComponentIndex = componentIndexes[halfWay + 1];
}
if (mostImportantComponentIndex > halfWay) {
ellipsisComponentIndex = componentIndexes[halfWay - 1];
}
if (mostImportantComponentIndex === halfWay) {
ellipsisComponentIndex = componentIndexes[1];
}
}
return replaceComponentIndexWithEllipsis(ellipsisComponentIndex);
} else {
// Add to the existing ellipsis
// Get nearest component index to the ellipsis, before and after
const nearestBefore = Array.keepMap(
slices.slice(0, ellipsisIndex).reverse(),
(slice) => (slice.type === "component" ? slice : null)
)[0];
const nearestAfter = Array.keepMap(
slices.slice(ellipsisIndex + 1),
(slice) => (slice.type === "component" ? slice : null)
)[0];
if (
nearestBefore.componentIndex !== 0 &&
nearestBefore.componentIndex !== mostImportantComponentIndex
) {
return replaceComponentIndexWithEllipsis(nearestBefore.componentIndex);
} else if (
nearestAfter.componentIndex !== 0 &&
nearestAfter.componentIndex !== mostImportantComponentIndex
) {
return replaceComponentIndexWithEllipsis(nearestAfter.componentIndex);
}
}
return slices;
};
let slices = createComponentSlices(path, matchingIndices);
let weight = calculateWeight(slices);
while (weight > maxWeight) {
const componentSlices = Array.keepMap(slices, (slice) =>
slice.type === "component" ? slice : null
);
const groupByComponentIndex = groupBy(
componentSlices,
(slice) => slice.componentIndex
);
const sortedByLongestMatch = Dict.entries(groupByComponentIndex).sort(
([, componentSlicesA], [, componentSlicesB]) => {
if (
calculateLongestMatch(componentSlicesA) >
calculateLongestMatch(componentSlicesB)
) {
return -1;
}
if (
calculateLongestMatch(componentSlicesB) >
calculateLongestMatch(componentSlicesA)
) {
return 1;
}
return 0;
}
);
const mostImportantComponentIndex = Number(sortedByLongestMatch[0][0]);
slices = addEllipsisToSlices(slices, mostImportantComponentIndex);
const newWeight = calculateWeight(slices);
// Just in case we can't shrink the weight any further
if (newWeight === weight) {
break;
}
weight = newWeight;
}
return slices;
}
export function createComponentSlices(
path: string,
matchingIndices: ReadonlyArray<[number, number]>
): Array<PathSlice> {
const slices: PathSlice[] = [];
const addComponent = (slice: StringSlice, componentIndex: number) => {
slices.push({ type: "component", componentIndex, slice });
};
const addJoin = () => {
slices.push({ type: "join" });
};
const addEllipsis = () => {
slices.push({ type: "ellipsis" });
};
const components = path.split(".");
let currentIndex = 0;
let currentComponentIndex = 0;
for (const component of components) {
if (currentComponentIndex !== 0) {
// Adds the "."
currentIndex += 1;
}
const endIndex = currentIndex + component.length;
// Example matchingIndices = [[0, 1], [6, 10], [12, 20]]
// currentIndex = 7
// endIndex = 7 + 6 = 13
const intersectingMatches = matchingIndices
.filter(
([start, end]) =>
(currentIndex >= start && currentIndex <= end) ||
(endIndex >= start && endIndex <= end) ||
(start >= currentIndex && end <= endIndex)
)
.map(
([start, end]) =>
[Math.max(start - currentIndex, 0), end - currentIndex] as [
number,
number
]
);
const stringSlices = getStringSlices(
component,
intersectingMatches,
component.length + 1
);
for (const stringSlice of stringSlices) {
addComponent(stringSlice, currentComponentIndex);
}
if (currentComponentIndex + 1 < components.length) {
addJoin();
}
currentComponentIndex += 1;
currentIndex = endIndex;
}
return slices;
}
+201 -13
View File
@@ -16,16 +16,20 @@
"@jsonhero/json-schema-fns": "^0.0.1", "@jsonhero/json-schema-fns": "^0.0.1",
"@jsonhero/path": "^1.0.17", "@jsonhero/path": "^1.0.17",
"@jsonhero/schema-infer": "^0.1.x", "@jsonhero/schema-infer": "^0.1.x",
"@radix-ui/react-dialog": "^0.1.7",
"@radix-ui/react-popover": "^0.1.5", "@radix-ui/react-popover": "^0.1.5",
"@radix-ui/react-tabs": "^0.1.5", "@radix-ui/react-tabs": "^0.1.5",
"@remix-run/cloudflare-workers": "^1.2.3", "@remix-run/cloudflare-workers": "^1.2.3",
"@remix-run/react": "^1.2.3", "@remix-run/react": "^1.2.3",
"@remix-run/serve": "^1.2.3", "@remix-run/serve": "^1.2.3",
"@swan-io/boxed": "^0.2.1",
"@uiw/react-codemirror": "^4.3.3", "@uiw/react-codemirror": "^4.3.3",
"clsx": "^1.1.1", "clsx": "^1.1.1",
"color": "^4.2.1", "color": "^4.2.1",
"downshift": "^6.1.7",
"fathom-client": "^3.4.1", "fathom-client": "^3.4.1",
"framer-motion": "^6.2.4", "framer-motion": "^6.2.4",
"fuse.js": "^6.5.3",
"json-source-map": "^0.6.1", "json-source-map": "^0.6.1",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
@@ -57,7 +61,7 @@
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"tailwindcss": "^3.0.22", "tailwindcss": "^3.0.22",
"ts-jest": "^27.1.3", "ts-jest": "^27.1.3",
"typescript": "^4.1.2" "typescript": "^4.6.x"
}, },
"engines": { "engines": {
"node": ">=14" "node": ">=14"
@@ -1701,9 +1705,9 @@
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
}, },
"node_modules/@jsonhero/json-infer-types": { "node_modules/@jsonhero/json-infer-types": {
"version": "1.2.8", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@jsonhero/json-infer-types/-/json-infer-types-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@jsonhero/json-infer-types/-/json-infer-types-1.2.9.tgz",
"integrity": "sha512-a0Ish/uHFI1AJcwpiCnK73uA2/XceffeysiqMJaX3V7AwUs5Xox1s/zjiUix959JLWbHnxsQDPPL1M8j560lyw==", "integrity": "sha512-QuIOEy7M57bB4fhwadtDUQv5HjrUFD9SXjvQfWUMSQ6zOL8l9S9SV/wRQCn0y0dal+xZPBoMoDqTMJyEBIHnQQ==",
"dependencies": { "dependencies": {
"ip-address": "^8.1.0", "ip-address": "^8.1.0",
"json5": "^2.2.0", "json5": "^2.2.0",
@@ -2151,6 +2155,61 @@
"react": "^16.8 || ^17.0" "react": "^16.8 || ^17.0"
} }
}, },
"node_modules/@radix-ui/react-dialog": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-0.1.7.tgz",
"integrity": "sha512-jXt8srGhHBRvEr9jhEAiwwJzWCWZoGRJ030aC9ja/gkRJbZdy0iD3FwXf+Ff4RtsZyLUMHW7VUwFOlz3Ixe1Vw==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "0.1.0",
"@radix-ui/react-compose-refs": "0.1.0",
"@radix-ui/react-context": "0.1.1",
"@radix-ui/react-dismissable-layer": "0.1.5",
"@radix-ui/react-focus-guards": "0.1.0",
"@radix-ui/react-focus-scope": "0.1.4",
"@radix-ui/react-id": "0.1.5",
"@radix-ui/react-portal": "0.1.4",
"@radix-ui/react-presence": "0.1.2",
"@radix-ui/react-primitive": "0.1.4",
"@radix-ui/react-slot": "0.1.2",
"@radix-ui/react-use-controllable-state": "0.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "^2.4.0"
},
"peerDependencies": {
"react": "^16.8 || ^17.0",
"react-dom": "^16.8 || ^17.0"
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz",
"integrity": "sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "0.1.0",
"@radix-ui/react-compose-refs": "0.1.0",
"@radix-ui/react-primitive": "0.1.4",
"@radix-ui/react-use-body-pointer-events": "0.1.1",
"@radix-ui/react-use-callback-ref": "0.1.0",
"@radix-ui/react-use-escape-keydown": "0.1.0"
},
"peerDependencies": {
"react": "^16.8 || ^17.0"
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-body-pointer-events": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz",
"integrity": "sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==",
"dependencies": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-use-layout-effect": "0.1.0"
},
"peerDependencies": {
"react": "^16.8 || ^17.0"
}
},
"node_modules/@radix-ui/react-dismissable-layer": { "node_modules/@radix-ui/react-dismissable-layer": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.4.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.4.tgz",
@@ -2604,6 +2663,11 @@
"@sinonjs/commons": "^1.7.0" "@sinonjs/commons": "^1.7.0"
} }
}, },
"node_modules/@swan-io/boxed": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@swan-io/boxed/-/boxed-0.2.1.tgz",
"integrity": "sha512-snqcLR1PiEg1JverBDYK6s07/L3jbZz/shfNYhoeTzQ0pnv2PcsXskuFGDpRhFOczSqYFCJsns8wsBkNRtctXA=="
},
"node_modules/@szmarczak/http-timer": { "node_modules/@szmarczak/http-timer": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
@@ -4110,6 +4174,11 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/compute-scroll-into-view": {
"version": "1.0.17",
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz",
"integrity": "sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg=="
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4719,6 +4788,31 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/downshift": {
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/downshift/-/downshift-6.1.7.tgz",
"integrity": "sha512-cVprZg/9Lvj/uhYRxELzlu1aezRcgPWBjTvspiGTVEU64gF5pRdSRKFVLcxqsZC637cLAGMbL40JavEfWnqgNg==",
"dependencies": {
"@babel/runtime": "^7.14.8",
"compute-scroll-into-view": "^1.0.17",
"prop-types": "^15.7.2",
"react-is": "^17.0.2",
"tslib": "^2.3.0"
},
"peerDependencies": {
"react": ">=16.12.0"
}
},
"node_modules/downshift/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
"node_modules/downshift/node_modules/tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -5869,6 +5963,14 @@
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" "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": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -12735,9 +12837,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "4.5.5", "version": "4.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz",
"integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==", "integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==",
"dev": true, "dev": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -14777,9 +14879,9 @@
} }
}, },
"@jsonhero/json-infer-types": { "@jsonhero/json-infer-types": {
"version": "1.2.8", "version": "1.2.9",
"resolved": "https://registry.npmjs.org/@jsonhero/json-infer-types/-/json-infer-types-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@jsonhero/json-infer-types/-/json-infer-types-1.2.9.tgz",
"integrity": "sha512-a0Ish/uHFI1AJcwpiCnK73uA2/XceffeysiqMJaX3V7AwUs5Xox1s/zjiUix959JLWbHnxsQDPPL1M8j560lyw==", "integrity": "sha512-QuIOEy7M57bB4fhwadtDUQv5HjrUFD9SXjvQfWUMSQ6zOL8l9S9SV/wRQCn0y0dal+xZPBoMoDqTMJyEBIHnQQ==",
"requires": { "requires": {
"ip-address": "^8.1.0", "ip-address": "^8.1.0",
"json5": "^2.2.0", "json5": "^2.2.0",
@@ -15119,6 +15221,53 @@
"@babel/runtime": "^7.13.10" "@babel/runtime": "^7.13.10"
} }
}, },
"@radix-ui/react-dialog": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-0.1.7.tgz",
"integrity": "sha512-jXt8srGhHBRvEr9jhEAiwwJzWCWZoGRJ030aC9ja/gkRJbZdy0iD3FwXf+Ff4RtsZyLUMHW7VUwFOlz3Ixe1Vw==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "0.1.0",
"@radix-ui/react-compose-refs": "0.1.0",
"@radix-ui/react-context": "0.1.1",
"@radix-ui/react-dismissable-layer": "0.1.5",
"@radix-ui/react-focus-guards": "0.1.0",
"@radix-ui/react-focus-scope": "0.1.4",
"@radix-ui/react-id": "0.1.5",
"@radix-ui/react-portal": "0.1.4",
"@radix-ui/react-presence": "0.1.2",
"@radix-ui/react-primitive": "0.1.4",
"@radix-ui/react-slot": "0.1.2",
"@radix-ui/react-use-controllable-state": "0.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "^2.4.0"
},
"dependencies": {
"@radix-ui/react-dismissable-layer": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.5.tgz",
"integrity": "sha512-J+fYWijkX4M4QKwf9dtu1oC0U6e6CEl8WhBp3Ad23yz2Hia0XCo6Pk/mp5CAFy4QBtQedTSkhW05AdtSOEoajQ==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "0.1.0",
"@radix-ui/react-compose-refs": "0.1.0",
"@radix-ui/react-primitive": "0.1.4",
"@radix-ui/react-use-body-pointer-events": "0.1.1",
"@radix-ui/react-use-callback-ref": "0.1.0",
"@radix-ui/react-use-escape-keydown": "0.1.0"
}
},
"@radix-ui/react-use-body-pointer-events": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.1.tgz",
"integrity": "sha512-R8leV2AWmJokTmERM8cMXFHWSiv/fzOLhG/JLmRBhLTAzOj37EQizssq4oW0Z29VcZy2tODMi9Pk/htxwb+xpA==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-use-layout-effect": "0.1.0"
}
}
}
},
"@radix-ui/react-dismissable-layer": { "@radix-ui/react-dismissable-layer": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.4.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.4.tgz",
@@ -15485,6 +15634,11 @@
"@sinonjs/commons": "^1.7.0" "@sinonjs/commons": "^1.7.0"
} }
}, },
"@swan-io/boxed": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@swan-io/boxed/-/boxed-0.2.1.tgz",
"integrity": "sha512-snqcLR1PiEg1JverBDYK6s07/L3jbZz/shfNYhoeTzQ0pnv2PcsXskuFGDpRhFOczSqYFCJsns8wsBkNRtctXA=="
},
"@szmarczak/http-timer": { "@szmarczak/http-timer": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
@@ -16710,6 +16864,11 @@
"vary": "~1.1.2" "vary": "~1.1.2"
} }
}, },
"compute-scroll-into-view": {
"version": "1.0.17",
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz",
"integrity": "sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg=="
},
"concat-map": { "concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -17167,6 +17326,30 @@
"integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==",
"dev": true "dev": true
}, },
"downshift": {
"version": "6.1.7",
"resolved": "https://registry.npmjs.org/downshift/-/downshift-6.1.7.tgz",
"integrity": "sha512-cVprZg/9Lvj/uhYRxELzlu1aezRcgPWBjTvspiGTVEU64gF5pRdSRKFVLcxqsZC637cLAGMbL40JavEfWnqgNg==",
"requires": {
"@babel/runtime": "^7.14.8",
"compute-scroll-into-view": "^1.0.17",
"prop-types": "^15.7.2",
"react-is": "^17.0.2",
"tslib": "^2.3.0"
},
"dependencies": {
"react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
"tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
}
}
},
"ee-first": { "ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -17945,6 +18128,11 @@
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" "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": { "gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -22902,9 +23090,9 @@
} }
}, },
"typescript": { "typescript": {
"version": "4.5.5", "version": "4.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz",
"integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==", "integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==",
"dev": true "dev": true
}, },
"unbox-primitive": { "unbox-primitive": {
+10 -4
View File
@@ -5,14 +5,16 @@
"license": "apache-2.0", "license": "apache-2.0",
"scripts": { "scripts": {
"clean": "rimraf dist", "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: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": "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: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: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", "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", "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", "dev:css": "tailwindcss -i ./styles/tailwind.css -o ./app/tailwind.css --watch",
"postinstall": "remix setup cloudflare-workers", "postinstall": "remix setup cloudflare-workers",
"start": "concurrently \"npm run dev\" \"npm run start:worker\"", "start": "concurrently \"npm run dev\" \"npm run start:worker\"",
@@ -33,16 +35,20 @@
"@jsonhero/json-schema-fns": "^0.0.1", "@jsonhero/json-schema-fns": "^0.0.1",
"@jsonhero/path": "^1.0.17", "@jsonhero/path": "^1.0.17",
"@jsonhero/schema-infer": "^0.1.x", "@jsonhero/schema-infer": "^0.1.x",
"@radix-ui/react-dialog": "^0.1.7",
"@radix-ui/react-popover": "^0.1.5", "@radix-ui/react-popover": "^0.1.5",
"@radix-ui/react-tabs": "^0.1.5", "@radix-ui/react-tabs": "^0.1.5",
"@remix-run/cloudflare-workers": "^1.2.3", "@remix-run/cloudflare-workers": "^1.2.3",
"@remix-run/react": "^1.2.3", "@remix-run/react": "^1.2.3",
"@remix-run/serve": "^1.2.3", "@remix-run/serve": "^1.2.3",
"@swan-io/boxed": "^0.2.1",
"@uiw/react-codemirror": "^4.3.3", "@uiw/react-codemirror": "^4.3.3",
"clsx": "^1.1.1", "clsx": "^1.1.1",
"color": "^4.2.1", "color": "^4.2.1",
"downshift": "^6.1.7",
"fathom-client": "^3.4.1", "fathom-client": "^3.4.1",
"framer-motion": "^6.2.4", "framer-motion": "^6.2.4",
"fuse.js": "^6.5.3",
"json-source-map": "^0.6.1", "json-source-map": "^0.6.1",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
@@ -74,7 +80,7 @@
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"tailwindcss": "^3.0.22", "tailwindcss": "^3.0.22",
"ts-jest": "^27.1.3", "ts-jest": "^27.1.3",
"typescript": "^4.1.2" "typescript": "^4.6.x"
}, },
"engines": { "engines": {
"node": ">=14" "node": ">=14"
@@ -99,4 +105,4 @@
}, },
"sideEffects": false, "sideEffects": false,
"main": "dist/worker.js" "main": "dist/worker.js"
} }
+4 -4
View File
@@ -6,10 +6,10 @@ module.exports = {
tiny: ".5rem", // 8px tiny: ".5rem", // 8px
xs: ".625rem", // 10px xs: ".625rem", // 10px
sm: ".75rem", // 12px (SmallBody) sm: ".75rem", // 12px (SmallBody)
base: ".875rem", // 14px (Body) <p> base: ".875rem", // 14px (Body) <p>
lg: "1rem", // 16px (SmallTitle) <h3> lg: "1rem", // 16px (SmallTitle) <h3>
xl: "1.125rem", // 18px (Title) <h2> xl: "1.125rem", // 18px (Title) <h2>
"2xl": "1.375rem", // 22px (LargeTitle) <h1> "2xl": "1.375rem", // 22px (LargeTitle) <h1>
"3xl": "1.5rem", // 24px "3xl": "1.5rem", // 24px
"4xl": "1.875rem", // 30px "4xl": "1.875rem", // 30px
"5xl": "2rem", // 32px "5xl": "2rem", // 32px
+618
View File
@@ -0,0 +1,618 @@
import Fuse from "fuse.js";
import {
createSearchEntries,
createSearchIndex,
getComponentSlices,
getStringSlices,
} 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("getComponentSlices", () => {
it("returns the correct slices for a path that DOES go above the maxWeight even without any matches", () => {
const slices = getComponentSlices(
"records.0.users.9.addresses.0.street_address.street_name",
[],
60
);
expect(slices).toMatchInlineSnapshot(`
Array [
Object {
"componentIndex": 0,
"slice": Object {
"isMatch": false,
"slice": "records",
},
"type": "component",
},
Object {
"type": "join",
},
Object {
"componentIndex": 1,
"slice": Object {
"isMatch": false,
"slice": "0",
},
"type": "component",
},
Object {
"type": "join",
},
Object {
"componentIndex": 2,
"slice": Object {
"isMatch": false,
"slice": "users",
},
"type": "component",
},
Object {
"type": "join",
},
Object {
"type": "ellipsis",
},
Object {
"type": "join",
},
Object {
"componentIndex": 7,
"slice": Object {
"isMatch": false,
"slice": "street_name",
},
"type": "component",
},
]
`);
});
it("returns the correct slices for a path that DOES go above the maxWeight", () => {
const slices = getComponentSlices(
"records.0.users.9.addresses.0.street_address.street_name",
[
[0, 1],
[11, 14],
[30, 35],
[45, 50],
],
60
);
expect(slices).toMatchInlineSnapshot(`
Array [
Object {
"componentIndex": 0,
"slice": Object {
"isMatch": true,
"slice": "re",
},
"type": "component",
},
Object {
"componentIndex": 0,
"slice": Object {
"isMatch": false,
"slice": "cords",
},
"type": "component",
},
Object {
"type": "join",
},
Object {
"type": "ellipsis",
},
Object {
"type": "join",
},
Object {
"componentIndex": 6,
"slice": Object {
"isMatch": true,
"slice": "street",
},
"type": "component",
},
Object {
"componentIndex": 6,
"slice": Object {
"isMatch": false,
"slice": "_address",
},
"type": "component",
},
Object {
"type": "join",
},
Object {
"componentIndex": 7,
"slice": Object {
"isMatch": true,
"slice": "street",
},
"type": "component",
},
Object {
"componentIndex": 7,
"slice": Object {
"isMatch": false,
"slice": "_name",
},
"type": "component",
},
]
`);
});
it("returns the correct slices for a path that does not go above the maxWeight", () => {
const slices = getComponentSlices("records.0.users", [[0, 3]], 70);
expect(slices).toMatchInlineSnapshot(`
Array [
Object {
"componentIndex": 0,
"slice": Object {
"isMatch": true,
"slice": "reco",
},
"type": "component",
},
Object {
"componentIndex": 0,
"slice": Object {
"isMatch": false,
"slice": "rds",
},
"type": "component",
},
Object {
"type": "join",
},
Object {
"componentIndex": 1,
"slice": Object {
"isMatch": false,
"slice": "0",
},
"type": "component",
},
Object {
"type": "join",
},
Object {
"componentIndex": 2,
"slice": Object {
"isMatch": false,
"slice": "users",
},
"type": "component",
},
]
`);
});
});
describe("getStringSlices", () => {
it("returns a slice for each part of the string based on the matches", () => {
const slices = getStringSlices(
"This is a really great (short) string",
[[10, 15]],
60
);
expect(slices).toMatchInlineSnapshot(`
Array [
Object {
"isMatch": false,
"slice": "This is a ",
},
Object {
"isMatch": true,
"slice": "really",
},
Object {
"isMatch": false,
"slice": " great (short) string",
},
]
`);
});
it("returns a subset of the string when the matched ranges are outside the window", () => {
const slices = getStringSlices(
"This is a very long string and the largest matched range is outside of the window, so we should try and get only slices of the string that focus on the largest match",
[
[10, 15],
[80, 90],
[100, 105],
],
60
);
expect(slices).toMatchInlineSnapshot(`
Array [
Object {
"isMatch": false,
"slice": "…",
},
Object {
"isMatch": false,
"slice": "e is outside of the windo",
},
Object {
"isMatch": true,
"slice": "w, so we sh",
},
Object {
"isMatch": false,
"slice": "ould try ",
},
Object {
"isMatch": true,
"slice": "and ge",
},
Object {
"isMatch": false,
"slice": "t only sli",
},
Object {
"isMatch": false,
"slice": "…",
},
]
`);
const slices2 = getStringSlices(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
[
[0, 3],
[12, 16],
[103, 108],
[302, 307],
],
56
);
expect(slices2).toMatchInlineSnapshot(`
Array [
Object {
"isMatch": false,
"slice": "…",
},
Object {
"isMatch": false,
"slice": " incididunt ut labore et ",
},
Object {
"isMatch": true,
"slice": "dolore",
},
Object {
"isMatch": false,
"slice": " magna aliqua. Ut enim ad",
},
Object {
"isMatch": false,
"slice": "…",
},
]
`);
});
});
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",
},
]
`);
});
});