WIP implementing search

This commit is contained in:
Eric Allam
2022-04-01 09:30:33 +01:00
parent 5655631f12
commit 167b548343
13 changed files with 821 additions and 331 deletions
+27 -77
View File
@@ -1,21 +1,30 @@
import { ExclamationIcon, SearchIcon } from "@heroicons/react/outline";
import { SearchIcon } from "@heroicons/react/outline";
import { ShortcutIcon } from "./Icons/ShortcutIcon";
import { Body } from "./Primitives/Body";
import { Dialog, DialogTrigger, DialogContent } from "./UI/Dialog";
import { EscapeKeyIcon } from "./Icons/EscapeKeyIcon";
import { ArrowKeysUpDownIcon } from "./Icons/ArrowKeysUpDownIcon";
import { LoadingIcon } from "./Icons/LoadingIcon";
import { SearchItem } from "./SearchItem";
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 type SearchBarProps = {
className?: string;
};
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 (
<Dialog>
<DialogTrigger>
<Dialog open={isOpen}>
<DialogTrigger onClick={() => setIsOpen(true)}>
<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-1">
<SearchIcon className="w-4 h-4 mr-1" />
@@ -40,73 +49,14 @@ export function SearchBar({ className }: SearchBarProps) {
"bg-white border-[1px] dark:border-slate-700 dark:bg-slate-800"
)}
>
<JamesCommandPalette />
<SearchPalette
onClose={() => setIsOpen(false)}
onSelect={(entry) => {
setIsOpen(false);
goToNodeId(entry.path, "search");
}}
/>
</DialogContent>
</Dialog>
);
}
function JamesCommandPalette() {
return (
<>
<div className="search-container max-h-[60vh] px-4 pt-4 overflow-hidden">
<label className="relative text-gray-400 focus-within:text-gray-600 block">
<SearchIcon className="absolute w-7 h-7 top-1/2 transform -translate-y-1/2 left-3 text-white pointer-events-none" />
<input
type="text"
placeholder="Search the JSON…"
className="search-field w-full pl-12 pr-4 py-4 rounded-sm text-white text-2xl caret-indigo-700 bg-slate-900 border-indigo-700 focus:outline-none focus:ring focus:ring-indigo-700"
/>
</label>
<div className="search-content flex flex-col mt-4 mb-2">
<div className="results flex">
<div className="results-loading flex">
<LoadingIcon className="animate-spin h-5 w-5 mr-1"></LoadingIcon>
<Body className="text-slate-400">Loading</Body>
</div>
<div className="results-returned">
<Body className="text-slate-400">35 results</Body>
</div>
<div className="results-none flex">
<ExclamationIcon className="h-5 w-5 mr-1 text-white"></ExclamationIcon>
<Body className="text-slate-400">
No results for "sdgiuhnkjdfg"
</Body>
</div>
</div>
</div>
<ul className="search-items w-full max-h-[inherit] pb-24 overflow-y-auto">
<SearchItem></SearchItem>
<SearchItem></SearchItem>
<SearchItem></SearchItem>
<SearchItem></SearchItem>
<SearchItem></SearchItem>
<SearchItem></SearchItem>
<SearchItem></SearchItem>
<SearchItem></SearchItem>
<SearchItem></SearchItem>
<SearchItem></SearchItem>
<SearchItem></SearchItem>
<SearchItem></SearchItem>
<SearchItem></SearchItem>
</ul>
</div>
<div className="footer flex items-center w-full gap-4 px-3 py-2 border-t-[1px] border-slate-700 rounded-br-lg rounded-bl-lg bg-slate-900">
<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-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-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-500">to close</Body>
</div>
</div>
</>
);
}
-49
View File
@@ -1,49 +0,0 @@
import { ChevronRightIcon } from "@heroicons/react/outline";
import { ArrayIcon } from "./Icons/ArrayIcon";
import { Body } from "./Primitives/Body";
import { Mono } from "./Primitives/Mono";
export type SearchItemProps = {
className?: string;
};
export function SearchItem(className: SearchItemProps) {
return (
<li className="w-full rounded-sm mb-2 last:mb-10 group dark:bg-slate-900 transition hover:cursor-pointer hover:dark:bg-indigo-700">
<div className="main-container flex items-center w-full p-2 pl-4 pr-3">
<ArrayIcon className="h-6 w-6 text-slate-400 transition group-hover:text-white"></ArrayIcon>
<div className="flex flex-col w-full ml-3">
<div className="path flex items-center gap-1 mb-1 text-white">
<Body className="text-xl">item</Body>
<ChevronRightIcon className="w-4 h-4"></ChevronRightIcon>
<Body className="text-xl">includes</Body>
<ChevronRightIcon className="w-4 h-4"></ChevronRightIcon>
<Body className="text-xl">tweets</Body>
<ChevronRightIcon className="w-4 h-4"></ChevronRightIcon>
<Body className="text-xl"></Body>
<ChevronRightIcon className="w-4 h-4"></ChevronRightIcon>
<Body className="text-xl">0</Body>
<ChevronRightIcon className="w-4 h-4"></ChevronRightIcon>
<Body className="text-xl">size</Body>
<ChevronRightIcon className="w-4 h-4"></ChevronRightIcon>
<Body className="text-xl">date</Body>
</div>
<div className="key-value flex justify-between">
<Mono className="text-slate-500 transition group-hover:text-white">
<span className="text-indigo-500 transition group-hover:text-white group-hover:underline group-hover:underline-offset-1">
2019
</span>
-12-31T19:26:16.000Z
</Mono>
<Mono className="text-slate-500 transition group-hover:text-white">
Tue, Dec 31,
<span className="text-indigo-500 transition group-hover:text-white group-hover:underline group-hover:underline-offset-1">
2019
</span>
, 7:26:16 PM GMT
</Mono>
</div>
</div>
</div>
</li>
);
}
+369 -19
View File
@@ -1,28 +1,378 @@
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 { 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 { truncate } from "lodash-es";
import { JSONHeroPath } from "@jsonhero/path";
import { useHotkeys } from "react-hotkeys-hook";
export function SearchPalette() {
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(),
});
return (
<div>
<label>Search json</label>
<div>
<input
type="text"
value={searchState.query ?? ""}
onChange={(e) => searchApi.search(e.currentTarget.value)}
/>
<>
<div
{...cb.getComboboxProps()}
className="search-container max-h-[60vh] px-4 pt-4 overflow-hidden"
>
<label
{...cb.getLabelProps()}
className="relative text-gray-400 focus-within:text-gray-600 block"
>
<SearchIcon className="absolute w-7 h-7 top-1/2 transform -translate-y-1/2 left-3 text-white pointer-events-none" />
<input
{...cb.getInputProps()}
type="text"
placeholder="Search the JSON…"
className="search-field w-full pl-12 pr-4 py-4 rounded-sm text-white text-2xl caret-indigo-700 bg-slate-900 border-indigo-700 focus:outline-none focus:ring focus:ring-indigo-700"
/>
</label>
<div className="search-content 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="search-items w-full max-h-[inherit] pb-24 overflow-y-auto relative"
>
<li key="total-size" style={{ height: rowVirtualizer.totalSize }} />
{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>
<ul>
{searchState.results?.map((result) => (
<li key={result.item.path}>
[{result.item.path}] {result.item.formattedValue}
{" - "}
{result.item.rawValue}
</li>
))}
</ul>
</div>
<div className="footer flex items-center w-full gap-4 px-3 py-2 border-t-[1px] border-slate-700 rounded-br-lg rounded-bl-lg bg-slate-900">
<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-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-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-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 ItemIcon = iconForValue(result.item.rawValue);
const heroPath = new JSONHeroPath(result.item.path);
const components = heroPath.components.slice(1);
return (
<li
{...itemProps}
className={classnames(
"w-full rounded-sm mb-2 last:mb-10 group transition hover:cursor-pointer",
isHighlighted ? "dark:bg-indigo-700" : "dark:bg-slate-900"
)}
>
<div className="main-container flex items-center w-full p-2 pl-4 pr-3">
<ItemIcon className="h-6 w-6 text-slate-400 transition group-hover:text-white"></ItemIcon>
<div className="flex flex-col w-full ml-3">
<div className="path flex items-center gap-1 mb-1 text-white">
{components.map((c, index) => {
return [
<Body key={c.toString() + index + "body"} className="text-lg">
{c.toString()}
</Body>,
].concat(
index + 1 === components.length
? []
: [
<ChevronRightIcon
key={c.toString() + index + "arrow"}
className="w-4 h-4"
/>,
]
);
})}
</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>
</li>
);
}
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(
"text-slate-500 transition",
isHighlighted ? "dark:text-white" : ""
)}
>
{output}
</Mono>
);
}
function createOutputForMatch(
stringValue: string,
isHighlighted: boolean,
match?: Fuse.FuseResultMatch
): JSX.Element {
if (!match) {
return <>{truncate(stringValue, { length: 56 })}</>;
}
if (stringValue.length <= 56) {
const stringSlices = getAllStringSlices(stringValue, match.indices);
return (
<>
{stringSlices.map((s, index) => {
return (
<span
key={index}
className={
s.isMatch
? classnames(
"text-indigo-500 transition",
isHighlighted
? "text-white underline underline-offset-1"
: ""
)
: ""
}
>
{s.slice}
</span>
);
})}
</>
);
}
return <>{truncate(stringValue, { length: 56 })}</>;
}
type StringSlice = {
start: number;
end: number;
isMatch: boolean;
slice: string;
};
function getAllStringSlices(
stringValue: string,
matchingIndices: ReadonlyArray<[number, number]>
): Array<StringSlice> {
const slices: StringSlice[] = [];
let currentIndex = 0;
const addSlice = (
start: number,
end: number,
isMatch: boolean,
slice: string
) => {
slices.push({ start, end, isMatch, slice });
};
for (const [start, end] of matchingIndices) {
addSlice(
currentIndex,
start,
false,
stringValue.slice(currentIndex, start)
);
addSlice(start, end + 1, true, stringValue.slice(start, end + 1));
currentIndex = end + 1;
}
addSlice(
currentIndex,
stringValue.length,
false,
stringValue.slice(currentIndex)
);
return slices;
}
+5 -2
View File
@@ -29,7 +29,6 @@ self.onmessage = (e: MessageEvent<SearchWorkerEvent>) => {
console.group(`SearchWorker: ${type}`);
console.log(payload);
console.groupEnd();
switch (type) {
case "initialize-index": {
@@ -54,7 +53,11 @@ self.onmessage = (e: MessageEvent<SearchWorkerEvent>) => {
const results = self.fuse.search(query);
self.postMessage({ type: "search-results", payload: { results } });
console.log("results", results);
self.postMessage({ type: "search-results", payload: { results, query } });
}
}
console.groupEnd();
};
+114 -26
View File
@@ -28,7 +28,7 @@ export type IndexInitializedEvent = {
export type SearchResultsEvent = {
type: "search-results";
payload: { results: Fuse.FuseResult<JsonSearchEntry>[] };
payload: { results: Fuse.FuseResult<JsonSearchEntry>[]; query: string };
};
export type SearchReceiveWorkerEvent =
@@ -37,6 +37,7 @@ export type SearchReceiveWorkerEvent =
export type JsonSearchApi = {
search: (query: string) => void;
reset: () => void;
};
const JsonSearchStateContext = createContext<JsonSearchState>(
@@ -56,36 +57,119 @@ type SearchAction = {
payload: { query: string };
};
type JsonSearchAction = SearchReceiveWorkerEvent | SearchAction;
type ResetAction = {
type: "reset";
};
type JsonSearchAction = SearchReceiveWorkerEvent | SearchAction | ResetAction;
function reducer(
state: JsonSearchState,
action: JsonSearchAction
): JsonSearchState {
switch (action.type) {
case "index-initialized":
return {
...state,
status: "idle",
results: undefined,
};
case "search-results":
return {
...state,
status: "idle",
results: action.payload.results,
};
case "search":
return {
...state,
status: "searching",
query: action.payload.query,
};
default:
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,
}: {
@@ -95,7 +179,7 @@ export function JsonSearchProvider({
const [state, dispatch] = useReducer<
React.Reducer<JsonSearchState, JsonSearchAction>
>(reducer, { status: "initializing" });
>(wrapReducer("jsonSearch", reducer), { status: "initializing" });
const search = useCallback(
(query: string) => {
@@ -104,6 +188,10 @@ export function JsonSearchProvider({
[dispatch]
);
const reset = useCallback(() => {
dispatch({ type: "reset" });
}, [dispatch]);
const handleWorkerMessage = useCallback(
(e: MessageEvent<SearchReceiveWorkerEvent>) => dispatch(e.data),
[dispatch]
@@ -132,10 +220,10 @@ export function JsonSearchProvider({
fuseOptions: {
includeScore: true,
includeMatches: true,
minMatchCharLength: 1,
minMatchCharLength: 2,
isCaseSensitive: false,
threshold: 0.6,
distance: 200,
distance: 20,
},
},
});
@@ -154,7 +242,7 @@ export function JsonSearchProvider({
return (
<JsonSearchStateContext.Provider value={state}>
<JsonSearchApiContext.Provider value={{ search }}>
<JsonSearchApiContext.Provider value={{ search, reset }}>
{children}
</JsonSearchApiContext.Provider>
</JsonSearchStateContext.Provider>
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -256,7 +256,7 @@ export type ResetSelectionNodeAction = {
export type GoAction = {
type: "GO";
direction: number;
direction: -1 | 1;
};
export type ColumnViewAction =
+154
View File
@@ -0,0 +1,154 @@
import {
CubeIcon,
CollectionIcon,
EyeOffIcon,
CheckCircleIcon,
AnnotationIcon,
CalendarIcon,
AtSymbolIcon,
GlobeAltIcon,
PhotographIcon,
CodeIcon,
PhoneIcon,
DocumentIcon,
ColorSwatchIcon,
CreditCardIcon,
CurrencyDollarIcon,
ClockIcon,
GlobeIcon,
EmojiHappyIcon,
ChatAlt2Icon,
ArchiveIcon,
IdentificationIcon,
KeyIcon,
DocumentTextIcon,
HashtagIcon,
} from "@heroicons/react/outline";
import { inferType, JSONValueType } from "@jsonhero/json-infer-types";
import { StringIcon } from "~/components/Icons/StringIcon";
import { IconComponent } from "~/useColumnView";
export function iconForValue(value: unknown): IconComponent {
return iconForType(inferType(value));
}
export function iconForType(type: JSONValueType): IconComponent {
switch (type.name) {
case "object": {
return CubeIcon;
}
case "array": {
return CollectionIcon;
}
case "null": {
return EyeOffIcon;
}
case "bool": {
return CheckCircleIcon;
}
case "int":
case "float": {
return HashtagIcon;
}
case "string": {
if (type.format == null) {
return StringIcon;
}
switch (type.format.name) {
case "timestamp": {
return CalendarIcon;
}
case "datetime": {
switch (type.format.parts) {
case "time":
return ClockIcon;
}
return ClockIcon;
}
case "email": {
return AtSymbolIcon;
}
case "hostname":
case "tld":
case "ip":
return GlobeAltIcon;
case "uri": {
switch (type.format.contentType) {
case "image/jpeg":
case "image/png":
case "image/gif":
case "image/webm":
return PhotographIcon;
case "application/json":
return CodeIcon;
default:
return GlobeAltIcon;
}
}
case "phoneNumber": {
return PhoneIcon;
}
case "currency": {
return CurrencyDollarIcon;
}
case "country": {
return GlobeIcon;
}
case "emoji": {
return EmojiHappyIcon;
}
case "color": {
return ColorSwatchIcon;
}
case "language": {
return ChatAlt2Icon;
}
case "filesize": {
return ArchiveIcon;
}
case "uuid": {
return IdentificationIcon;
}
case "json":
case "jsonPointer": {
return CodeIcon;
}
case "jwt": {
return KeyIcon;
}
case "semver": {
return DocumentTextIcon;
}
case "creditcard": {
switch (type.format.variant) {
case "visa": {
return CreditCardIcon;
}
case "mastercard": {
return CreditCardIcon;
}
case "amex": {
return CreditCardIcon;
}
case "discover": {
return CreditCardIcon;
}
case "dinersclub": {
return CreditCardIcon;
}
default: {
return CreditCardIcon;
}
}
}
default: {
return AnnotationIcon;
}
}
}
default: {
return DocumentIcon;
}
}
}
+2 -149
View File
@@ -1,34 +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 { 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);
@@ -117,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
+46
View File
@@ -83,3 +83,49 @@ export function getRawValue(type: JSONValueType): string | undefined {
return "null";
}
}
type StringSlice = {
start: number;
end: number;
isMatch: boolean;
slice: string;
};
export function getStringSlices(
stringValue: string,
matchingIndices: ReadonlyArray<[number, number]>,
maxLength: number
): Array<StringSlice> {
const slices: StringSlice[] = [];
let currentIndex = 0;
const addSlice = (
start: number,
end: number,
isMatch: boolean,
slice: string
) => {
slices.push({ start, end, isMatch, slice });
};
for (const [start, end] of matchingIndices) {
addSlice(
currentIndex,
start,
false,
stringValue.slice(currentIndex, start)
);
addSlice(start, end + 1, true, stringValue.slice(start, end + 1));
currentIndex = end + 1;
}
addSlice(
currentIndex,
stringValue.length,
false,
stringValue.slice(currentIndex)
);
return slices;
}
+66 -6
View File
@@ -25,6 +25,7 @@
"@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",
@@ -1703,9 +1704,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",
@@ -4167,6 +4168,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",
@@ -4776,6 +4782,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",
@@ -14842,9 +14873,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",
@@ -16822,6 +16853,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",
@@ -17279,6 +17315,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",
+2 -1
View File
@@ -44,6 +44,7 @@
"@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",
@@ -103,4 +104,4 @@
},
"sideEffects": false,
"main": "dist/worker.js"
}
}
+34
View File
@@ -2,6 +2,7 @@ import Fuse from "fuse.js";
import {
createSearchEntries,
createSearchIndex,
getStringSlices,
} from "../app/utilities/search";
const json = {
@@ -57,6 +58,39 @@ const json = {
],
};
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",
[[9, 16]],
60
);
expect(slices).toMatchInlineSnapshot(`
Array [
Object {
"end": 9,
"isMatch": false,
"slice": "This is a",
"start": 0,
},
Object {
"end": 17,
"isMatch": true,
"slice": " really ",
"start": 9,
},
Object {
"end": 37,
"isMatch": false,
"slice": "great (short) string",
"start": 17,
},
]
`);
});
});
describe("createSearchIndex", () => {
it("creates a search index that can search keys, raw values, and formatted values", () => {
const [index, entries] = createSearchIndex(json);