-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+
-
-
+
+
diff --git a/app/useColumnView/index.ts b/app/useColumnView/index.ts
index 4aa1b67..f8a57d8 100644
--- a/app/useColumnView/index.ts
+++ b/app/useColumnView/index.ts
@@ -220,7 +220,7 @@ export function useColumnView({
selectedNodes,
highlightedNodeId,
highlightedPath,
- columns,
+ columns: columns ?? [],
getColumnViewProps,
canGoBack,
canGoForward,
@@ -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/formatter.ts b/app/utilities/formatter.ts
index ff5a940..1ab469d 100644
--- a/app/utilities/formatter.ts
+++ b/app/utilities/formatter.ts
@@ -26,9 +26,20 @@ export function formatRawValue(type: JSONValueType): string {
}
}
-export function formatValue(type: JSONValueType): string | undefined {
+export type FormatValueOptions = {
+ leafNodesOnly?: boolean;
+};
+
+export function formatValue(
+ type: JSONValueType,
+ options?: FormatValueOptions
+): string | undefined {
switch (type.name) {
case "array": {
+ if (options?.leafNodesOnly) {
+ return;
+ }
+
if (type.value.length == 0) {
return formatRawValue(type);
} else if (type.value.length === 1) {
@@ -38,6 +49,10 @@ export function formatValue(type: JSONValueType): string | undefined {
}
}
case "object": {
+ if (options?.leafNodesOnly) {
+ return;
+ }
+
if (Object.keys(type.value).length == 0) {
return formatRawValue(type);
} else if (Object.keys(type.value).length === 1) {
diff --git a/app/utilities/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 7666677..2fe9bc8 100644
--- a/app/utilities/jsonColumnView.ts
+++ b/app/utilities/jsonColumnView.ts
@@ -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
diff --git a/app/utilities/search.ts b/app/utilities/search.ts
new file mode 100644
index 0000000..a5309c8
--- /dev/null
+++ b/app/utilities/search.ts
@@ -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
, Array] {
+ const entries = createSearchEntries(json);
+
+ const index = Fuse.createIndex(
+ ["path", "rawValue", "formattedValue"],
+ entries
+ );
+
+ return [index, entries];
+}
+
+export function createSearchEntries(json: unknown): Array {
+ return createSearchEntryChildren(inferType(json), new JSONHeroPath("$"));
+}
+
+function createSearchEntryChildren(
+ info: JSONValueType,
+ path: JSONHeroPath
+): Array {
+ if (info.name === "array" && info.value) {
+ return info.value.flatMap((value, index) => {
+ const childPath = path.child(index.toString());
+ const childInfo = inferType(value);
+
+ const children = createSearchEntryChildren(childInfo, childPath);
+
+ return [
+ {
+ path: childPath.toString(),
+ rawValue: getRawValue(childInfo),
+ formattedValue: formatValue(childInfo, { leafNodesOnly: true }),
+ },
+ ...children,
+ ];
+ });
+ }
+
+ if (info.name === "object" && info.value) {
+ return Object.entries(info.value).flatMap(([key, value]) => {
+ const childPath = path.child(key);
+ const childInfo = inferType(value);
+
+ const children = createSearchEntryChildren(childInfo, childPath);
+
+ return [
+ {
+ path: childPath.toString(),
+ rawValue: getRawValue(childInfo),
+ formattedValue: formatValue(childInfo, { leafNodesOnly: true }),
+ },
+ ...children,
+ ];
+ });
+ }
+
+ return [];
+}
+
+export function getRawValue(type: JSONValueType): string | undefined {
+ switch (type.name) {
+ case "string":
+ return type.value;
+ case "int":
+ return type.value.toString();
+ case "float":
+ return type.value.toString();
+ case "bool":
+ return type.value ? "true" : "false";
+ case "null":
+ return "null";
+ }
+}
+
+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 {
+ 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 {
+ 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 {
+ 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;
+}
diff --git a/package-lock.json b/package-lock.json
index 37992d4..e7c7561 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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": {
diff --git a/package.json b/package.json
index f5fcc3f..4985ef2 100644
--- a/package.json
+++ b/package.json
@@ -5,14 +5,16 @@
"license": "apache-2.0",
"scripts": {
"clean": "rimraf dist",
- "build": "npm run build:css && remix build",
+ "build": "npm run build:css && npm run build:search && remix build",
"build:css": "tailwindcss -i ./styles/tailwind.css -o ./app/tailwind.css --minify",
"build:worker": "esbuild --define:process.env.NODE_ENV='\"production\"' --minify --bundle --sourcemap --outdir=dist ./worker",
"build:worker:analyze": "esbuild --define:process.env.NODE_ENV='\"production\"' --minify --bundle --sourcemap --metafile=meta.json --outdir=dist ./worker",
"build:visualize": "npm run clean && npm run build && npm run build:worker:analyze && esbuild-visualizer --metadata ./meta.json --exclude *.png",
+ "build:search": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"production\"'",
+ "dev:search": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --bundle --format=esm --define:process.env.NODE_ENV='\"development\"' --watch",
"dev:worker": "esbuild --define:process.env.NODE_ENV='\"development\"' --bundle --sourcemap --outdir=dist ./worker",
"start:worker": "miniflare --env .env --build-command \"npm run dev:worker\" --watch",
- "dev": "concurrently \"npm run dev:css\" \"remix watch\"",
+ "dev": "concurrently \"npm run dev:css\" \"npm run dev:search\" \"remix watch\"",
"dev:css": "tailwindcss -i ./styles/tailwind.css -o ./app/tailwind.css --watch",
"postinstall": "remix setup cloudflare-workers",
"start": "concurrently \"npm run dev\" \"npm run start:worker\"",
@@ -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"
-}
\ No newline at end of file
+}
diff --git a/tailwind.config.js b/tailwind.config.js
index a995baf..cf35692 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -6,10 +6,10 @@ module.exports = {
tiny: ".5rem", // 8px
xs: ".625rem", // 10px
sm: ".75rem", // 12px (SmallBody)
- base: ".875rem", // 14px (Body)
- lg: "1rem", // 16px (SmallTitle)
- xl: "1.125rem", // 18px (Title)
- "2xl": "1.375rem", // 22px (LargeTitle)
+ base: ".875rem", // 14px (Body)
+ lg: "1rem", // 16px (SmallTitle)
+ xl: "1.125rem", // 18px (Title)
+ "2xl": "1.375rem", // 22px (LargeTitle)
"3xl": "1.5rem", // 24px
"4xl": "1.875rem", // 30px
"5xl": "2rem", // 32px
diff --git a/tests/search.test.ts b/tests/search.test.ts
new file mode 100644
index 0000000..cb9da49
--- /dev/null
+++ b/tests/search.test.ts
@@ -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",
+ },
+]
+`);
+ });
+});