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:
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user