feat: Search shared documents (#3126)

* provide a type-ahead search input on shared document pages that allow search of child document tree
* improve keyboard navigation handling of all search views
* improve coloring on dark mode list selection states
* refactor PaginatedList component to eliminate edge cases
This commit is contained in:
Nan Yu
2022-04-08 10:40:51 -07:00
committed by GitHub
parent 5fb5e69181
commit 75a868e5e8
22 changed files with 804 additions and 168 deletions

View File

@@ -4,6 +4,7 @@ import {
buildCollection,
buildTeam,
buildUser,
buildShare,
} from "@server/test/factories";
import { flushdb, seed } from "@server/test/support";
import slugify from "@server/utils/slugify";
@@ -26,7 +27,7 @@ paragraph 2`,
const document = await buildDocument({
version: 0,
text: `# Heading
*paragraph*`,
});
expect(document.getSummary()).toBe("paragraph");
@@ -174,7 +175,7 @@ describe("#searchForTeam", () => {
expect(results[0].document?.id).toBe(document.id);
});
test("should not return search results from private collections", async () => {
test("should not return results from private collections without providing collectionId", async () => {
const team = await buildTeam();
const collection = await buildCollection({
permission: null,
@@ -189,6 +190,52 @@ describe("#searchForTeam", () => {
expect(results.length).toBe(0);
});
test("should return results from private collections when collectionId is provided", async () => {
const team = await buildTeam();
const collection = await buildCollection({
permission: null,
teamId: team.id,
});
await buildDocument({
teamId: team.id,
collectionId: collection.id,
title: "test",
});
const { results } = await Document.searchForTeam(team, "test", {
collectionId: collection.id,
});
expect(results.length).toBe(1);
});
test("should return results from document tree of shared document", async () => {
const team = await buildTeam();
const collection = await buildCollection({
permission: null,
teamId: team.id,
});
const document = await buildDocument({
teamId: team.id,
collectionId: collection.id,
title: "test 1",
});
await buildDocument({
teamId: team.id,
collectionId: collection.id,
title: "test 2",
});
const share = await buildShare({
documentId: document.id,
includeChildDocuments: true,
});
const { results } = await Document.searchForTeam(team, "test", {
collectionId: collection.id,
share,
});
expect(results.length).toBe(1);
});
test("should handle no collections", async () => {
const team = await buildTeam();
const { results } = await Document.searchForTeam(team, "test");

View File

@@ -1,4 +1,5 @@
import removeMarkdown from "@tommoor/remove-markdown";
import invariant from "invariant";
import { compact, find, map, uniq } from "lodash";
import randomstring from "randomstring";
import {
@@ -38,6 +39,7 @@ import slugify from "@server/utils/slugify";
import Backlink from "./Backlink";
import Collection from "./Collection";
import Revision from "./Revision";
import Share from "./Share";
import Star from "./Star";
import Team from "./Team";
import User from "./User";
@@ -45,7 +47,7 @@ import View from "./View";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
type SearchResponse = {
export type SearchResponse = {
results: {
ranking: number;
context: string;
@@ -58,10 +60,13 @@ type SearchOptions = {
limit?: number;
offset?: number;
collectionId?: string;
share?: Share;
dateFilter?: DateFilter;
collaboratorIds?: string[];
includeArchived?: boolean;
includeDrafts?: boolean;
snippetMinWords?: number;
snippetMaxWords?: number;
};
const serializer = new MarkdownSerializer();
@@ -436,12 +441,24 @@ class Document extends ParanoidModel {
query: string,
options: SearchOptions = {}
): Promise<SearchResponse> {
const limit = options.limit || 15;
const offset = options.offset || 0;
const wildcardQuery = `${escape(query)}:*`;
const collectionIds = await team.collectionIds();
const {
snippetMinWords = 20,
snippetMaxWords = 30,
limit = 15,
offset = 0,
} = options;
// If the team has access no public collections then shortcircuit the rest of this
// restrict to specific collection if provided
// enables search in private collections if specified
let collectionIds;
if (options.collectionId) {
collectionIds = [options.collectionId];
} else {
collectionIds = await team.collectionIds();
}
// short circuit if no relevant collections
if (!collectionIds.length) {
return {
results: [],
@@ -449,11 +466,25 @@ class Document extends ParanoidModel {
};
}
// Build the SQL query to get documentIds, ranking, and search term context
// restrict to documents in the tree of a shared document when one is provided
let documentIds;
if (options.share?.includeChildDocuments) {
const sharedDocument = await options.share.$get("document");
invariant(sharedDocument, "Cannot find document for share");
const childDocumentIds = await sharedDocument.getChildDocumentIds();
documentIds = [sharedDocument.id, ...childDocumentIds];
}
const documentClause = documentIds ? `"id" IN(:documentIds) AND` : "";
// Build the SQL query to get result documentIds, ranking, and search term context
const whereClause = `
"searchVector" @@ to_tsquery('english', :query) AND
"teamId" = :teamId AND
"collectionId" IN(:collectionIds) AND
${documentClause}
"deletedAt" IS NULL AND
"publishedAt" IS NOT NULL
`;
@@ -461,7 +492,7 @@ class Document extends ParanoidModel {
SELECT
id,
ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking",
ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=20, MaxWords=30') as "searchContext"
ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=:snippetMinWords, MaxWords=:snippetMaxWords') as "searchContext"
FROM documents
WHERE ${whereClause}
ORDER BY
@@ -479,6 +510,9 @@ class Document extends ParanoidModel {
teamId: team.id,
query: wildcardQuery,
collectionIds,
documentIds,
snippetMinWords,
snippetMaxWords,
};
const resultsQuery = this.sequelize!.query(selectSql, {
type: QueryTypes.SELECT,
@@ -526,8 +560,12 @@ class Document extends ParanoidModel {
query: string,
options: SearchOptions = {}
): Promise<SearchResponse> {
const limit = options.limit || 15;
const offset = options.offset || 0;
const {
snippetMinWords = 20,
snippetMaxWords = 30,
limit = 15,
offset = 0,
} = options;
const wildcardQuery = `${escape(query)}:*`;
// Ensure we're filtering by the users accessible collections. If
@@ -580,7 +618,7 @@ class Document extends ParanoidModel {
SELECT
id,
ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking",
ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=20, MaxWords=30') as "searchContext"
ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=:snippetMinWords, MaxWords=:snippetMaxWords') as "searchContext"
FROM documents
WHERE ${whereClause}
ORDER BY
@@ -601,6 +639,8 @@ class Document extends ParanoidModel {
query: wildcardQuery,
collectionIds,
dateFilter,
snippetMinWords,
snippetMaxWords,
};
const resultsQuery = this.sequelize!.query(selectSql, {
type: QueryTypes.SELECT,