Skip to content

Commit

Permalink
feat: Introduce advanced search capabilities (#753)
Browse files Browse the repository at this point in the history
* feat: Implement search filtering in the backend

* feat: Implement search language parser

* rename matcher name

* Add ability to interleve text

* More fixes

* be more tolerable to parsing errors

* Add a search query explainer widget

* Handle date parsing gracefully

* Fix the lockfile

* Encode query search param

* Fix table body error

* Fix error when writing quotes
  • Loading branch information
MohamedBassem authored Dec 31, 2024
1 parent f476fca commit cbaf9e6
Show file tree
Hide file tree
Showing 11 changed files with 1,054 additions and 20 deletions.
98 changes: 98 additions & 0 deletions apps/web/components/dashboard/search/QueryExplainerTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import InfoTooltip from "@/components/ui/info-tooltip";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";

import { TextAndMatcher } from "@hoarder/shared/searchQueryParser";
import { Matcher } from "@hoarder/shared/types/search";

export default function QueryExplainerTooltip({
parsedSearchQuery,
className,
}: {
parsedSearchQuery: TextAndMatcher & { result: string };
className?: string;
}) {
if (parsedSearchQuery.result == "invalid") {
return null;
}

const MatcherComp = ({ matcher }: { matcher: Matcher }) => {
switch (matcher.type) {
case "tagName":
return (
<TableRow>
<TableCell>Tag Name</TableCell>
<TableCell>{matcher.tagName}</TableCell>
</TableRow>
);
case "listName":
return (
<TableRow>
<TableCell>List Name</TableCell>
<TableCell>{matcher.listName}</TableCell>
</TableRow>
);
case "dateAfter":
return (
<TableRow>
<TableCell>Created After</TableCell>
<TableCell>{matcher.dateAfter.toDateString()}</TableCell>
</TableRow>
);
case "dateBefore":
return (
<TableRow>
<TableCell>Created Before</TableCell>
<TableCell>{matcher.dateBefore.toDateString()}</TableCell>
</TableRow>
);
case "favourited":
return (
<TableRow>
<TableCell>Favourited</TableCell>
<TableCell>{matcher.favourited.toString()}</TableCell>
</TableRow>
);
case "archived":
return (
<TableRow>
<TableCell>Archived</TableCell>
<TableCell>{matcher.archived.toString()}</TableCell>
</TableRow>
);
case "and":
case "or":
return (
<TableRow>
<TableCell className="capitalize">{matcher.type}</TableCell>
<TableCell>
<Table>
<TableBody>
{matcher.matchers.map((m, i) => (
<MatcherComp key={i} matcher={m} />
))}
</TableBody>
</Table>
</TableCell>
</TableRow>
);
}
};

return (
<InfoTooltip className={className}>
<Table>
<TableBody>
{parsedSearchQuery.text && (
<TableRow>
<TableCell>Text</TableCell>
<TableCell>{parsedSearchQuery.text}</TableCell>
</TableRow>
)}
{parsedSearchQuery.matcher && (
<MatcherComp matcher={parsedSearchQuery.matcher} />
)}
</TableBody>
</Table>
</InfoTooltip>
);
}
27 changes: 18 additions & 9 deletions apps/web/components/dashboard/search/SearchInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import React, { useEffect, useImperativeHandle, useRef } from "react";
import { Input } from "@/components/ui/input";
import { useDoBookmarkSearch } from "@/lib/hooks/bookmark-search";
import { useTranslation } from "@/lib/i18n/client";
import { cn } from "@/lib/utils";

import QueryExplainerTooltip from "./QueryExplainerTooltip";

function useFocusSearchOnKeyPress(
inputRef: React.RefObject<HTMLInputElement>,
Expand Down Expand Up @@ -47,7 +50,8 @@ const SearchInput = React.forwardRef<
React.HTMLAttributes<HTMLInputElement> & { loading?: boolean }
>(({ className, ...props }, ref) => {
const { t } = useTranslation();
const { debounceSearch, searchQuery, isInSearchPage } = useDoBookmarkSearch();
const { debounceSearch, searchQuery, parsedSearchQuery, isInSearchPage } =
useDoBookmarkSearch();

const [value, setValue] = React.useState(searchQuery);

Expand All @@ -67,14 +71,19 @@ const SearchInput = React.forwardRef<
}, [isInSearchPage]);

return (
<Input
ref={inputRef}
value={value}
onChange={onChange}
placeholder={t("common.search")}
className={className}
{...props}
/>
<div className={cn("relative flex-1", className)}>
<QueryExplainerTooltip
className="-translate-1/2 absolute right-1.5 top-2 p-0.5"
parsedSearchQuery={parsedSearchQuery}
/>
<Input
ref={inputRef}
value={value}
onChange={onChange}
placeholder={t("common.search")}
{...props}
/>
</div>
);
});
SearchInput.displayName = "SearchInput";
Expand Down
20 changes: 12 additions & 8 deletions apps/web/lib/hooks/bookmark-search.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { api } from "@/lib/trpc";
import { keepPreviousData } from "@tanstack/react-query";

import { parseSearchQuery } from "@hoarder/shared/searchQueryParser";

function useSearchQuery() {
const searchParams = useSearchParams();
const searchQuery = searchParams.get("q") ?? "";
return { searchQuery };
const searchQuery = decodeURIComponent(searchParams.get("q") ?? "");
const parsed = useMemo(() => parseSearchQuery(searchQuery), [searchQuery]);
return { searchQuery, parsedSearchQuery: parsed };
}

export function useDoBookmarkSearch() {
const router = useRouter();
const { searchQuery } = useSearchQuery();
const { searchQuery, parsedSearchQuery } = useSearchQuery();
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | undefined>();
const pathname = usePathname();

Expand All @@ -26,7 +29,7 @@ export function useDoBookmarkSearch() {

const doSearch = (val: string) => {
setTimeoutId(undefined);
router.replace(`/dashboard/search?q=${val}`);
router.replace(`/dashboard/search?q=${encodeURIComponent(val)}`);
};

const debounceSearch = (val: string) => {
Expand All @@ -43,12 +46,13 @@ export function useDoBookmarkSearch() {
doSearch,
debounceSearch,
searchQuery,
parsedSearchQuery,
isInSearchPage: pathname.startsWith("/dashboard/search"),
};
}

export function useBookmarkSearch() {
const { searchQuery } = useSearchQuery();
const { parsedSearchQuery } = useSearchQuery();

const {
data,
Expand All @@ -60,7 +64,8 @@ export function useBookmarkSearch() {
isFetchingNextPage,
} = api.bookmarks.searchBookmarks.useInfiniteQuery(
{
text: searchQuery,
text: parsedSearchQuery.text,
matcher: parsedSearchQuery.matcher,
},
{
placeholderData: keepPreviousData,
Expand All @@ -75,7 +80,6 @@ export function useBookmarkSearch() {
}

return {
searchQuery,
error,
data,
isPending,
Expand Down
7 changes: 5 additions & 2 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,21 @@
"meilisearch": "^0.37.0",
"ollama": "^0.5.9",
"openai": "^4.67.1",
"typescript-parsec": "^0.3.4",
"winston": "^3.11.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@hoarder/eslint-config": "workspace:^0.2.0",
"@hoarder/prettier-config": "workspace:^0.1.0",
"@hoarder/tsconfig": "workspace:^0.1.0"
"@hoarder/tsconfig": "workspace:^0.1.0",
"vitest": "^1.3.1"
},
"scripts": {
"typecheck": "tsc --noEmit",
"format": "prettier . --ignore-path ../../.prettierignore",
"lint": "eslint ."
"lint": "eslint .",
"test": "vitest"
},
"main": "index.ts",
"eslintConfig": {
Expand Down
Loading

0 comments on commit cbaf9e6

Please sign in to comment.