From 167b548343f7bd084c3b048985bcdc8a926ec781 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 1 Apr 2022 09:30:33 +0100 Subject: [PATCH] WIP implementing search --- app/components/SearchBar.tsx | 104 +++------ app/components/SearchItem.tsx | 49 ---- app/components/SearchPalette.tsx | 388 +++++++++++++++++++++++++++++-- app/entry.worker.ts | 7 +- app/hooks/useJsonSearch.tsx | 140 ++++++++--- app/hooks/useJsonTree.tsx | 2 +- app/useColumnView/index.ts | 2 +- app/utilities/icons.ts | 154 ++++++++++++ app/utilities/jsonColumnView.ts | 151 +----------- app/utilities/search.ts | 46 ++++ package-lock.json | 72 +++++- package.json | 3 +- tests/search.test.ts | 34 +++ 13 files changed, 821 insertions(+), 331 deletions(-) delete mode 100644 app/components/SearchItem.tsx create mode 100644 app/utilities/icons.ts diff --git a/app/components/SearchBar.tsx b/app/components/SearchBar.tsx index 081432b..4e9a425 100644 --- a/app/components/SearchBar.tsx +++ b/app/components/SearchBar.tsx @@ -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 ( - - + + setIsOpen(true)}>
@@ -40,73 +49,14 @@ export function SearchBar({ className }: SearchBarProps) { "bg-white border-[1px] dark:border-slate-700 dark:bg-slate-800" )} > - + setIsOpen(false)} + onSelect={(entry) => { + setIsOpen(false); + goToNodeId(entry.path, "search"); + }} + />
); } - -function JamesCommandPalette() { - return ( - <> -
- -
-
-
- - Loading… -
-
- 35 results -
-
- - - No results for "sdgiuhnkjdfg" - -
-
-
-
    - - - - - - - - - - - - - -
-
-
-
- - ⏎ - - to select -
-
- - to navigate -
-
- - to close -
-
- - ); -} diff --git a/app/components/SearchItem.tsx b/app/components/SearchItem.tsx deleted file mode 100644 index d230a15..0000000 --- a/app/components/SearchItem.tsx +++ /dev/null @@ -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 ( -
  • -
    - -
    -
    - item - - includes - - tweets - - … - - 0 - - size - - date -
    -
    - - - 2019 - - -12-31T19:26:16.000Z - - - Tue, Dec 31, - - 2019 - - , 7:26:16 PM GMT - -
    -
    -
    -
  • - ); -} diff --git a/app/components/SearchPalette.tsx b/app/components/SearchPalette.tsx index 5051560..ad4ff4d 100644 --- a/app/components/SearchPalette.tsx +++ b/app/components/SearchPalette.tsx @@ -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(null); + + const rowVirtualizer = useVirtual({ + size: (searchState.results ?? []).length, + parentRef: listRef, + estimateSize: useCallback(() => 70, []), + overscan: 6, + }); + + function comboboxReducer( + state: UseComboboxState>, + actionAndChanges: UseComboboxStateChangeOptions< + Fuse.FuseResult + > + ): Partial>> { + 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 ( -
    - -
    - searchApi.search(e.currentTarget.value)} - /> + <> +
    + +
    +
    + {searchState.status !== "idle" && + (!searchState.results || searchState.results.length === 0) && ( +
    + + Loading… +
    + )} + {searchState.results && searchState.results.length > 0 && ( +
    + + {searchState.results.length === 1 + ? "1 result" + : `${searchState.results.length} results`} + +
    + )} + {searchState.status === "idle" && + searchState.query && + searchState.query.length > 1 && + (!searchState.results || searchState.results.length === 0) && ( +
    + + + No results for "{cb.inputValue}" + +
    + )} +
    +
    +
      +
    • + {rowVirtualizer.virtualItems.map((virtualRow) => { + const result = (searchState.results ?? [])[virtualRow.index]; + + return ( + + ); + })} +
    -
      - {searchState.results?.map((result) => ( -
    • - [{result.item.path}] {result.item.formattedValue} - {" - "} - {result.item.rawValue} -
    • - ))} -
    -
    +
    +
    + + ⏎ + + to select +
    +
    + + to navigate +
    +
    + + to close +
    +
    + ); } + +type SearchItemProps = { + itemProps: React.HTMLAttributes; + result: Fuse.FuseResult; + 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 ( +
  • +
    + +
    +
    + {components.map((c, index) => { + return [ + + {c.toString()} + , + ].concat( + index + 1 === components.length + ? [] + : [ + , + ] + ); + })} +
    +
    + {result.item.rawValue && ( + + )} + {result.item.formattedValue && + result.item.formattedValue !== result.item.rawValue && ( + + )} +
    +
    +
    +
  • + ); +} + +function SearchResultValue({ + isHighlighted, + keyName, + stringValue, + searchResult, +}: { + isHighlighted: boolean; + keyName: string; + stringValue: string; + searchResult: Fuse.FuseResult; +}) { + const match = (searchResult.matches ?? []).find( + (match) => match.key === keyName + ); + + const output = createOutputForMatch(stringValue, isHighlighted, match); + + return ( + + {output} + + ); +} + +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 ( + + {s.slice} + + ); + })} + + ); + } + + return <>{truncate(stringValue, { length: 56 })}; +} + +type StringSlice = { + start: number; + end: number; + isMatch: boolean; + slice: string; +}; + +function getAllStringSlices( + stringValue: string, + matchingIndices: ReadonlyArray<[number, number]> +): Array { + 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; +} diff --git a/app/entry.worker.ts b/app/entry.worker.ts index 7a9d5e5..a62db1c 100644 --- a/app/entry.worker.ts +++ b/app/entry.worker.ts @@ -29,7 +29,6 @@ self.onmessage = (e: MessageEvent) => { console.group(`SearchWorker: ${type}`); console.log(payload); - console.groupEnd(); switch (type) { case "initialize-index": { @@ -54,7 +53,11 @@ self.onmessage = (e: MessageEvent) => { 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(); }; diff --git a/app/hooks/useJsonSearch.tsx b/app/hooks/useJsonSearch.tsx index 911a17e..9f10948 100644 --- a/app/hooks/useJsonSearch.tsx +++ b/app/hooks/useJsonSearch.tsx @@ -28,7 +28,7 @@ export type IndexInitializedEvent = { export type SearchResultsEvent = { type: "search-results"; - payload: { results: Fuse.FuseResult[] }; + payload: { results: Fuse.FuseResult[]; query: string }; }; export type SearchReceiveWorkerEvent = @@ -37,6 +37,7 @@ export type SearchReceiveWorkerEvent = export type JsonSearchApi = { search: (query: string) => void; + reset: () => void; }; const JsonSearchStateContext = createContext( @@ -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( + name: string, + reducer: React.Reducer +): React.Reducer { + 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 - >(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) => 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 ( - + {children} diff --git a/app/hooks/useJsonTree.tsx b/app/hooks/useJsonTree.tsx index afbcfbf..0119193 100644 --- a/app/hooks/useJsonTree.tsx +++ b/app/hooks/useJsonTree.tsx @@ -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, diff --git a/app/useColumnView/index.ts b/app/useColumnView/index.ts index 5a85024..f8a57d8 100644 --- a/app/useColumnView/index.ts +++ b/app/useColumnView/index.ts @@ -256,7 +256,7 @@ export type ResetSelectionNodeAction = { export type GoAction = { type: "GO"; - direction: number; + direction: -1 | 1; }; export type ColumnViewAction = diff --git a/app/utilities/icons.ts b/app/utilities/icons.ts new file mode 100644 index 0000000..394fa22 --- /dev/null +++ b/app/utilities/icons.ts @@ -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; + } + } +} diff --git a/app/utilities/jsonColumnView.ts b/app/utilities/jsonColumnView.ts index 412f81a..2fe9bc8 100644 --- a/app/utilities/jsonColumnView.ts +++ b/app/utilities/jsonColumnView.ts @@ -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 diff --git a/app/utilities/search.ts b/app/utilities/search.ts index d208f1a..a6839e9 100644 --- a/app/utilities/search.ts +++ b/app/utilities/search.ts @@ -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 { + 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; +} diff --git a/package-lock.json b/package-lock.json index 72bd2f0..97a1b2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 00311c5..c181b57 100644 --- a/package.json +++ b/package.json @@ -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" -} \ No newline at end of file +} diff --git a/tests/search.test.ts b/tests/search.test.ts index 0253b9c..808c44c 100644 --- a/tests/search.test.ts +++ b/tests/search.test.ts @@ -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);