Squashed commit of the following:
commit09bed34946Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 8 14:03:50 2022 +0100 Fixed the layout of a search list item commit90126d43adAuthor: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 17:25:13 2022 +0100 Insanely complicated path search results highlighitng and truncating commita4bbd5521eAuthor: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 14:05:01 2022 +0100 Improved the title editing in the header commit5814053968Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 13:38:33 2022 +0100 Improved text highlight colour commit110de37242Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 13:36:57 2022 +0100 Icon hover state now uses isHighlighted commitfb6da943d9Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 13:31:11 2022 +0100 Light mode styling commit2c378d5158Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 12:24:43 2022 +0100 Spacing between search items no longer a hack commit85d90bb0aeAuthor: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 12:19:10 2022 +0100 Added ellipsis to search matches if they are windowed commit91f3e70676Author: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 12:08:00 2022 +0100 Fixed search item icons commite384c87c6fAuthor: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 11:52:58 2022 +0100 Improved hover state commitf4681edc07Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Apr 1 11:47:33 2022 +0100 Removed focus state on button commitfaa2d0663cAuthor: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 11:26:42 2022 +0100 made the search palette a little bigger commitdf8bd521feAuthor: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 11:26:14 2022 +0100 Remove onOverlayClick prop commitecd046b3b2Author: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 11:18:46 2022 +0100 Close search when the overlay is clicked commitfba330b5d9Author: 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 commitfc2fe8ae6fAuthor: 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 commit45193ec92cAuthor: 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 commit0de5fef7e9Author: 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 commit167b548343Author: Eric Allam <eallam@icloud.com> Date: Fri Apr 1 09:30:33 2022 +0100 WIP implementing search commit5655631f12Author: James Ritchie <james@jamesritchie.co.uk> Date: Fri Mar 25 15:28:19 2022 +0000 Build and styled search modal commite368db81c8Author: Eric Allam <eallam@icloud.com> Date: Fri Mar 25 14:42:03 2022 +0000 Implement JSON search through fuse.js + web worker + react hook commit8caa11ba1cAuthor: Eric Allam <eallam@icloud.com> Date: Wed Mar 23 10:21:33 2022 +0000 Start of the Command Palette using radix-ui Dialog commit904b64d781Author: 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:
+2
-1
@@ -10,4 +10,5 @@ node_modules
|
||||
/dist
|
||||
.mf
|
||||
/meta.json
|
||||
/stats.html
|
||||
/stats.html
|
||||
public/entry.worker.js
|
||||
@@ -27,26 +27,24 @@ export function DocumentTitle() {
|
||||
className="flex justify-center items-center w-full"
|
||||
title={doc.title}
|
||||
>
|
||||
<input
|
||||
ref={ref}
|
||||
className={
|
||||
"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"
|
||||
}
|
||||
type="text"
|
||||
name="title"
|
||||
spellCheck="false"
|
||||
placeholder="Name your JSON file"
|
||||
value={editedTitle}
|
||||
onChange={(e) => setEditedTitle(e.target.value)}
|
||||
/>
|
||||
<label className="relative block group">
|
||||
<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" />
|
||||
<input
|
||||
ref={ref}
|
||||
className={
|
||||
"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"
|
||||
}
|
||||
type="text"
|
||||
name="title"
|
||||
spellCheck="false"
|
||||
placeholder="Name your JSON file"
|
||||
value={editedTitle}
|
||||
onChange={(e) => setEditedTitle(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{match(editedTitle)
|
||||
.with(doc.title, () => (
|
||||
<PencilAltIcon
|
||||
className="h-5 w-5 text-black text-opacity-50"
|
||||
onClick={startEditing}
|
||||
/>
|
||||
))
|
||||
.with(doc.title, () => <p className="ml-2 text-transparent">Save</p>)
|
||||
.with("", () => (
|
||||
<button
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export function JsonView({ children }: { children: React.ReactNode }) {
|
||||
<div className="flex-1 pr-2 min-w-0">
|
||||
<PathBar />
|
||||
</div>
|
||||
{/* <SearchBar /> */}
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
@@ -1,26 +1,66 @@
|
||||
import { SearchIcon } from "@heroicons/react/outline";
|
||||
import { ShortcutIcon } from "./Icons/ShortcutIcon";
|
||||
import { Body } from "./Primitives/Body";
|
||||
import { Dialog, DialogTrigger, DialogContent } from "./UI/Dialog";
|
||||
|
||||
export type SearchBarProps = {
|
||||
className?: string;
|
||||
};
|
||||
import classnames from "~/utilities/classnames";
|
||||
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 (
|
||||
<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">
|
||||
<div className="flex items-center pl-2">
|
||||
<SearchIcon className="w-4 h-4 mr-1" />
|
||||
<Body>Search…</Body>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pr-2">
|
||||
<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">
|
||||
⌘
|
||||
</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>
|
||||
<Dialog open={isOpen}>
|
||||
<DialogTrigger
|
||||
className="focus:outline-none focus-visible:outline-none"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<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">
|
||||
<div className="flex items-center pl-1">
|
||||
<SearchIcon className="w-4 h-4 mr-1" />
|
||||
<Body>Search…</Body>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pr-1">
|
||||
<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">
|
||||
⌘
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { inferType, JSONValueType } from "@jsonhero/json-infer-types";
|
||||
import { JSONHeroPath } from "@jsonhero/path";
|
||||
import { IconComponent } from "~/useColumnView";
|
||||
import { formatValue } from "~/utilities/formatter";
|
||||
import { iconForType } from "~/utilities/jsonColumnView";
|
||||
import { iconForType } from "~/utilities/icons";
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
|
||||
+27
-24
@@ -21,6 +21,7 @@ import { JsonSchemaProvider } from "~/hooks/useJsonSchema";
|
||||
import { JsonView } from "~/components/JsonView";
|
||||
import safeFetch from "~/utilities/safeFetch";
|
||||
import { JsonTreeViewProvider } from "~/hooks/useJsonTree";
|
||||
import { JsonSearchProvider } from "~/hooks/useJsonSearch";
|
||||
|
||||
export const loader: LoaderFunction = async ({ params, request }) => {
|
||||
invariant(params.id, "expected params.id");
|
||||
@@ -114,34 +115,36 @@ export default function JsonDocumentRoute() {
|
||||
<JsonProvider initialJson={loaderData.json}>
|
||||
<JsonSchemaProvider>
|
||||
<JsonColumnViewProvider>
|
||||
<JsonTreeViewProvider overscan={25}>
|
||||
<div>
|
||||
<div className="h-screen flex flex-col">
|
||||
<Header />
|
||||
<div className="bg-slate-50 flex-grow transition dark:bg-slate-900">
|
||||
<div className="main-container flex justify-items-stretch h-full">
|
||||
<SideBar />
|
||||
<JsonView>
|
||||
<Outlet />
|
||||
</JsonView>
|
||||
<JsonSearchProvider>
|
||||
<JsonTreeViewProvider overscan={25}>
|
||||
<div>
|
||||
<div className="h-screen flex flex-col">
|
||||
<Header />
|
||||
<div className="bg-slate-50 flex-grow transition dark:bg-slate-900">
|
||||
<div className="main-container flex justify-items-stretch h-full">
|
||||
<SideBar />
|
||||
<JsonView>
|
||||
<Outlet />
|
||||
</JsonView>
|
||||
|
||||
<Resizable
|
||||
isHorizontal={true}
|
||||
initialSize={500}
|
||||
minimumSize={280}
|
||||
maximumSize={900}
|
||||
>
|
||||
<div className="info-panel flex-grow h-full">
|
||||
<InfoPanel />
|
||||
</div>
|
||||
</Resizable>
|
||||
<Resizable
|
||||
isHorizontal={true}
|
||||
initialSize={500}
|
||||
minimumSize={280}
|
||||
maximumSize={900}
|
||||
>
|
||||
<div className="info-panel flex-grow h-full">
|
||||
<InfoPanel />
|
||||
</div>
|
||||
</Resizable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer></Footer>
|
||||
<Footer></Footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</JsonTreeViewProvider>
|
||||
</JsonTreeViewProvider>
|
||||
</JsonSearchProvider>
|
||||
</JsonColumnViewProvider>
|
||||
</JsonSchemaProvider>
|
||||
</JsonProvider>
|
||||
|
||||
@@ -220,7 +220,7 @@ export function useColumnView({
|
||||
selectedNodes,
|
||||
highlightedNodeId,
|
||||
highlightedPath,
|
||||
columns,
|
||||
columns: columns ?? [],
|
||||
getColumnViewProps,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
@@ -256,7 +256,7 @@ export type ResetSelectionNodeAction = {
|
||||
|
||||
export type GoAction = {
|
||||
type: "GO";
|
||||
direction: number;
|
||||
direction: -1 | 1;
|
||||
};
|
||||
|
||||
export type ColumnViewAction =
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { 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 { ColumnViewNode } from "~/useColumnView";
|
||||
import { formatValue } from "./formatter";
|
||||
import { iconForType } from "./icons";
|
||||
|
||||
export function generateColumnViewNode(json: unknown): ColumnViewNode {
|
||||
const info = inferType(json);
|
||||
@@ -119,127 +91,6 @@ export function generateNodesToPath(
|
||||
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(
|
||||
ancestor: JSONHeroPath,
|
||||
descendant: JSONHeroPath
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+201
-13
@@ -16,16 +16,20 @@
|
||||
"@jsonhero/json-schema-fns": "^0.0.1",
|
||||
"@jsonhero/path": "^1.0.17",
|
||||
"@jsonhero/schema-infer": "^0.1.x",
|
||||
"@radix-ui/react-dialog": "^0.1.7",
|
||||
"@radix-ui/react-popover": "^0.1.5",
|
||||
"@radix-ui/react-tabs": "^0.1.5",
|
||||
"@remix-run/cloudflare-workers": "^1.2.3",
|
||||
"@remix-run/react": "^1.2.3",
|
||||
"@remix-run/serve": "^1.2.3",
|
||||
"@swan-io/boxed": "^0.2.1",
|
||||
"@uiw/react-codemirror": "^4.3.3",
|
||||
"clsx": "^1.1.1",
|
||||
"color": "^4.2.1",
|
||||
"downshift": "^6.1.7",
|
||||
"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",
|
||||
@@ -57,7 +61,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"
|
||||
@@ -1701,9 +1705,9 @@
|
||||
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
|
||||
},
|
||||
"node_modules/@jsonhero/json-infer-types": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@jsonhero/json-infer-types/-/json-infer-types-1.2.8.tgz",
|
||||
"integrity": "sha512-a0Ish/uHFI1AJcwpiCnK73uA2/XceffeysiqMJaX3V7AwUs5Xox1s/zjiUix959JLWbHnxsQDPPL1M8j560lyw==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@jsonhero/json-infer-types/-/json-infer-types-1.2.9.tgz",
|
||||
"integrity": "sha512-QuIOEy7M57bB4fhwadtDUQv5HjrUFD9SXjvQfWUMSQ6zOL8l9S9SV/wRQCn0y0dal+xZPBoMoDqTMJyEBIHnQQ==",
|
||||
"dependencies": {
|
||||
"ip-address": "^8.1.0",
|
||||
"json5": "^2.2.0",
|
||||
@@ -2151,6 +2155,61 @@
|
||||
"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": {
|
||||
"version": "0.1.4",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
|
||||
@@ -4110,6 +4174,11 @@
|
||||
"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": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -4719,6 +4788,31 @@
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"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",
|
||||
"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",
|
||||
@@ -12735,9 +12837,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",
|
||||
@@ -14777,9 +14879,9 @@
|
||||
}
|
||||
},
|
||||
"@jsonhero/json-infer-types": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@jsonhero/json-infer-types/-/json-infer-types-1.2.8.tgz",
|
||||
"integrity": "sha512-a0Ish/uHFI1AJcwpiCnK73uA2/XceffeysiqMJaX3V7AwUs5Xox1s/zjiUix959JLWbHnxsQDPPL1M8j560lyw==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@jsonhero/json-infer-types/-/json-infer-types-1.2.9.tgz",
|
||||
"integrity": "sha512-QuIOEy7M57bB4fhwadtDUQv5HjrUFD9SXjvQfWUMSQ6zOL8l9S9SV/wRQCn0y0dal+xZPBoMoDqTMJyEBIHnQQ==",
|
||||
"requires": {
|
||||
"ip-address": "^8.1.0",
|
||||
"json5": "^2.2.0",
|
||||
@@ -15119,6 +15221,53 @@
|
||||
"@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": {
|
||||
"version": "0.1.4",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"@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": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
|
||||
@@ -16710,6 +16864,11 @@
|
||||
"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": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -17167,6 +17326,30 @@
|
||||
"integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==",
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"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",
|
||||
"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",
|
||||
@@ -22902,9 +23090,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": {
|
||||
|
||||
+10
-4
@@ -5,14 +5,16 @@
|
||||
"license": "apache-2.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist",
|
||||
"build": "npm run build:css && remix build",
|
||||
"build": "npm run build:css && npm run build:search && remix build",
|
||||
"build:css": "tailwindcss -i ./styles/tailwind.css -o ./app/tailwind.css --minify",
|
||||
"build:worker": "esbuild --define:process.env.NODE_ENV='\"production\"' --minify --bundle --sourcemap --outdir=dist ./worker",
|
||||
"build:worker:analyze": "esbuild --define:process.env.NODE_ENV='\"production\"' --minify --bundle --sourcemap --metafile=meta.json --outdir=dist ./worker",
|
||||
"build:visualize": "npm run clean && npm run build && npm run build:worker:analyze && esbuild-visualizer --metadata ./meta.json --exclude *.png",
|
||||
"build:search": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"production\"'",
|
||||
"dev:search": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"development\"' --watch",
|
||||
"dev:worker": "esbuild --define:process.env.NODE_ENV='\"development\"' --bundle --sourcemap --outdir=dist ./worker",
|
||||
"start:worker": "miniflare --env .env --build-command \"npm run dev:worker\" --watch",
|
||||
"dev": "concurrently \"npm run dev:css\" \"remix watch\"",
|
||||
"dev": "concurrently \"npm run dev:css\" \"npm run dev:search\" \"remix watch\"",
|
||||
"dev:css": "tailwindcss -i ./styles/tailwind.css -o ./app/tailwind.css --watch",
|
||||
"postinstall": "remix setup cloudflare-workers",
|
||||
"start": "concurrently \"npm run dev\" \"npm run start:worker\"",
|
||||
@@ -33,16 +35,20 @@
|
||||
"@jsonhero/json-schema-fns": "^0.0.1",
|
||||
"@jsonhero/path": "^1.0.17",
|
||||
"@jsonhero/schema-infer": "^0.1.x",
|
||||
"@radix-ui/react-dialog": "^0.1.7",
|
||||
"@radix-ui/react-popover": "^0.1.5",
|
||||
"@radix-ui/react-tabs": "^0.1.5",
|
||||
"@remix-run/cloudflare-workers": "^1.2.3",
|
||||
"@remix-run/react": "^1.2.3",
|
||||
"@remix-run/serve": "^1.2.3",
|
||||
"@swan-io/boxed": "^0.2.1",
|
||||
"@uiw/react-codemirror": "^4.3.3",
|
||||
"clsx": "^1.1.1",
|
||||
"color": "^4.2.1",
|
||||
"downshift": "^6.1.7",
|
||||
"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",
|
||||
@@ -74,7 +80,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"
|
||||
@@ -99,4 +105,4 @@
|
||||
},
|
||||
"sideEffects": false,
|
||||
"main": "dist/worker.js"
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -6,10 +6,10 @@ module.exports = {
|
||||
tiny: ".5rem", // 8px
|
||||
xs: ".625rem", // 10px
|
||||
sm: ".75rem", // 12px (SmallBody)
|
||||
base: ".875rem", // 14px (Body) <p>
|
||||
lg: "1rem", // 16px (SmallTitle) <h3>
|
||||
xl: "1.125rem", // 18px (Title) <h2>
|
||||
"2xl": "1.375rem", // 22px (LargeTitle) <h1>
|
||||
base: ".875rem", // 14px (Body) <p>
|
||||
lg: "1rem", // 16px (SmallTitle) <h3>
|
||||
xl: "1.125rem", // 18px (Title) <h2>
|
||||
"2xl": "1.375rem", // 22px (LargeTitle) <h1>
|
||||
"3xl": "1.5rem", // 24px
|
||||
"4xl": "1.875rem", // 30px
|
||||
"5xl": "2rem", // 32px
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user