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 <tom.moor@gmail.com>
This commit is contained in:
@@ -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 (
|
||||
<Wrapper>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<StyledButton {...props} className={className} neutral disclosure>
|
||||
{activeKey ? selectedLabel : defaultLabel}
|
||||
{selectedItems.length ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
@@ -56,7 +61,7 @@ const FilterOptions = ({
|
||||
onSelect(option.key);
|
||||
menu.hide();
|
||||
}}
|
||||
selected={option.key === activeKey}
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
{...menu}
|
||||
>
|
||||
{option.icon && <Icon>{option.icon}</Icon>}
|
||||
|
||||
@@ -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) {
|
||||
<>
|
||||
<Filters>
|
||||
<DocumentTypeFilter
|
||||
includeArchived={includeArchived}
|
||||
includeDrafts={includeDrafts}
|
||||
onSelect={({ includeArchived, includeDrafts }) =>
|
||||
handleFilterChange({ includeArchived, includeDrafts })
|
||||
statusFilter={statusFilter}
|
||||
onSelect={({ statusFilter }) =>
|
||||
handleFilterChange({ statusFilter })
|
||||
}
|
||||
/>
|
||||
<CollectionFilter
|
||||
|
||||
@@ -29,7 +29,7 @@ function CollectionFilter(props: Props) {
|
||||
return (
|
||||
<FilterOptions
|
||||
options={options}
|
||||
activeKey={collectionId}
|
||||
selectedKeys={[collectionId]}
|
||||
onSelect={onSelect}
|
||||
defaultLabel={t("Any collection")}
|
||||
selectedPrefix={`${t("Collection")}:`}
|
||||
|
||||
@@ -39,7 +39,7 @@ const DateFilter = ({ dateFilter, onSelect }: Props) => {
|
||||
return (
|
||||
<FilterOptions
|
||||
options={options}
|
||||
activeKey={dateFilter}
|
||||
selectedKeys={[dateFilter]}
|
||||
onSelect={onSelect}
|
||||
defaultLabel={t("Any time")}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<FilterOptions
|
||||
options={options}
|
||||
activeKey={getActiveKey()}
|
||||
selectedKeys={statusFilter}
|
||||
onSelect={handleSelect}
|
||||
defaultLabel={t("Document type")}
|
||||
defaultLabel={t("Any status")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ function UserFilter(props: Props) {
|
||||
return (
|
||||
<FilterOptions
|
||||
options={options}
|
||||
activeKey={userId}
|
||||
selectedKeys={[userId]}
|
||||
onSelect={onSelect}
|
||||
defaultLabel={t("Any author")}
|
||||
selectedPrefix={`${t("Author")}:`}
|
||||
|
||||
@@ -56,7 +56,7 @@ const UserStatusFilter = ({ activeKey, onSelect, ...rest }: Props) => {
|
||||
return (
|
||||
<FilterOptions
|
||||
options={options}
|
||||
activeKey={activeKey}
|
||||
selectedKeys={[activeKey]}
|
||||
onSelect={onSelect}
|
||||
defaultLabel={t("Active")}
|
||||
{...rest}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
JSONObject,
|
||||
NavigationNode,
|
||||
PublicTeam,
|
||||
StatusFilter,
|
||||
} from "@shared/types";
|
||||
import { subtractDate } from "@shared/utils/date";
|
||||
import { bytesToHumanReadable } from "@shared/utils/files";
|
||||
@@ -36,8 +37,7 @@ export type SearchParams = {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
dateFilter?: DateFilter;
|
||||
includeArchived?: boolean;
|
||||
includeDrafts?: boolean;
|
||||
statusFilter?: StatusFilter[];
|
||||
collectionId?: string;
|
||||
userId?: string;
|
||||
shareId?: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DocumentPermission } from "@shared/types";
|
||||
import { DocumentPermission, StatusFilter } from "@shared/types";
|
||||
import SearchHelper from "@server/models/helpers/SearchHelper";
|
||||
import {
|
||||
buildDocument,
|
||||
@@ -208,28 +208,30 @@ 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 { 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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<T.DocumentsSearchReq>) => {
|
||||
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<T.DocumentsSearchReq>) => {
|
||||
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,
|
||||
|
||||
@@ -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<typeof DocumentsRestoreSchema>;
|
||||
|
||||
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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user