mirror of
https://github.com/outline/outline.git
synced 2025-12-20 18:19:43 -06:00
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:
@@ -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 }) => ({
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user