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

@@ -11,6 +11,7 @@ import {
NotFoundError,
InvalidRequestError,
AuthorizationError,
AuthenticationError,
} from "@server/errors";
import auth from "@server/middlewares/authentication";
import {
@@ -386,7 +387,7 @@ async function loadDocument({
}: {
id?: string;
shareId?: string;
user: User;
user?: User;
}): Promise<{
document: Document;
share?: Share;
@@ -396,6 +397,10 @@ async function loadDocument({
let collection;
let share;
if (!shareId && !(id && user)) {
throw AuthenticationError(`Authentication or shareId required`);
}
if (shareId) {
share = await Share.findOne({
where: {
@@ -454,7 +459,7 @@ async function loadDocument({
// If the user has access to read the document, we can just update
// the last access date and return the document without additional checks.
const canReadDocument = can(user, "read", document);
const canReadDocument = user && can(user, "read", document);
if (canReadDocument) {
await share.update({
@@ -519,9 +524,9 @@ async function loadDocument({
if (document.deletedAt) {
// don't send data if user cannot restore deleted doc
authorize(user, "restore", document);
user && authorize(user, "restore", document);
} else {
authorize(user, "read", document);
user && authorize(user, "read", document);
}
collection = document.collection;
@@ -739,82 +744,133 @@ router.post("documents.search_titles", auth(), pagination(), async (ctx) => {
};
});
router.post("documents.search", auth(), pagination(), async (ctx) => {
const {
query,
includeArchived,
includeDrafts,
collectionId,
userId,
dateFilter,
} = ctx.body;
const { offset, limit } = ctx.state.pagination;
const { user } = ctx.state;
assertNotEmpty(query, "query is required");
if (collectionId) {
assertUuid(collectionId, "collectionId must be a UUID");
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
}
let collaboratorIds = undefined;
if (userId) {
assertUuid(userId, "userId must be a UUID");
collaboratorIds = [userId];
}
if (dateFilter) {
assertIn(
dateFilter,
["day", "week", "month", "year"],
"dateFilter must be one of day,week,month,year"
);
}
const { results, totalCount } = await Document.searchForUser(user, query, {
includeArchived: includeArchived === "true",
includeDrafts: includeDrafts === "true",
collaboratorIds,
collectionId,
dateFilter,
offset,
limit,
});
const documents = results.map((result) => result.document);
const data = await Promise.all(
results.map(async (result) => {
const document = await presentDocument(result.document);
return { ...result, document };
})
);
// When requesting subsequent pages of search results we don't want to record
// duplicate search query records
if (offset === 0) {
SearchQuery.create({
userId: user.id,
teamId: user.teamId,
source: ctx.state.authType,
router.post(
"documents.search",
auth({
required: false,
}),
pagination(),
async (ctx) => {
const {
query,
results: totalCount,
});
includeArchived,
includeDrafts,
collectionId,
userId,
dateFilter,
shareId,
} = ctx.body;
assertNotEmpty(query, "query is required");
const { offset, limit } = ctx.state.pagination;
const snippetMinWords = parseInt(ctx.body.snippetMinWords || 20, 10);
const snippetMaxWords = parseInt(ctx.body.snippetMaxWords || 30, 10);
// this typing is a bit ugly, would be better to use a type like ContextWithState
// but that doesn't adequately handle cases when auth is optional
const { user }: { user: User | undefined } = ctx.state;
let teamId;
let response;
if (shareId) {
const { share, document } = await loadDocument({
shareId,
user,
});
if (!share?.includeChildDocuments) {
throw InvalidRequestError("Child documents cannot be searched");
}
teamId = share.teamId;
const team = await Team.findByPk(teamId);
invariant(team, "Share must belong to a team");
response = await Document.searchForTeam(team, query, {
includeArchived: includeArchived === "true",
includeDrafts: includeDrafts === "true",
collectionId: document.collectionId,
share,
dateFilter,
offset,
limit,
snippetMinWords,
snippetMaxWords,
});
} else {
if (!user) {
throw AuthenticationError("Authentication error");
}
teamId = user.teamId;
if (collectionId) {
assertUuid(collectionId, "collectionId must be a UUID");
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "read", collection);
}
let collaboratorIds = undefined;
if (userId) {
assertUuid(userId, "userId must be a UUID");
collaboratorIds = [userId];
}
if (dateFilter) {
assertIn(
dateFilter,
["day", "week", "month", "year"],
"dateFilter must be one of day,week,month,year"
);
}
response = await Document.searchForUser(user, query, {
includeArchived: includeArchived === "true",
includeDrafts: includeDrafts === "true",
collaboratorIds,
collectionId,
dateFilter,
offset,
limit,
snippetMinWords,
snippetMaxWords,
});
}
const { results, totalCount } = response;
const documents = results.map((result) => result.document);
const data = await Promise.all(
results.map(async (result) => {
const document = await presentDocument(result.document);
return { ...result, document };
})
);
// When requesting subsequent pages of search results we don't want to record
// duplicate search query records
if (offset === 0) {
SearchQuery.create({
userId: user?.id,
teamId,
shareId,
source: ctx.state.authType || "app", // we'll consider anything that isn't "api" to be "app"
query,
results: totalCount,
});
}
ctx.body = {
pagination: ctx.state.pagination,
data,
policies: user ? presentPolicies(user, documents) : null,
};
}
const policies = presentPolicies(user, documents);
ctx.body = {
pagination: ctx.state.pagination,
data,
policies,
};
});
);
// Deprecated use stars.create instead
router.post("documents.star", auth(), async (ctx) => {