mirror of
https://github.com/outline/outline.git
synced 2025-03-14 10:07:11 +00: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
|
||||
const results = await documents.searchTitles(term);
|
||||
const results = await documents.searchTitles({ query: term });
|
||||
|
||||
return sortBy(
|
||||
results.map(({ document }) => ({
|
||||
|
@ -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,
|
||||
});
|
||||
|
@ -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 && (
|
||||
{filterVisibility.document && (
|
||||
<DocumentFilter
|
||||
document={document}
|
||||
document={document!}
|
||||
onClick={() => {
|
||||
handleFilterChange({ documentId: undefined });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DocumentTypeFilter
|
||||
statusFilter={statusFilter}
|
||||
onSelect={({ statusFilter }) =>
|
||||
handleFilterChange({ statusFilter })
|
||||
}
|
||||
/>
|
||||
{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>
|
||||
|
@ -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,
|
||||
];
|
||||
|
@ -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");
|
||||
|
||||
|
@ -167,7 +167,7 @@ export type PaginationParams = {
|
||||
export type SearchResult = {
|
||||
id: string;
|
||||
ranking: number;
|
||||
context: string;
|
||||
context?: string;
|
||||
document: Document;
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
||||
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,
|
||||
};
|
||||
}),
|
||||
|
@ -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", {
|
||||
|
@ -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,
|
||||
|
@ -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 */
|
||||
|
Reference in New Issue
Block a user