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
const results = await documents.searchTitles(term);
const results = await documents.searchTitles({ query: term });
return sortBy(
results.map(({ document }) => ({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,15 @@ import escapeRegExp from "lodash/escapeRegExp";
import find from "lodash/find";
import map from "lodash/map";
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 { regexIndexOf, regexLastIndexOf } from "@shared/utils/string";
import { getUrls } from "@shared/utils/urls";
@ -21,7 +29,7 @@ type SearchResponse = {
/** The search ranking, for sorting results */
ranking: number;
/** A snippet of contextual text around the search result */
context: string;
context?: string;
/** The document result */
document: Document;
}[];
@ -34,6 +42,8 @@ type SearchOptions = {
limit?: number;
/** The query offset for pagination */
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. */
collectionId?: string | null;
/** Limit results to a shared document. */
@ -67,12 +77,11 @@ export default class SearchHelper {
public static async searchForTeam(
team: Team,
query: string,
options: SearchOptions = {}
): 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,
statusFilter: [...(options.statusFilter || []), StatusFilter.Published],
});
@ -92,34 +101,19 @@ export default class SearchHelper {
});
}
const replacements = {
query: this.webSearchQuery(query),
};
const findOptions = this.buildFindOptions(query);
try {
const resultsQuery = Document.unscoped().findAll({
attributes: [
"id",
[
Sequelize.literal(
`ts_rank("searchVector", to_tsquery('english', :query))`
),
"searchRanking",
],
],
replacements,
...findOptions,
where,
order: [
["searchRanking", "DESC"],
["updatedAt", "DESC"],
],
limit,
offset,
}) as any as Promise<RankedDocument[]>;
const countQuery = Document.unscoped().count({
// @ts-expect-error Types are incorrect for count
replacements,
replacements: findOptions.replacements,
where,
}) as any as Promise<number>;
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) {
if (err.message.includes("syntax error in tsquery")) {
throw ValidationError("Invalid search query");
@ -149,17 +148,18 @@ export default class SearchHelper {
public static async searchTitlesForUser(
user: User,
query: string,
options: SearchOptions = {}
): Promise<Document[]> {
const { limit = 15, offset = 0 } = options;
const where = await this.buildWhere(user, undefined, options);
const { limit = 15, offset = 0, query, ...rest } = options;
const where = await this.buildWhere(user, rest);
where[Op.and].push({
title: {
[Op.iLike]: `%${query}%`,
},
});
if (query) {
where[Op.and].push({
title: {
[Op.iLike]: `%${query}%`,
},
});
}
const include = [
{
@ -206,16 +206,13 @@ export default class SearchHelper {
public static async searchForUser(
user: User,
query: string,
options: SearchOptions = {}
): 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 = {
query: this.webSearchQuery(query),
};
const findOptions = this.buildFindOptions(query);
const include = [
{
@ -230,23 +227,10 @@ export default class SearchHelper {
try {
const results = (await Document.unscoped().findAll({
attributes: [
"id",
[
Sequelize.literal(
`ts_rank("searchVector", to_tsquery('english', :query))`
),
"searchRanking",
],
],
...findOptions,
subQuery: false,
include,
replacements: queryReplacements,
where,
order: [
["searchRanking", "DESC"],
["updatedAt", "DESC"],
],
limit,
offset,
})) as any as RankedDocument[];
@ -255,7 +239,7 @@ export default class SearchHelper {
// @ts-expect-error Types are incorrect for count
subQuery: false,
include,
replacements: queryReplacements,
replacements: findOptions.replacements,
where,
}) as any as Promise<number>;
@ -284,7 +268,12 @@ export default class SearchHelper {
: countQuery,
]);
return this.buildResponse(query, results, documents, count);
return this.buildResponse({
query,
results,
documents,
count,
});
} catch (err) {
if (err.message.includes("syntax error in tsquery")) {
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) {
const quotedQueries = Array.from(query.matchAll(/"([^"]*)"/g));
const text = DocumentHelper.toPlainText(document);
@ -349,11 +357,7 @@ export default class SearchHelper {
return context.slice(startIndex, endIndex);
}
private static async buildWhere(
model: User | Team,
query: string | undefined,
options: SearchOptions
) {
private static async buildWhere(model: User | Team, options: SearchOptions) {
const teamId = model instanceof Team ? model.id : model.teamId;
const where: 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
// 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.
const limitedQuery = this.escapeQuery(
likelyUrls
.reduce((q, url) => q.replace(url, ""), query)
.reduce((q, url) => q.replace(url, ""), options.query)
.slice(0, this.maxQueryLength)
.trim()
);
@ -513,12 +517,17 @@ export default class SearchHelper {
return where;
}
private static buildResponse(
query: string,
results: RankedDocument[],
documents: Document[],
count: number
): SearchResponse {
private static buildResponse({
query,
results,
documents,
count,
}: {
query?: string;
results: RankedDocument[];
documents: Document[];
count: number;
}): SearchResponse {
return {
results: map(results, (result) => {
const document = find(documents, {
@ -527,7 +536,7 @@ export default class SearchHelper {
return {
ranking: result.dataValues.searchRanking,
context: this.buildResultContext(document, query),
context: query ? this.buildResultContext(document, query) : undefined,
document,
};
}),

View File

@ -1862,17 +1862,6 @@ describe("#documents.search", () => {
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 () => {
const user = await buildUser();
const res = await server.post("/api/documents.search", {

View File

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

View File

@ -36,9 +36,30 @@ const DateFilterSchema = z.object({
.optional(),
});
const SearchQuerySchema = z.object({
/** Query for search */
query: z.string().refine((v) => v.trim() !== ""),
const BaseSearchSchema = DateFilterSchema.extend({
/** Filter results for team based on the collection */
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({
@ -153,35 +174,25 @@ export const DocumentsRestoreSchema = BaseSchema.extend({
export type DocumentsRestoreReq = z.infer<typeof DocumentsRestoreSchema>;
export const DocumentsSearchSchema = BaseSchema.extend({
body: SearchQuerySchema.merge(DateFilterSchema).extend({
/** Filter results for team based on the collection */
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),
body: BaseSearchSchema.extend({
/** Query for search */
query: z.string().optional(),
}),
});
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({
body: BaseIdSchema.extend({
/** New document title */