feat: allow search without a search term (#7765)

* feat: allow search without a search term

* tests

* conditional filter visibility

* add icon to collection filter
This commit is contained in:
Hemachandar
2024-11-04 04:29:48 +05:30
committed by GitHub
parent e4d60382fd
commit c1c20f1ff9
12 changed files with 352 additions and 200 deletions

View File

@@ -98,7 +98,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
} }
// default search for anything that doesn't look like a URL // default search for anything that doesn't look like a URL
const results = await documents.searchTitles(term); const results = await documents.searchTitles({ query: term });
return sortBy( return sortBy(
results.map(({ document }) => ({ results.map(({ document }) => ({

View File

@@ -55,7 +55,8 @@ function SearchPopover({ shareId, className }: Props) {
const performSearch = React.useCallback( const performSearch = React.useCallback(
async ({ query, ...options }) => { async ({ query, ...options }) => {
if (query?.length > 0) { if (query?.length > 0) {
const response = await documents.search(query, { const response = await documents.search({
query,
shareId, shareId,
...options, ...options,
}); });

View File

@@ -57,9 +57,10 @@ function Search(props: Props) {
const recentSearchesRef = React.useRef<HTMLDivElement | null>(null); const recentSearchesRef = React.useRef<HTMLDivElement | null>(null);
// filters // filters
const query = decodeURIComponentSafe( const decodedQuery = decodeURIComponentSafe(
routeMatch.params.term ?? params.get("query") ?? "" routeMatch.params.term ?? params.get("query") ?? ""
); ).trim();
const query = decodedQuery !== "" ? decodedQuery : undefined;
const collectionId = params.get("collectionId") ?? undefined; const collectionId = params.get("collectionId") ?? undefined;
const userId = params.get("userId") ?? undefined; const userId = params.get("userId") ?? undefined;
const documentId = params.get("documentId") ?? undefined; const documentId = params.get("documentId") ?? undefined;
@@ -68,7 +69,19 @@ function Search(props: Props) {
? (params.getAll("statusFilter") as TStatusFilter[]) ? (params.getAll("statusFilter") as TStatusFilter[])
: [TStatusFilter.Published, TStatusFilter.Draft]; : [TStatusFilter.Published, TStatusFilter.Draft];
const titleFilter = params.get("titleFilter") === "true"; const titleFilter = params.get("titleFilter") === "true";
const hasFilters = !!(documentId || collectionId || userId || dateFilter);
const isSearchable = !!(query || collectionId || userId);
const document = documentId ? documents.get(documentId) : undefined;
const filterVisibility = {
document: !!document,
collection: !document,
user: !document || !!(document && query),
documentType: isSearchable,
date: isSearchable,
title: !!query && !document,
};
const filters = React.useMemo( const filters = React.useMemo(
() => ({ () => ({
@@ -100,22 +113,22 @@ function Search(props: Props) {
query, query,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}); });
}
if (isSearchable) {
return async () => return async () =>
titleFilter titleFilter
? await documents.searchTitles(query, filters) ? await documents.searchTitles(filters)
: await documents.search(query, filters); : await documents.search(filters);
} }
return () => Promise.resolve([] as SearchResult[]); return () => Promise.resolve([] as SearchResult[]);
}, [query, titleFilter, filters, searches, documents]); }, [query, titleFilter, filters, searches, documents, isSearchable]);
const { data, next, end, error, loading } = usePaginatedRequest(requestFn, { const { data, next, end, error, loading } = usePaginatedRequest(requestFn, {
limit: Pagination.defaultLimit, limit: Pagination.defaultLimit,
}); });
const document = documentId ? documents.get(documentId) : undefined;
const updateLocation = (query: string) => { const updateLocation = (query: string) => {
history.replace({ history.replace({
pathname: searchPath(query), pathname: searchPath(query),
@@ -225,39 +238,47 @@ function Search(props: Props) {
: t("Search") : t("Search")
}`} }`}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
defaultValue={query} defaultValue={query ?? ""}
/> />
{(query || hasFilters) && ( <Filters>
<Filters> {filterVisibility.document && (
{document && ( <DocumentFilter
<DocumentFilter document={document!}
document={document} onClick={() => {
onClick={() => { handleFilterChange({ documentId: undefined });
handleFilterChange({ documentId: undefined }); }}
}}
/>
)}
<DocumentTypeFilter
statusFilter={statusFilter}
onSelect={({ statusFilter }) =>
handleFilterChange({ statusFilter })
}
/> />
)}
{filterVisibility.collection && (
<CollectionFilter <CollectionFilter
collectionId={collectionId} collectionId={collectionId}
onSelect={(collectionId) => onSelect={(collectionId) =>
handleFilterChange({ collectionId }) handleFilterChange({ collectionId })
} }
/> />
)}
{filterVisibility.user && (
<UserFilter <UserFilter
userId={userId} userId={userId}
onSelect={(userId) => handleFilterChange({ userId })} onSelect={(userId) => handleFilterChange({ userId })}
/> />
)}
{filterVisibility.documentType && (
<DocumentTypeFilter
statusFilter={statusFilter}
onSelect={({ statusFilter }) =>
handleFilterChange({ statusFilter })
}
/>
)}
{filterVisibility.date && (
<DateFilter <DateFilter
dateFilter={dateFilter} dateFilter={dateFilter}
onSelect={(dateFilter) => handleFilterChange({ dateFilter })} onSelect={(dateFilter) => handleFilterChange({ dateFilter })}
/> />
)}
{filterVisibility.title && (
<SearchTitlesFilter <SearchTitlesFilter
width={26} width={26}
height={14} height={14}
@@ -267,10 +288,10 @@ function Search(props: Props) {
}} }}
checked={titleFilter} checked={titleFilter}
/> />
</Filters> )}
)} </Filters>
</form> </form>
{query ? ( {isSearchable ? (
<> <>
{error ? ( {error ? (
<Fade> <Fade>
@@ -322,7 +343,7 @@ function Search(props: Props) {
/> />
</ResultList> </ResultList>
</> </>
) : documentId || collectionId ? null : ( ) : documentId ? null : (
<RecentSearches ref={recentSearchesRef} onEscape={handleEscape} /> <RecentSearches ref={recentSearchesRef} onEscape={handleEscape} />
)} )}
</ResultsWrapper> </ResultsWrapper>

View File

@@ -1,7 +1,9 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { CollectionIcon as SVGCollectionIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import FilterOptions from "~/components/FilterOptions"; import FilterOptions from "~/components/FilterOptions";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
type Props = { type Props = {
@@ -16,14 +18,16 @@ function CollectionFilter(props: Props) {
const { collections } = useStores(); const { collections } = useStores();
const { onSelect, collectionId } = props; const { onSelect, collectionId } = props;
const options = React.useMemo(() => { const options = React.useMemo(() => {
const collectionOptions = collections.orderedData.map((user) => ({ const collectionOptions = collections.orderedData.map((collection) => ({
key: user.id, key: collection.id,
label: user.name, label: collection.name,
icon: <CollectionIcon collection={collection} size={18} />,
})); }));
return [ return [
{ {
key: "", key: "",
label: t("Any collection"), label: t("Any collection"),
icon: <SVGCollectionIcon size={18} />,
}, },
...collectionOptions, ...collectionOptions,
]; ];

View File

@@ -33,6 +33,7 @@ type FetchPageParams = PaginationParams & {
}; };
export type SearchParams = { export type SearchParams = {
query?: string;
offset?: number; offset?: number;
limit?: number; limit?: number;
dateFilter?: DateFilter; dateFilter?: DateFilter;
@@ -412,14 +413,10 @@ export default class DocumentsStore extends Store<Document> {
this.fetchNamedPage("list", options); this.fetchNamedPage("list", options);
@action @action
searchTitles = async ( searchTitles = async (options?: SearchParams): Promise<SearchResult[]> => {
query: string,
options?: SearchParams
): Promise<SearchResult[]> => {
const compactedOptions = omitBy(options, (o) => !o); const compactedOptions = omitBy(options, (o) => !o);
const res = await client.post("/documents.search_titles", { const res = await client.post("/documents.search_titles", {
...compactedOptions, ...compactedOptions,
query,
}); });
invariant(res?.data, "Search response should be available"); invariant(res?.data, "Search response should be available");
@@ -447,14 +444,10 @@ export default class DocumentsStore extends Store<Document> {
}; };
@action @action
search = async ( search = async (options: SearchParams): Promise<SearchResult[]> => {
query: string,
options: SearchParams
): Promise<SearchResult[]> => {
const compactedOptions = omitBy(options, (o) => !o); const compactedOptions = omitBy(options, (o) => !o);
const res = await client.post("/documents.search", { const res = await client.post("/documents.search", {
...compactedOptions, ...compactedOptions,
query,
}); });
invariant(res?.data, "Search response should be available"); invariant(res?.data, "Search response should be available");

View File

@@ -167,7 +167,7 @@ export type PaginationParams = {
export type SearchResult = { export type SearchResult = {
id: string; id: string;
ranking: number; ranking: number;
context: string; context?: string;
document: Document; document: Document;
}; };

View File

@@ -225,6 +225,7 @@ router.post(
} }
const options = { const options = {
query: text,
limit: 5, limit: 5,
}; };
@@ -238,11 +239,7 @@ router.post(
return; return;
} }
const { results, total } = await SearchHelper.searchForUser( const { results, total } = await SearchHelper.searchForUser(user, options);
user,
text,
options
);
await SearchQuery.create({ await SearchQuery.create({
userId: user ? user.id : null, userId: user ? user.id : null,

View File

@@ -27,11 +27,37 @@ describe("SearchHelper", () => {
collectionId: collection.id, collectionId: collection.id,
title: "test", title: "test",
}); });
const { results } = await SearchHelper.searchForTeam(team, "test"); const { results } = await SearchHelper.searchForTeam(team, {
query: "test",
});
expect(results.length).toBe(1); expect(results.length).toBe(1);
expect(results[0].document?.id).toBe(document.id); expect(results[0].document?.id).toBe(document.id);
}); });
test("should return search results from a collection without search term", async () => {
const team = await buildTeam();
const collection = await buildCollection({
teamId: team.id,
});
const documents = await Promise.all([
buildDocument({
teamId: team.id,
collectionId: collection.id,
title: "document 1",
}),
buildDocument({
teamId: team.id,
collectionId: collection.id,
title: "document 2",
}),
]);
const { results } = await SearchHelper.searchForTeam(team);
expect(results.length).toBe(2);
expect(results.map((r) => r.document.id).sort()).toEqual(
documents.map((doc) => doc.id).sort()
);
});
test("should not return results from private collections without providing collectionId", async () => { test("should not return results from private collections without providing collectionId", async () => {
const team = await buildTeam(); const team = await buildTeam();
const collection = await buildCollection({ const collection = await buildCollection({
@@ -43,7 +69,9 @@ describe("SearchHelper", () => {
collectionId: collection.id, collectionId: collection.id,
title: "test", title: "test",
}); });
const { results } = await SearchHelper.searchForTeam(team, "test"); const { results } = await SearchHelper.searchForTeam(team, {
query: "test",
});
expect(results.length).toBe(0); expect(results.length).toBe(0);
}); });
@@ -58,7 +86,8 @@ describe("SearchHelper", () => {
collectionId: collection.id, collectionId: collection.id,
title: "test", title: "test",
}); });
const { results } = await SearchHelper.searchForTeam(team, "test", { const { results } = await SearchHelper.searchForTeam(team, {
query: "test",
collectionId: collection.id, collectionId: collection.id,
}); });
expect(results.length).toBe(1); expect(results.length).toBe(1);
@@ -86,7 +115,8 @@ describe("SearchHelper", () => {
includeChildDocuments: true, includeChildDocuments: true,
}); });
const { results } = await SearchHelper.searchForTeam(team, "test", { const { results } = await SearchHelper.searchForTeam(team, {
query: "test",
collectionId: collection.id, collectionId: collection.id,
share, share,
}); });
@@ -95,13 +125,17 @@ describe("SearchHelper", () => {
test("should handle no collections", async () => { test("should handle no collections", async () => {
const team = await buildTeam(); const team = await buildTeam();
const { results } = await SearchHelper.searchForTeam(team, "test"); const { results } = await SearchHelper.searchForTeam(team, {
query: "test",
});
expect(results.length).toBe(0); expect(results.length).toBe(0);
}); });
test("should handle backslashes in search term", async () => { test("should handle backslashes in search term", async () => {
const team = await buildTeam(); const team = await buildTeam();
const { results } = await SearchHelper.searchForTeam(team, "\\\\"); const { results } = await SearchHelper.searchForTeam(team, {
query: "\\\\",
});
expect(results.length).toBe(0); expect(results.length).toBe(0);
}); });
@@ -120,7 +154,9 @@ describe("SearchHelper", () => {
collectionId: collection.id, collectionId: collection.id,
title: "test number 2", title: "test number 2",
}); });
const { total } = await SearchHelper.searchForTeam(team, "test"); const { total } = await SearchHelper.searchForTeam(team, {
query: "test",
});
expect(total).toBe(2); expect(total).toBe(2);
}); });
@@ -136,7 +172,9 @@ describe("SearchHelper", () => {
}); });
document.title = "change"; document.title = "change";
await document.save(); await document.save();
const { total } = await SearchHelper.searchForTeam(team, "test number"); const { total } = await SearchHelper.searchForTeam(team, {
query: "test number",
});
expect(total).toBe(1); expect(total).toBe(1);
}); });
@@ -152,10 +190,9 @@ describe("SearchHelper", () => {
}); });
document.title = "change"; document.title = "change";
await document.save(); await document.save();
const { total } = await SearchHelper.searchForTeam( const { total } = await SearchHelper.searchForTeam(team, {
team, query: "title doesn't exist",
"title doesn't exist" });
);
expect(total).toBe(0); expect(total).toBe(0);
}); });
}); });
@@ -181,16 +218,78 @@ describe("SearchHelper", () => {
deletedAt: new Date(), deletedAt: new Date(),
title: "test", title: "test",
}); });
const { results } = await SearchHelper.searchForUser(user, "test"); const { results } = await SearchHelper.searchForUser(user, {
query: "test",
});
expect(results.length).toBe(1); expect(results.length).toBe(1);
expect(results[0].ranking).toBeTruthy(); expect(results[0].ranking).toBeTruthy();
expect(results[0].document?.id).toBe(document.id); expect(results[0].document?.id).toBe(document.id);
}); });
test("should return search results for a user without search term", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
userId: user.id,
});
const documents = await Promise.all([
buildDocument({
teamId: team.id,
userId: user.id,
collectionId: collection.id,
title: "document 1",
}),
buildDocument({
teamId: team.id,
userId: user.id,
collectionId: collection.id,
title: "document 2",
}),
]);
const { results } = await SearchHelper.searchForUser(user);
expect(results.length).toBe(2);
expect(results.map((r) => r.document.id).sort()).toEqual(
documents.map((doc) => doc.id).sort()
);
});
test("should return search results from a collection without search term", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
userId: user.id,
});
const documents = await Promise.all([
buildDocument({
teamId: team.id,
userId: user.id,
collectionId: collection.id,
title: "document 1",
}),
buildDocument({
teamId: team.id,
userId: user.id,
collectionId: collection.id,
title: "document 2",
}),
]);
const { results } = await SearchHelper.searchForUser(user, {
collectionId: collection.id,
});
expect(results.length).toBe(2);
expect(results.map((r) => r.document.id).sort()).toEqual(
documents.map((doc) => doc.id).sort()
);
});
test("should handle no collections", async () => { test("should handle no collections", async () => {
const team = await buildTeam(); const team = await buildTeam();
const user = await buildUser({ teamId: team.id }); const user = await buildUser({ teamId: team.id });
const { results } = await SearchHelper.searchForUser(user, "test"); const { results } = await SearchHelper.searchForUser(user, {
query: "test",
});
expect(results.length).toBe(0); expect(results.length).toBe(0);
}); });
@@ -218,7 +317,8 @@ describe("SearchHelper", () => {
title: "test", title: "test",
archivedAt: new Date(), archivedAt: new Date(),
}); });
const { results } = await SearchHelper.searchForUser(user, "test", { const { results } = await SearchHelper.searchForUser(user, {
query: "test",
statusFilter: [StatusFilter.Draft], statusFilter: [StatusFilter.Draft],
}); });
expect(results.length).toBe(1); expect(results.length).toBe(1);
@@ -242,7 +342,8 @@ describe("SearchHelper", () => {
permission: DocumentPermission.Read, permission: DocumentPermission.Read,
}); });
const { results } = await SearchHelper.searchForUser(user, "test", { const { results } = await SearchHelper.searchForUser(user, {
query: "test",
statusFilter: [StatusFilter.Published, StatusFilter.Archived], statusFilter: [StatusFilter.Published, StatusFilter.Archived],
}); });
expect(results.length).toBe(0); expect(results.length).toBe(0);
@@ -272,7 +373,8 @@ describe("SearchHelper", () => {
title: "test", title: "test",
archivedAt: new Date(), archivedAt: new Date(),
}); });
const { results } = await SearchHelper.searchForUser(user, "test", { const { results } = await SearchHelper.searchForUser(user, {
query: "test",
statusFilter: [StatusFilter.Published], statusFilter: [StatusFilter.Published],
}); });
expect(results.length).toBe(1); expect(results.length).toBe(1);
@@ -308,7 +410,8 @@ describe("SearchHelper", () => {
title: "test", title: "test",
archivedAt: new Date(), archivedAt: new Date(),
}); });
const { results } = await SearchHelper.searchForUser(user, "test", { const { results } = await SearchHelper.searchForUser(user, {
query: "test",
statusFilter: [StatusFilter.Archived], statusFilter: [StatusFilter.Archived],
}); });
expect(results.length).toBe(1); expect(results.length).toBe(1);
@@ -335,7 +438,8 @@ describe("SearchHelper", () => {
title: "test", title: "test",
archivedAt: new Date(), archivedAt: new Date(),
}); });
const { results } = await SearchHelper.searchForUser(user, "test", { const { results } = await SearchHelper.searchForUser(user, {
query: "test",
statusFilter: [StatusFilter.Archived, StatusFilter.Published], statusFilter: [StatusFilter.Archived, StatusFilter.Published],
}); });
expect(results.length).toBe(2); expect(results.length).toBe(2);
@@ -362,7 +466,8 @@ describe("SearchHelper", () => {
title: "archived not draft", title: "archived not draft",
archivedAt: new Date(), archivedAt: new Date(),
}); });
const { results } = await SearchHelper.searchForUser(user, "draft", { const { results } = await SearchHelper.searchForUser(user, {
query: "draft",
statusFilter: [StatusFilter.Published, StatusFilter.Draft], statusFilter: [StatusFilter.Published, StatusFilter.Draft],
}); });
expect(results.length).toBe(2); expect(results.length).toBe(2);
@@ -389,7 +494,8 @@ describe("SearchHelper", () => {
title: "archived not draft", title: "archived not draft",
archivedAt: new Date(), archivedAt: new Date(),
}); });
const { results } = await SearchHelper.searchForUser(user, "draft", { const { results } = await SearchHelper.searchForUser(user, {
query: "draft",
statusFilter: [StatusFilter.Draft, StatusFilter.Archived], statusFilter: [StatusFilter.Draft, StatusFilter.Archived],
}); });
expect(results.length).toBe(2); expect(results.length).toBe(2);
@@ -414,7 +520,9 @@ describe("SearchHelper", () => {
collectionId: collection.id, collectionId: collection.id,
title: "test number 2", title: "test number 2",
}); });
const { total } = await SearchHelper.searchForUser(user, "test"); const { total } = await SearchHelper.searchForUser(user, {
query: "test",
});
expect(total).toBe(2); expect(total).toBe(2);
}); });
@@ -433,7 +541,9 @@ describe("SearchHelper", () => {
}); });
document.title = "change"; document.title = "change";
await document.save(); await document.save();
const { total } = await SearchHelper.searchForUser(user, "test number"); const { total } = await SearchHelper.searchForUser(user, {
query: "test number",
});
expect(total).toBe(1); expect(total).toBe(1);
}); });
@@ -452,10 +562,9 @@ describe("SearchHelper", () => {
}); });
document.title = "change"; document.title = "change";
await document.save(); await document.save();
const { total } = await SearchHelper.searchForUser( const { total } = await SearchHelper.searchForUser(user, {
user, query: "title doesn't exist",
"title doesn't exist" });
);
expect(total).toBe(0); expect(total).toBe(0);
}); });
@@ -474,7 +583,9 @@ describe("SearchHelper", () => {
}); });
document.title = "change"; document.title = "change";
await document.save(); await document.save();
const { total } = await SearchHelper.searchForUser(user, `"test number"`); const { total } = await SearchHelper.searchForUser(user, {
query: `"test number"`,
});
expect(total).toBe(1); expect(total).toBe(1);
}); });
@@ -493,7 +604,9 @@ describe("SearchHelper", () => {
}); });
document.title = "change"; document.title = "change";
await document.save(); await document.save();
const { total } = await SearchHelper.searchForUser(user, "env: "); const { total } = await SearchHelper.searchForUser(user, {
query: "env: ",
});
expect(total).toBe(1); expect(total).toBe(1);
}); });
}); });
@@ -512,7 +625,9 @@ describe("SearchHelper", () => {
collectionId: collection.id, collectionId: collection.id,
title: "test", title: "test",
}); });
const documents = await SearchHelper.searchTitlesForUser(user, "test"); const documents = await SearchHelper.searchTitlesForUser(user, {
query: "test",
});
expect(documents.length).toBe(1); expect(documents.length).toBe(1);
expect(documents[0]?.id).toBe(document.id); expect(documents[0]?.id).toBe(document.id);
}); });
@@ -545,7 +660,8 @@ describe("SearchHelper", () => {
collectionId: collection1.id, collectionId: collection1.id,
title: "test", title: "test",
}); });
const documents = await SearchHelper.searchTitlesForUser(user, "test", { const documents = await SearchHelper.searchTitlesForUser(user, {
query: "test",
collectionId: collection.id, collectionId: collection.id,
}); });
expect(documents.length).toBe(1); expect(documents.length).toBe(1);
@@ -555,7 +671,9 @@ describe("SearchHelper", () => {
test("should handle no collections", async () => { test("should handle no collections", async () => {
const team = await buildTeam(); const team = await buildTeam();
const user = await buildUser({ teamId: team.id }); const user = await buildUser({ teamId: team.id });
const documents = await SearchHelper.searchTitlesForUser(user, "test"); const documents = await SearchHelper.searchTitlesForUser(user, {
query: "test",
});
expect(documents.length).toBe(0); expect(documents.length).toBe(0);
}); });
@@ -583,7 +701,8 @@ describe("SearchHelper", () => {
title: "test", title: "test",
archivedAt: new Date(), archivedAt: new Date(),
}); });
const documents = await SearchHelper.searchTitlesForUser(user, "test", { const documents = await SearchHelper.searchTitlesForUser(user, {
query: "test",
statusFilter: [StatusFilter.Draft], statusFilter: [StatusFilter.Draft],
}); });
expect(documents.length).toBe(1); expect(documents.length).toBe(1);
@@ -613,7 +732,8 @@ describe("SearchHelper", () => {
title: "test", title: "test",
archivedAt: new Date(), archivedAt: new Date(),
}); });
const documents = await SearchHelper.searchTitlesForUser(user, "test", { const documents = await SearchHelper.searchTitlesForUser(user, {
query: "test",
statusFilter: [StatusFilter.Published], statusFilter: [StatusFilter.Published],
}); });
expect(documents.length).toBe(1); expect(documents.length).toBe(1);
@@ -649,7 +769,8 @@ describe("SearchHelper", () => {
title: "test", title: "test",
archivedAt: new Date(), archivedAt: new Date(),
}); });
const documents = await SearchHelper.searchTitlesForUser(user, "test", { const documents = await SearchHelper.searchTitlesForUser(user, {
query: "test",
statusFilter: [StatusFilter.Archived], statusFilter: [StatusFilter.Archived],
}); });
expect(documents.length).toBe(1); expect(documents.length).toBe(1);
@@ -676,7 +797,8 @@ describe("SearchHelper", () => {
title: "test", title: "test",
archivedAt: new Date(), archivedAt: new Date(),
}); });
const documents = await SearchHelper.searchTitlesForUser(user, "test", { const documents = await SearchHelper.searchTitlesForUser(user, {
query: "test",
statusFilter: [StatusFilter.Archived, StatusFilter.Published], statusFilter: [StatusFilter.Archived, StatusFilter.Published],
}); });
expect(documents.length).toBe(2); expect(documents.length).toBe(2);
@@ -703,7 +825,8 @@ describe("SearchHelper", () => {
title: "archived not draft", title: "archived not draft",
archivedAt: new Date(), archivedAt: new Date(),
}); });
const documents = await SearchHelper.searchTitlesForUser(user, "draft", { const documents = await SearchHelper.searchTitlesForUser(user, {
query: "draft",
statusFilter: [StatusFilter.Published, StatusFilter.Draft], statusFilter: [StatusFilter.Published, StatusFilter.Draft],
}); });
expect(documents.length).toBe(2); expect(documents.length).toBe(2);
@@ -730,7 +853,8 @@ describe("SearchHelper", () => {
title: "archived not draft", title: "archived not draft",
archivedAt: new Date(), archivedAt: new Date(),
}); });
const documents = await SearchHelper.searchTitlesForUser(user, "draft", { const documents = await SearchHelper.searchTitlesForUser(user, {
query: "draft",
statusFilter: [StatusFilter.Draft, StatusFilter.Archived], statusFilter: [StatusFilter.Draft, StatusFilter.Archived],
}); });
expect(documents.length).toBe(2); expect(documents.length).toBe(2);

View File

@@ -3,7 +3,15 @@ import escapeRegExp from "lodash/escapeRegExp";
import find from "lodash/find"; import find from "lodash/find";
import map from "lodash/map"; import map from "lodash/map";
import queryParser from "pg-tsquery"; import queryParser from "pg-tsquery";
import { Op, Sequelize, WhereOptions } from "sequelize"; import {
BindOrReplacements,
FindAttributeOptions,
FindOptions,
Op,
Order,
Sequelize,
WhereOptions,
} from "sequelize";
import { DateFilter, StatusFilter } from "@shared/types"; import { DateFilter, StatusFilter } from "@shared/types";
import { regexIndexOf, regexLastIndexOf } from "@shared/utils/string"; import { regexIndexOf, regexLastIndexOf } from "@shared/utils/string";
import { getUrls } from "@shared/utils/urls"; import { getUrls } from "@shared/utils/urls";
@@ -21,7 +29,7 @@ type SearchResponse = {
/** The search ranking, for sorting results */ /** The search ranking, for sorting results */
ranking: number; ranking: number;
/** A snippet of contextual text around the search result */ /** A snippet of contextual text around the search result */
context: string; context?: string;
/** The document result */ /** The document result */
document: Document; document: Document;
}[]; }[];
@@ -34,6 +42,8 @@ type SearchOptions = {
limit?: number; limit?: number;
/** The query offset for pagination */ /** The query offset for pagination */
offset?: number; offset?: number;
/** The text to search for */
query?: string;
/** Limit results to a collection. Authorization is presumed to have been done before passing to this helper. */ /** Limit results to a collection. Authorization is presumed to have been done before passing to this helper. */
collectionId?: string | null; collectionId?: string | null;
/** Limit results to a shared document. */ /** Limit results to a shared document. */
@@ -67,12 +77,11 @@ export default class SearchHelper {
public static async searchForTeam( public static async searchForTeam(
team: Team, team: Team,
query: string,
options: SearchOptions = {} options: SearchOptions = {}
): Promise<SearchResponse> { ): Promise<SearchResponse> {
const { limit = 15, offset = 0 } = options; const { limit = 15, offset = 0, query } = options;
const where = await this.buildWhere(team, query, { const where = await this.buildWhere(team, {
...options, ...options,
statusFilter: [...(options.statusFilter || []), StatusFilter.Published], statusFilter: [...(options.statusFilter || []), StatusFilter.Published],
}); });
@@ -92,34 +101,19 @@ export default class SearchHelper {
}); });
} }
const replacements = { const findOptions = this.buildFindOptions(query);
query: this.webSearchQuery(query),
};
try { try {
const resultsQuery = Document.unscoped().findAll({ const resultsQuery = Document.unscoped().findAll({
attributes: [ ...findOptions,
"id",
[
Sequelize.literal(
`ts_rank("searchVector", to_tsquery('english', :query))`
),
"searchRanking",
],
],
replacements,
where, where,
order: [
["searchRanking", "DESC"],
["updatedAt", "DESC"],
],
limit, limit,
offset, offset,
}) as any as Promise<RankedDocument[]>; }) as any as Promise<RankedDocument[]>;
const countQuery = Document.unscoped().count({ const countQuery = Document.unscoped().count({
// @ts-expect-error Types are incorrect for count // @ts-expect-error Types are incorrect for count
replacements, replacements: findOptions.replacements,
where, where,
}) as any as Promise<number>; }) as any as Promise<number>;
const [results, count] = await Promise.all([resultsQuery, countQuery]); const [results, count] = await Promise.all([resultsQuery, countQuery]);
@@ -138,7 +132,12 @@ export default class SearchHelper {
], ],
}); });
return this.buildResponse(query, results, documents, count); return this.buildResponse({
query,
results,
documents,
count,
});
} catch (err) { } catch (err) {
if (err.message.includes("syntax error in tsquery")) { if (err.message.includes("syntax error in tsquery")) {
throw ValidationError("Invalid search query"); throw ValidationError("Invalid search query");
@@ -149,17 +148,18 @@ export default class SearchHelper {
public static async searchTitlesForUser( public static async searchTitlesForUser(
user: User, user: User,
query: string,
options: SearchOptions = {} options: SearchOptions = {}
): Promise<Document[]> { ): Promise<Document[]> {
const { limit = 15, offset = 0 } = options; const { limit = 15, offset = 0, query, ...rest } = options;
const where = await this.buildWhere(user, undefined, options); const where = await this.buildWhere(user, rest);
where[Op.and].push({ if (query) {
title: { where[Op.and].push({
[Op.iLike]: `%${query}%`, title: {
}, [Op.iLike]: `%${query}%`,
}); },
});
}
const include = [ const include = [
{ {
@@ -206,16 +206,13 @@ export default class SearchHelper {
public static async searchForUser( public static async searchForUser(
user: User, user: User,
query: string,
options: SearchOptions = {} options: SearchOptions = {}
): Promise<SearchResponse> { ): Promise<SearchResponse> {
const { limit = 15, offset = 0 } = options; const { limit = 15, offset = 0, query } = options;
const where = await this.buildWhere(user, query, options); const where = await this.buildWhere(user, options);
const queryReplacements = { const findOptions = this.buildFindOptions(query);
query: this.webSearchQuery(query),
};
const include = [ const include = [
{ {
@@ -230,23 +227,10 @@ export default class SearchHelper {
try { try {
const results = (await Document.unscoped().findAll({ const results = (await Document.unscoped().findAll({
attributes: [ ...findOptions,
"id",
[
Sequelize.literal(
`ts_rank("searchVector", to_tsquery('english', :query))`
),
"searchRanking",
],
],
subQuery: false, subQuery: false,
include, include,
replacements: queryReplacements,
where, where,
order: [
["searchRanking", "DESC"],
["updatedAt", "DESC"],
],
limit, limit,
offset, offset,
})) as any as RankedDocument[]; })) as any as RankedDocument[];
@@ -255,7 +239,7 @@ export default class SearchHelper {
// @ts-expect-error Types are incorrect for count // @ts-expect-error Types are incorrect for count
subQuery: false, subQuery: false,
include, include,
replacements: queryReplacements, replacements: findOptions.replacements,
where, where,
}) as any as Promise<number>; }) as any as Promise<number>;
@@ -284,7 +268,12 @@ export default class SearchHelper {
: countQuery, : countQuery,
]); ]);
return this.buildResponse(query, results, documents, count); return this.buildResponse({
query,
results,
documents,
count,
});
} catch (err) { } catch (err) {
if (err.message.includes("syntax error in tsquery")) { if (err.message.includes("syntax error in tsquery")) {
throw ValidationError("Invalid search query"); throw ValidationError("Invalid search query");
@@ -293,6 +282,25 @@ export default class SearchHelper {
} }
} }
private static buildFindOptions(query?: string): FindOptions {
const attributes: FindAttributeOptions = ["id"];
const replacements: BindOrReplacements = {};
const order: Order = [["updatedAt", "DESC"]];
if (query) {
attributes.push([
Sequelize.literal(
`ts_rank("searchVector", to_tsquery('english', :query))`
),
"searchRanking",
]);
replacements["query"] = this.webSearchQuery(query);
order.unshift(["searchRanking", "DESC"]);
}
return { attributes, replacements, order };
}
private static buildResultContext(document: Document, query: string) { private static buildResultContext(document: Document, query: string) {
const quotedQueries = Array.from(query.matchAll(/"([^"]*)"/g)); const quotedQueries = Array.from(query.matchAll(/"([^"]*)"/g));
const text = DocumentHelper.toPlainText(document); const text = DocumentHelper.toPlainText(document);
@@ -349,11 +357,7 @@ export default class SearchHelper {
return context.slice(startIndex, endIndex); return context.slice(startIndex, endIndex);
} }
private static async buildWhere( private static async buildWhere(model: User | Team, options: SearchOptions) {
model: User | Team,
query: string | undefined,
options: SearchOptions
) {
const teamId = model instanceof Team ? model.id : model.teamId; const teamId = model instanceof Team ? model.id : model.teamId;
const where: WhereOptions<Document> & { const where: WhereOptions<Document> & {
[Op.or]: WhereOptions<Document>[]; [Op.or]: WhereOptions<Document>[];
@@ -462,15 +466,15 @@ export default class SearchHelper {
}); });
} }
if (query) { if (options.query) {
// find words that look like urls, these should be treated separately as the postgres full-text // find words that look like urls, these should be treated separately as the postgres full-text
// index will generally not match them. // index will generally not match them.
const likelyUrls = getUrls(query); const likelyUrls = getUrls(options.query);
// remove likely urls, and escape the rest of the query. // remove likely urls, and escape the rest of the query.
const limitedQuery = this.escapeQuery( const limitedQuery = this.escapeQuery(
likelyUrls likelyUrls
.reduce((q, url) => q.replace(url, ""), query) .reduce((q, url) => q.replace(url, ""), options.query)
.slice(0, this.maxQueryLength) .slice(0, this.maxQueryLength)
.trim() .trim()
); );
@@ -513,12 +517,17 @@ export default class SearchHelper {
return where; return where;
} }
private static buildResponse( private static buildResponse({
query: string, query,
results: RankedDocument[], results,
documents: Document[], documents,
count: number count,
): SearchResponse { }: {
query?: string;
results: RankedDocument[];
documents: Document[];
count: number;
}): SearchResponse {
return { return {
results: map(results, (result) => { results: map(results, (result) => {
const document = find(documents, { const document = find(documents, {
@@ -527,7 +536,7 @@ export default class SearchHelper {
return { return {
ranking: result.dataValues.searchRanking, ranking: result.dataValues.searchRanking,
context: this.buildResultContext(document, query), context: query ? this.buildResultContext(document, query) : undefined,
document, document,
}; };
}), }),

View File

@@ -1862,17 +1862,6 @@ describe("#documents.search", () => {
expect(body.data.length).toEqual(0); expect(body.data.length).toEqual(0);
}); });
it("should expect a query", async () => {
const user = await buildUser();
const res = await server.post("/api/documents.search", {
body: {
token: user.getJwtToken(),
query: " ",
},
});
expect(res.status).toEqual(400);
});
it("should not allow unknown dateFilter values", async () => { it("should not allow unknown dateFilter values", async () => {
const user = await buildUser(); const user = await buildUser();
const res = await server.post("/api/documents.search", { const res = await server.post("/api/documents.search", {

View File

@@ -904,8 +904,8 @@ router.post(
auth(), auth(),
pagination(), pagination(),
rateLimiter(RateLimiterStrategy.OneHundredPerMinute), rateLimiter(RateLimiterStrategy.OneHundredPerMinute),
validate(T.DocumentsSearchSchema), validate(T.DocumentsSearchTitlesSchema),
async (ctx: APIContext<T.DocumentsSearchReq>) => { async (ctx: APIContext<T.DocumentsSearchTitlesReq>) => {
const { query, statusFilter, dateFilter, collectionId, userId } = const { query, statusFilter, dateFilter, collectionId, userId } =
ctx.input.body; ctx.input.body;
const { offset, limit } = ctx.state.pagination; const { offset, limit } = ctx.state.pagination;
@@ -923,7 +923,8 @@ router.post(
collaboratorIds = [userId]; collaboratorIds = [userId];
} }
const documents = await SearchHelper.searchTitlesForUser(user, query, { const documents = await SearchHelper.searchTitlesForUser(user, {
query,
dateFilter, dateFilter,
statusFilter, statusFilter,
collectionId, collectionId,
@@ -989,7 +990,8 @@ router.post(
const team = await share.$get("team"); const team = await share.$get("team");
invariant(team, "Share must belong to a team"); invariant(team, "Share must belong to a team");
response = await SearchHelper.searchForTeam(team, query, { response = await SearchHelper.searchForTeam(team, {
query,
collectionId: document.collectionId, collectionId: document.collectionId,
share, share,
dateFilter, dateFilter,
@@ -1031,7 +1033,8 @@ router.post(
collaboratorIds = [userId]; collaboratorIds = [userId];
} }
response = await SearchHelper.searchForUser(user, query, { response = await SearchHelper.searchForUser(user, {
query,
collaboratorIds, collaboratorIds,
collectionId, collectionId,
documentIds, documentIds,
@@ -1059,7 +1062,7 @@ router.post(
// When requesting subsequent pages of search results we don't want to record // When requesting subsequent pages of search results we don't want to record
// duplicate search query records // duplicate search query records
if (offset === 0) { if (query && offset === 0) {
await SearchQuery.create({ await SearchQuery.create({
userId: user?.id, userId: user?.id,
teamId, teamId,

View File

@@ -36,9 +36,30 @@ const DateFilterSchema = z.object({
.optional(), .optional(),
}); });
const SearchQuerySchema = z.object({ const BaseSearchSchema = DateFilterSchema.extend({
/** Query for search */ /** Filter results for team based on the collection */
query: z.string().refine((v) => v.trim() !== ""), collectionId: z.string().uuid().optional(),
/** Filter results based on user */
userId: z.string().uuid().optional(),
/** Filter results based on content within a document and it's children */
documentId: z.string().uuid().optional(),
/** Document statuses to include in results */
statusFilter: z.nativeEnum(StatusFilter).array().optional(),
/** Filter results for the team derived from shareId */
shareId: z
.string()
.refine((val) => isUUID(val) || UrlHelper.SHARE_URL_SLUG_REGEX.test(val))
.optional(),
/** Min words to be shown in the results snippets */
snippetMinWords: z.number().default(20),
/** Max words to be accomodated in the results snippets */
snippetMaxWords: z.number().default(30),
}); });
const BaseIdSchema = z.object({ const BaseIdSchema = z.object({
@@ -153,35 +174,25 @@ export const DocumentsRestoreSchema = BaseSchema.extend({
export type DocumentsRestoreReq = z.infer<typeof DocumentsRestoreSchema>; export type DocumentsRestoreReq = z.infer<typeof DocumentsRestoreSchema>;
export const DocumentsSearchSchema = BaseSchema.extend({ export const DocumentsSearchSchema = BaseSchema.extend({
body: SearchQuerySchema.merge(DateFilterSchema).extend({ body: BaseSearchSchema.extend({
/** Filter results for team based on the collection */ /** Query for search */
collectionId: z.string().uuid().optional(), query: z.string().optional(),
/** Filter results based on user */
userId: z.string().uuid().optional(),
/** Filter results based on content within a document and it's children */
documentId: z.string().uuid().optional(),
/** Document statuses to include in results */
statusFilter: z.nativeEnum(StatusFilter).array().optional(),
/** Filter results for the team derived from shareId */
shareId: z
.string()
.refine((val) => isUUID(val) || UrlHelper.SHARE_URL_SLUG_REGEX.test(val))
.optional(),
/** Min words to be shown in the results snippets */
snippetMinWords: z.number().default(20),
/** Max words to be accomodated in the results snippets */
snippetMaxWords: z.number().default(30),
}), }),
}); });
export type DocumentsSearchReq = z.infer<typeof DocumentsSearchSchema>; export type DocumentsSearchReq = z.infer<typeof DocumentsSearchSchema>;
export const DocumentsSearchTitlesSchema = BaseSchema.extend({
body: BaseSearchSchema.extend({
/** Query for search */
query: z.string().refine((val) => val.trim() !== ""),
}),
});
export type DocumentsSearchTitlesReq = z.infer<
typeof DocumentsSearchTitlesSchema
>;
export const DocumentsDuplicateSchema = BaseSchema.extend({ export const DocumentsDuplicateSchema = BaseSchema.extend({
body: BaseIdSchema.extend({ body: BaseIdSchema.extend({
/** New document title */ /** New document title */