From 50b90b8878fa3c6278dbbec71000c5db9798bb99 Mon Sep 17 00:00:00 2001 From: Pranav Joglekar Date: Sun, 25 Feb 2024 00:32:19 +0530 Subject: [PATCH] improv: use statusFilter instead of includeArchive,includeDrafts for document search (#6537) * improv: use statusFilter instead of includeArchive,includeDrafts for document search * improv: update FilterComponent to add support for multiple selected items * feat: update document type search ui * fix test * Restore support for old parameters to avoid breaking change --------- Co-authored-by: Tom Moor --- app/components/FilterOptions.tsx | 19 +- app/scenes/Search/Search.tsx | 26 +- .../Search/components/CollectionFilter.tsx | 2 +- app/scenes/Search/components/DateFilter.tsx | 2 +- .../Search/components/DocumentTypeFilter.tsx | 66 +--- app/scenes/Search/components/UserFilter.tsx | 2 +- .../Settings/components/UserStatusFilter.tsx | 2 +- app/stores/DocumentsStore.ts | 4 +- server/models/helpers/SearchHelper.test.ts | 326 +++++++++++++++--- server/models/helpers/SearchHelper.ts | 67 ++-- server/routes/api/documents/documents.test.ts | 27 +- server/routes/api/documents/documents.ts | 34 +- server/routes/api/documents/schema.ts | 25 +- shared/i18n/locales/en_US/translation.json | 9 +- shared/types.ts | 6 + 15 files changed, 426 insertions(+), 191 deletions(-) diff --git a/app/components/FilterOptions.tsx b/app/components/FilterOptions.tsx index ebd0351e6..428226df0 100644 --- a/app/components/FilterOptions.tsx +++ b/app/components/FilterOptions.tsx @@ -16,7 +16,7 @@ type TFilterOption = { type Props = { options: TFilterOption[]; - activeKey: string | null | undefined; + selectedKeys: (string | null | undefined)[]; defaultLabel?: string; selectedPrefix?: string; className?: string; @@ -25,7 +25,7 @@ type Props = { const FilterOptions = ({ options, - activeKey = "", + selectedKeys = [], defaultLabel = "Filter options", selectedPrefix = "", className, @@ -34,17 +34,22 @@ const FilterOptions = ({ const menu = useMenuState({ modal: true, }); - const selected = - options.find((option) => option.key === activeKey) || options[0]; + const selectedItems = options.filter((option) => + selectedKeys.includes(option.key) + ); - const selectedLabel = selected ? `${selectedPrefix} ${selected.label}` : ""; + const selectedLabel = selectedItems.length + ? selectedItems + .map((selected) => `${selectedPrefix} ${selected.label}`) + .join(", ") + : ""; return ( {(props) => ( - {activeKey ? selectedLabel : defaultLabel} + {selectedItems.length ? selectedLabel : defaultLabel} )} @@ -56,7 +61,7 @@ const FilterOptions = ({ onSelect(option.key); menu.hide(); }} - selected={option.key === activeKey} + selected={selectedKeys.includes(option.key)} {...menu} > {option.icon && {option.icon}} diff --git a/app/scenes/Search/Search.tsx b/app/scenes/Search/Search.tsx index 283b1d38e..a138fac4d 100644 --- a/app/scenes/Search/Search.tsx +++ b/app/scenes/Search/Search.tsx @@ -9,7 +9,10 @@ import breakpoint from "styled-components-breakpoint"; import { v4 as uuidv4 } from "uuid"; import { Pagination } from "@shared/constants"; import { hideScrollbars } from "@shared/styles"; -import { DateFilter as TDateFilter } from "@shared/types"; +import { + DateFilter as TDateFilter, + StatusFilter as TStatusFilter, +} from "@shared/types"; import ArrowKeyNavigation from "~/components/ArrowKeyNavigation"; import DocumentListItem from "~/components/DocumentListItem"; import Empty from "~/components/Empty"; @@ -54,18 +57,18 @@ function Search(props: Props) { // filters const query = decodeURIComponentSafe(routeMatch.params.term ?? ""); - const includeArchived = params.get("includeArchived") === "true"; - const includeDrafts = params.get("includeDrafts") !== "false"; const collectionId = params.get("collectionId") ?? undefined; const userId = params.get("userId") ?? undefined; const dateFilter = (params.get("dateFilter") as TDateFilter) ?? undefined; + const statusFilter = params.getAll("statusFilter")?.length + ? (params.getAll("statusFilter") as TStatusFilter[]) + : [TStatusFilter.Published, TStatusFilter.Draft]; const titleFilter = params.get("titleFilter") === "true"; const filters = React.useMemo( () => ({ query, - includeArchived, - includeDrafts, + statusFilter, collectionId, userId, dateFilter, @@ -73,8 +76,7 @@ function Search(props: Props) { }), [ query, - includeArchived, - includeDrafts, + JSON.stringify(statusFilter), collectionId, userId, dateFilter, @@ -118,8 +120,7 @@ function Search(props: Props) { collectionId?: string | undefined; userId?: string | undefined; dateFilter?: TDateFilter; - includeArchived?: boolean | undefined; - includeDrafts?: boolean | undefined; + statusFilter?: TStatusFilter[]; titleFilter?: boolean | undefined; }) => { history.replace({ @@ -214,10 +215,9 @@ function Search(props: Props) { <> - handleFilterChange({ includeArchived, includeDrafts }) + statusFilter={statusFilter} + onSelect={({ statusFilter }) => + handleFilterChange({ statusFilter }) } /> { return ( diff --git a/app/scenes/Search/components/DocumentTypeFilter.tsx b/app/scenes/Search/components/DocumentTypeFilter.tsx index c27fc7106..79211fa9e 100644 --- a/app/scenes/Search/components/DocumentTypeFilter.tsx +++ b/app/scenes/Search/components/DocumentTypeFilter.tsx @@ -1,80 +1,50 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; +import { StatusFilter as TStatusFilter } from "@shared/types"; import FilterOptions from "~/components/FilterOptions"; type Props = { - includeArchived?: boolean; - includeDrafts?: boolean; - onSelect: (option: { - includeArchived?: boolean; - includeDrafts?: boolean; - }) => void; + statusFilter: TStatusFilter[]; + onSelect: (option: { statusFilter: TStatusFilter[] }) => void; }; -enum DocumentType { - Published = "published", - Active = "active", - All = "all", -} - -const DocumentTypeFilter = ({ - includeArchived, - includeDrafts, - onSelect, -}: Props) => { +const DocumentTypeFilter = ({ statusFilter, onSelect }: Props) => { const { t } = useTranslation(); const options = React.useMemo( () => [ { - key: DocumentType.Published, + key: TStatusFilter.Published, label: t("Published documents"), - note: t("Documents you have access to, excluding drafts"), }, { - key: DocumentType.Active, - label: t("Active documents"), - note: t("Documents you have access to, including drafts"), + key: TStatusFilter.Archived, + label: t("Archived documents"), }, { - key: DocumentType.All, - label: t("All documents"), - note: t("Documents you have access to, including drafts and archived"), + key: TStatusFilter.Draft, + label: t("Draft documents"), }, ], [t] ); - const getActiveKey = () => { - if (includeArchived && includeDrafts) { - return DocumentType.All; + const handleSelect = (key: TStatusFilter) => { + let modifiedStatusFilter; + if (statusFilter.includes(key)) { + modifiedStatusFilter = statusFilter.filter((status) => status !== key); + } else { + modifiedStatusFilter = [...statusFilter, key]; } - if (includeDrafts) { - return DocumentType.Active; - } - - return DocumentType.Published; - }; - - const handleSelect = (key: DocumentType) => { - switch (key) { - case DocumentType.Published: - return onSelect({ includeArchived: false, includeDrafts: false }); - case DocumentType.Active: - return onSelect({ includeArchived: false, includeDrafts: true }); - case DocumentType.All: - return onSelect({ includeArchived: true, includeDrafts: true }); - default: - onSelect({ includeArchived: false, includeDrafts: false }); - } + onSelect({ statusFilter: modifiedStatusFilter }); }; return ( ); }; diff --git a/app/scenes/Search/components/UserFilter.tsx b/app/scenes/Search/components/UserFilter.tsx index 8f138df54..c816ae4de 100644 --- a/app/scenes/Search/components/UserFilter.tsx +++ b/app/scenes/Search/components/UserFilter.tsx @@ -43,7 +43,7 @@ function UserFilter(props: Props) { return ( { return ( { createdById: user.id, title: "test", }); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "test", + }); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "test", + archivedAt: new Date(), + }); const { results } = await SearchHelper.searchForUser(user, "test", { - includeDrafts: true, + statusFilter: [StatusFilter.Draft], }); expect(results.length).toBe(1); }); - test("should not include drafts", async () => { + test("should not include drafts with user read permission", async () => { const user = await buildUser(); await buildDraftDocument({ - teamId: user.teamId, - userId: user.id, - createdById: user.id, title: "test", }); - const { results } = await SearchHelper.searchForUser(user, "test", { - includeDrafts: false, - }); - expect(results.length).toBe(0); - }); - - test("should not include drafts with user permission", async () => { - const user = await buildUser(); const draft = await buildDraftDocument({ teamId: user.teamId, userId: user.id, @@ -244,32 +246,105 @@ describe("SearchHelper", () => { }); const { results } = await SearchHelper.searchForUser(user, "test", { - includeDrafts: false, + statusFilter: [StatusFilter.Published, StatusFilter.Archived], }); expect(results.length).toBe(0); }); - test("should include results from drafts as well", async () => { + test("should search only published created by user", async () => { const user = await buildUser(); await buildDocument({ - userId: user.id, - teamId: user.teamId, - createdById: user.id, - title: "not draft", + title: "test", }); await buildDraftDocument({ teamId: user.teamId, userId: user.id, createdById: user.id, - title: "draft", + title: "test", }); - const { results } = await SearchHelper.searchForUser(user, "draft", { - includeDrafts: true, + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "test", + }); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "test", + archivedAt: new Date(), + }); + const { results } = await SearchHelper.searchForUser(user, "test", { + statusFilter: [StatusFilter.Published], + }); + expect(results.length).toBe(1); + }); + + test("should search only archived documents created by user", async () => { + const user = await buildUser(); + await buildDocument({ + title: "test", + }); + await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "test", + }); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "test", + }); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "test", + }); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "test", + archivedAt: new Date(), + }); + const { results } = await SearchHelper.searchForUser(user, "test", { + statusFilter: [StatusFilter.Archived], + }); + expect(results.length).toBe(1); + }); + + test("should return results from archived and published", async () => { + const user = await buildUser(); + await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "test", + }); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "test", + }); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "test", + archivedAt: new Date(), + }); + const { results } = await SearchHelper.searchForUser(user, "test", { + statusFilter: [StatusFilter.Archived, StatusFilter.Published], }); expect(results.length).toBe(2); }); - test("should not include results from drafts", async () => { + test("should return results from drafts and published", async () => { const user = await buildUser(); await buildDocument({ userId: user.id, @@ -283,10 +358,44 @@ describe("SearchHelper", () => { createdById: user.id, title: "draft", }); - const { results } = await SearchHelper.searchForUser(user, "draft", { - includeDrafts: false, + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "archived not draft", + archivedAt: new Date(), }); - expect(results.length).toBe(1); + const { results } = await SearchHelper.searchForUser(user, "draft", { + statusFilter: [StatusFilter.Published, StatusFilter.Draft], + }); + expect(results.length).toBe(2); + }); + + test("should include results from drafts and archived", async () => { + const user = await buildUser(); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "not draft", + }); + await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "draft", + }); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "archived not draft", + archivedAt: new Date(), + }); + const { results } = await SearchHelper.searchForUser(user, "draft", { + statusFilter: [StatusFilter.Draft, StatusFilter.Archived], + }); + expect(results.length).toBe(2); }); test("should return the total count of search results", async () => { @@ -421,38 +530,37 @@ describe("SearchHelper", () => { test("should search only drafts created by user", async () => { const user = await buildUser(); await buildDraftDocument({ - teamId: user.teamId, - userId: user.id, - createdById: user.id, title: "test", }); - const documents = await SearchHelper.searchTitlesForUser(user, "test", { - includeDrafts: true, - }); - expect(documents.length).toBe(1); - }); - - test("should not include drafts", async () => { - const user = await buildUser(); await buildDraftDocument({ teamId: user.teamId, userId: user.id, createdById: user.id, title: "test", }); - const documents = await SearchHelper.searchTitlesForUser(user, "test", { - includeDrafts: false, - }); - expect(documents.length).toBe(0); - }); - - test("should include results from drafts as well", async () => { - const user = await buildUser(); await buildDocument({ userId: user.id, teamId: user.teamId, createdById: user.id, - title: "not test", + title: "test", + }); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "test", + archivedAt: new Date(), + }); + const documents = await SearchHelper.searchTitlesForUser(user, "test", { + statusFilter: [StatusFilter.Draft], + }); + expect(documents.length).toBe(1); + }); + + test("should search only published created by user", async () => { + const user = await buildUser(); + await buildDocument({ + title: "test", }); await buildDraftDocument({ teamId: user.teamId, @@ -460,30 +568,140 @@ describe("SearchHelper", () => { createdById: user.id, title: "test", }); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "test", + }); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "test", + archivedAt: new Date(), + }); const documents = await SearchHelper.searchTitlesForUser(user, "test", { - includeDrafts: true, + statusFilter: [StatusFilter.Published], + }); + expect(documents.length).toBe(1); + }); + + test("should search only archived documents created by user", async () => { + const user = await buildUser(); + await buildDocument({ + title: "test", + }); + await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "test", + }); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "test", + }); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "test", + }); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "test", + archivedAt: new Date(), + }); + const documents = await SearchHelper.searchTitlesForUser(user, "test", { + statusFilter: [StatusFilter.Archived], + }); + expect(documents.length).toBe(1); + }); + + test("should return results from archived and published", async () => { + const user = await buildUser(); + await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "test", + }); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "test", + }); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "test", + archivedAt: new Date(), + }); + const documents = await SearchHelper.searchTitlesForUser(user, "test", { + statusFilter: [StatusFilter.Archived, StatusFilter.Published], }); expect(documents.length).toBe(2); }); - test("should not include results from drafts", async () => { + test("should return results from drafts and published", async () => { const user = await buildUser(); await buildDocument({ userId: user.id, teamId: user.teamId, createdById: user.id, - title: "not test", + title: "not draft", }); await buildDraftDocument({ teamId: user.teamId, userId: user.id, createdById: user.id, - title: "test", + title: "draft", }); - const documents = await SearchHelper.searchTitlesForUser(user, "test", { - includeDrafts: false, + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "archived not draft", + archivedAt: new Date(), }); - expect(documents.length).toBe(1); + const documents = await SearchHelper.searchTitlesForUser(user, "draft", { + statusFilter: [StatusFilter.Published, StatusFilter.Draft], + }); + expect(documents.length).toBe(2); + }); + + test("should include results from drafts and archived", async () => { + const user = await buildUser(); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "not draft", + }); + await buildDraftDocument({ + teamId: user.teamId, + userId: user.id, + createdById: user.id, + title: "draft", + }); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + createdById: user.id, + title: "archived not draft", + archivedAt: new Date(), + }); + const documents = await SearchHelper.searchTitlesForUser(user, "draft", { + statusFilter: [StatusFilter.Draft, StatusFilter.Archived], + }); + expect(documents.length).toBe(2); }); }); diff --git a/server/models/helpers/SearchHelper.ts b/server/models/helpers/SearchHelper.ts index 1fb8e5c8d..70e5d4f4a 100644 --- a/server/models/helpers/SearchHelper.ts +++ b/server/models/helpers/SearchHelper.ts @@ -4,7 +4,7 @@ import find from "lodash/find"; import map from "lodash/map"; import queryParser from "pg-tsquery"; import { Op, Sequelize, WhereOptions } from "sequelize"; -import { DateFilter } from "@shared/types"; +import { DateFilter, StatusFilter } from "@shared/types"; import Collection from "@server/models/Collection"; import Document from "@server/models/Document"; import Share from "@server/models/Share"; @@ -36,12 +36,10 @@ type SearchOptions = { share?: Share; /** Limit results to a date range. */ dateFilter?: DateFilter; + /** Status of the documents to return */ + statusFilter?: StatusFilter[]; /** Limit results to a list of users that collaborated on the document. */ collaboratorIds?: string[]; - /** Include archived documents in the results */ - includeArchived?: boolean; - /** Include draft documents in the results (will only ever return your own) */ - includeDrafts?: boolean; /** The minimum number of words to be returned in the contextual snippet */ snippetMinWords?: number; /** The maximum number of words to be returned in the contextual snippet */ @@ -356,36 +354,59 @@ export default class SearchHelper { }); } - if (!options.includeArchived) { - where[Op.and].push({ - archivedAt: { - [Op.eq]: null, - }, - }); - } - - if (options.includeDrafts && model instanceof User) { - where[Op.and].push({ - [Op.or]: [ + const statusQuery = []; + if (options.statusFilter?.includes(StatusFilter.Published)) { + statusQuery.push({ + [Op.and]: [ { publishedAt: { [Op.ne]: null, }, + archivedAt: { + [Op.eq]: null, + }, }, - { - createdById: model.id, - }, - { "$memberships.id$": { [Op.ne]: null } }, ], }); - } else { - where[Op.and].push({ - publishedAt: { + } + + if ( + options.statusFilter?.includes(StatusFilter.Draft) && + // Only ever include draft results for the user's own documents + model instanceof User + ) { + statusQuery.push({ + [Op.and]: [ + { + publishedAt: { + [Op.eq]: null, + }, + archivedAt: { + [Op.eq]: null, + }, + [Op.or]: [ + { createdById: model.id }, + { "$memberships.id$": { [Op.ne]: null } }, + ], + }, + ], + }); + } + + if (options.statusFilter?.includes(StatusFilter.Archived)) { + statusQuery.push({ + archivedAt: { [Op.ne]: null, }, }); } + if (options.statusFilter?.length) { + where[Op.and].push({ + [Op.or]: statusQuery, + }); + } + return where; } diff --git a/server/routes/api/documents/documents.test.ts b/server/routes/api/documents/documents.test.ts index 9ea8b7826..135d512ad 100644 --- a/server/routes/api/documents/documents.test.ts +++ b/server/routes/api/documents/documents.test.ts @@ -1,6 +1,10 @@ import { faker } from "@faker-js/faker"; import { addMinutes, subDays } from "date-fns"; -import { CollectionPermission, DocumentPermission } from "@shared/types"; +import { + CollectionPermission, + DocumentPermission, + StatusFilter, +} from "@shared/types"; import { Document, View, @@ -1104,7 +1108,7 @@ describe("#documents.search_titles", () => { body: { token: member.getJwtToken(), query: "title", - includeDrafts: true, + statusFilter: [StatusFilter.Draft], }, }); const body = await res.json(); @@ -1215,7 +1219,7 @@ describe("#documents.search_titles", () => { body: { token: user.getJwtToken(), query: "SECRET", - includeArchived: true, + statusFilter: [StatusFilter.Archived], }, }); const body = await res.json(); @@ -1235,7 +1239,7 @@ describe("#documents.search_titles", () => { body: { token: user.getJwtToken(), query: "SECRET", - includeDrafts: true, + statusFilter: [StatusFilter.Draft], }, }); const body = await res.json(); @@ -1282,6 +1286,7 @@ describe("#documents.search_titles", () => { body: { token: user.getJwtToken(), query: "SECRET", + statusFilter: [StatusFilter.Published, StatusFilter.Draft], }, }); const body = await res.json(); @@ -1371,7 +1376,7 @@ describe("#documents.search", () => { body: { token: user.getJwtToken(), shareId: share.id, - includeDrafts: true, + statusFilter: [StatusFilter.Draft], query: "test", }, }); @@ -1540,6 +1545,7 @@ describe("#documents.search", () => { body: { token: user.getJwtToken(), query: "search term", + statusFilter: [StatusFilter.Published, StatusFilter.Archived], }, }); const body = await res.json(); @@ -1574,7 +1580,7 @@ describe("#documents.search", () => { body: { token: user.getJwtToken(), query: "search term", - includeDrafts: true, + statusFilter: [StatusFilter.Draft], }, }); const body = await res.json(); @@ -1595,7 +1601,7 @@ describe("#documents.search", () => { const res = await server.post("/api/documents.search", { body: { token: user.getJwtToken(), - includeDrafts: true, + statusFilter: [StatusFilter.Draft], query: "text", }, }); @@ -1616,7 +1622,7 @@ describe("#documents.search", () => { body: { token: user.getJwtToken(), query: "search term", - includeDrafts: true, + statusFilter: [StatusFilter.Draft], }, }); const body = await res.json(); @@ -1636,6 +1642,7 @@ describe("#documents.search", () => { body: { token: user.getJwtToken(), query: "search term", + statusFilter: [StatusFilter.Published, StatusFilter.Draft], }, }); const body = await res.json(); @@ -1655,7 +1662,7 @@ describe("#documents.search", () => { body: { token: user.getJwtToken(), query: "search term", - includeArchived: true, + statusFilter: [StatusFilter.Archived], }, }); const body = await res.json(); @@ -1899,7 +1906,7 @@ describe("#documents.search", () => { body: { token: member.getJwtToken(), query: "title", - includeDrafts: true, + statusFilter: [StatusFilter.Draft], }, }); const body = await res.json(); diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index ad1aa6f03..8d780fca2 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -7,7 +7,7 @@ import Router from "koa-router"; import escapeRegExp from "lodash/escapeRegExp"; import mime from "mime-types"; import { Op, ScopeOptions, Sequelize, WhereOptions } from "sequelize"; -import { TeamPreference } from "@shared/types"; +import { StatusFilter, TeamPreference } from "@shared/types"; import { subtractDate } from "@shared/utils/date"; import slugify from "@shared/utils/slugify"; import documentCreator from "@server/commands/documentCreator"; @@ -732,14 +732,8 @@ router.post( rateLimiter(RateLimiterStrategy.OneHundredPerMinute), validate(T.DocumentsSearchSchema), async (ctx: APIContext) => { - const { - query, - includeArchived, - includeDrafts, - dateFilter, - collectionId, - userId, - } = ctx.input.body; + const { query, statusFilter, dateFilter, collectionId, userId } = + ctx.input.body; const { offset, limit } = ctx.state.pagination; const { user } = ctx.state.auth; let collaboratorIds = undefined; @@ -756,9 +750,8 @@ router.post( } const documents = await SearchHelper.searchTitlesForUser(user, query, { - includeArchived, - includeDrafts, dateFilter, + statusFilter, collectionId, collaboratorIds, offset, @@ -786,11 +779,12 @@ router.post( async (ctx: APIContext) => { const { query, - includeArchived, - includeDrafts, collectionId, userId, dateFilter, + statusFilter = [], + includeArchived, + includeDrafts, shareId, snippetMinWords, snippetMaxWords, @@ -800,6 +794,14 @@ router.post( // Unfortunately, this still doesn't adequately handle cases when auth is optional const { user } = ctx.state.auth; + // TODO: Deprecated filter options, remove in a few versions + if (includeArchived && !statusFilter.includes(StatusFilter.Archived)) { + statusFilter.push(StatusFilter.Archived); + } + if (includeDrafts && !statusFilter.includes(StatusFilter.Draft)) { + statusFilter.push(StatusFilter.Draft); + } + let teamId; let response; let share; @@ -823,11 +825,10 @@ router.post( invariant(team, "Share must belong to a team"); response = await SearchHelper.searchForTeam(team, query, { - includeArchived, - includeDrafts, collectionId: document.collectionId, share, dateFilter, + statusFilter, offset, limit, snippetMinWords, @@ -854,11 +855,10 @@ router.post( } response = await SearchHelper.searchForUser(user, query, { - includeArchived, - includeDrafts, collaboratorIds, collectionId, dateFilter, + statusFilter, offset, limit, snippetMinWords, diff --git a/server/routes/api/documents/schema.ts b/server/routes/api/documents/schema.ts index c69e0f2a9..a7aac22aa 100644 --- a/server/routes/api/documents/schema.ts +++ b/server/routes/api/documents/schema.ts @@ -3,7 +3,7 @@ import formidable from "formidable"; import isEmpty from "lodash/isEmpty"; import isUUID from "validator/lib/isUUID"; import { z } from "zod"; -import { DocumentPermission } from "@shared/types"; +import { DocumentPermission, StatusFilter } from "@shared/types"; import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers"; import { BaseSchema } from "@server/routes/api/schema"; @@ -147,18 +147,29 @@ export type DocumentsRestoreReq = z.infer; export const DocumentsSearchSchema = BaseSchema.extend({ body: SearchQuerySchema.merge(DateFilterSchema).extend({ - /** Whether to include archived docs in results */ - includeArchived: z.boolean().optional(), - - /** Whether to include drafts in results */ - includeDrafts: z.boolean().optional(), - /** Filter results for team based on the collection */ collectionId: z.string().uuid().optional(), /** Filter results based on user */ userId: z.string().uuid().optional(), + /** + * Whether to include archived documents in results + * + * @deprecated Use `statusFilter` instead + */ + includeArchived: z.boolean().optional(), + + /** + * Whether to include draft documents in results + * + * @deprecated Use `statusFilter` instead + */ + includeDrafts: z.boolean().optional(), + + /** Document statuses to include in results */ + statusFilter: z.nativeEnum(StatusFilter).array().optional(), + /** Filter results for the team derived from shareId */ shareId: z .string() diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 7948374c0..9f4262372 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -740,12 +740,9 @@ "Past month": "Past month", "Past year": "Past year", "Published documents": "Published documents", - "Documents you have access to, excluding drafts": "Documents you have access to, excluding drafts", - "Active documents": "Active documents", - "Documents you have access to, including drafts": "Documents you have access to, including drafts", - "All documents": "All documents", - "Documents you have access to, including drafts and archived": "Documents you have access to, including drafts and archived", - "Document type": "Document type", + "Archived documents": "Archived documents", + "Draft documents": "Draft documents", + "Any status": "Any status", "Search Results": "Search Results", "Remove search": "Remove search", "Any author": "Any author", diff --git a/shared/types.ts b/shared/types.ts index 8f311fe7b..c9efee1ff 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -6,6 +6,12 @@ export enum UserRole { export type DateFilter = "day" | "week" | "month" | "year"; +export enum StatusFilter { + Published = "published", + Archived = "archived", + Draft = "draft", +} + export enum Client { Web = "web", Desktop = "desktop",