Support for filter by parent document (#6850)
* Backend support for filter by parent document * parentDocumentId -> documentId
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
||||
EditIcon,
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
StarredIcon,
|
||||
TrashIcon,
|
||||
UnstarredIcon,
|
||||
@@ -20,6 +21,7 @@ import { createAction } from "~/actions";
|
||||
import { CollectionSection } from "~/actions/sections";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import history from "~/utils/history";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
|
||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
|
||||
<DynamicCollectionIcon collection={collection} />
|
||||
@@ -111,6 +113,17 @@ export const editCollectionPermissions = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const searchInCollection = createAction({
|
||||
name: ({ t }) => t("Search in collection"),
|
||||
analyticsName: "Search collection",
|
||||
section: CollectionSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ activeCollectionId }) => !!activeCollectionId,
|
||||
perform: ({ activeCollectionId }) => {
|
||||
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
|
||||
},
|
||||
});
|
||||
|
||||
export const starCollection = createAction({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star collection",
|
||||
|
||||
@@ -606,6 +606,17 @@ export const pinDocument = createAction({
|
||||
children: [pinDocumentToCollection, pinDocumentToHome],
|
||||
});
|
||||
|
||||
export const searchInDocument = createAction({
|
||||
name: ({ t }) => t("Search in document"),
|
||||
analyticsName: "Search document",
|
||||
section: DocumentSection,
|
||||
icon: <SearchIcon />,
|
||||
visible: ({ activeDocumentId }) => !!activeDocumentId,
|
||||
perform: ({ activeDocumentId }) => {
|
||||
history.push(searchPath(undefined, { documentId: activeDocumentId }));
|
||||
},
|
||||
});
|
||||
|
||||
export const printDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Print") : t("Print document"),
|
||||
@@ -613,7 +624,7 @@ export const printDocument = createAction({
|
||||
section: DocumentSection,
|
||||
icon: <PrintIcon />,
|
||||
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
|
||||
perform: async () => {
|
||||
perform: () => {
|
||||
queueMicrotask(window.print);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ const FilterOptions = ({
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<div>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<StyledButton {...props} className={className} neutral disclosure>
|
||||
@@ -76,7 +76,7 @@ const FilterOptions = ({
|
||||
</MenuItem>
|
||||
))}
|
||||
</ContextMenu>
|
||||
</Wrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -98,7 +98,7 @@ const LabelWithNote = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
export const StyledButton = styled(Button)`
|
||||
box-shadow: none;
|
||||
text-transform: none;
|
||||
border-color: transparent;
|
||||
@@ -120,8 +120,4 @@ const Icon = styled.div`
|
||||
height: 18px;
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
export default FilterOptions;
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function useActionContext(
|
||||
isContextMenu: false,
|
||||
isCommandBar: false,
|
||||
isButton: false,
|
||||
activeCollectionId: stores.ui.activeCollectionId,
|
||||
activeCollectionId: stores.ui.activeCollectionId ?? undefined,
|
||||
activeDocumentId: stores.ui.activeDocumentId,
|
||||
currentUserId: stores.auth.user?.id,
|
||||
currentTeamId: stores.auth.team?.id,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
editCollectionPermissions,
|
||||
starCollection,
|
||||
unstarCollection,
|
||||
searchInCollection,
|
||||
} from "~/actions/definitions/collections";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
@@ -205,6 +206,7 @@ function CollectionMenu({
|
||||
onClick: handleExport,
|
||||
icon: <ExportIcon />,
|
||||
},
|
||||
actionToMenuItem(searchInCollection, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
createNestedDocument,
|
||||
shareDocument,
|
||||
copyDocument,
|
||||
searchInDocument,
|
||||
} from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
@@ -94,7 +95,7 @@ function DocumentMenu({
|
||||
const context = useActionContext({
|
||||
isContextMenu: true,
|
||||
activeDocumentId: document.id,
|
||||
activeCollectionId: document.collectionId,
|
||||
activeCollectionId: document.collectionId ?? undefined,
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
@@ -305,6 +306,7 @@ function DocumentMenu({
|
||||
actionToMenuItem(downloadDocument, context),
|
||||
actionToMenuItem(copyDocument, context),
|
||||
actionToMenuItem(printDocument, context),
|
||||
actionToMenuItem(searchInDocument, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ import { searchPath } from "~/utils/routeHelpers";
|
||||
import { decodeURIComponentSafe } from "~/utils/urls";
|
||||
import CollectionFilter from "./components/CollectionFilter";
|
||||
import DateFilter from "./components/DateFilter";
|
||||
import { DocumentFilter } from "./components/DocumentFilter";
|
||||
import DocumentTypeFilter from "./components/DocumentTypeFilter";
|
||||
import RecentSearches from "./components/RecentSearches";
|
||||
import SearchInput from "./components/SearchInput";
|
||||
@@ -59,11 +60,13 @@ function Search(props: Props) {
|
||||
const query = decodeURIComponentSafe(routeMatch.params.term ?? "");
|
||||
const collectionId = params.get("collectionId") ?? undefined;
|
||||
const userId = params.get("userId") ?? undefined;
|
||||
const documentId = params.get("documentId") ?? 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 hasFilters = !!(documentId || collectionId || userId || dateFilter);
|
||||
|
||||
const filters = React.useMemo(
|
||||
() => ({
|
||||
@@ -73,6 +76,7 @@ function Search(props: Props) {
|
||||
userId,
|
||||
dateFilter,
|
||||
titleFilter,
|
||||
documentId,
|
||||
}),
|
||||
[
|
||||
query,
|
||||
@@ -81,6 +85,7 @@ function Search(props: Props) {
|
||||
userId,
|
||||
dateFilter,
|
||||
titleFilter,
|
||||
documentId,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -107,6 +112,8 @@ function Search(props: Props) {
|
||||
limit: Pagination.defaultLimit,
|
||||
});
|
||||
|
||||
const document = documentId ? documents.get(documentId) : undefined;
|
||||
|
||||
const updateLocation = (query: string) => {
|
||||
history.replace({
|
||||
pathname: searchPath(query),
|
||||
@@ -118,6 +125,7 @@ function Search(props: Props) {
|
||||
// some complexity as the query string is the source of truth for the filters.
|
||||
const handleFilterChange = (search: {
|
||||
collectionId?: string | undefined;
|
||||
documentId?: string | undefined;
|
||||
userId?: string | undefined;
|
||||
dateFilter?: TDateFilter;
|
||||
statusFilter?: TStatusFilter[];
|
||||
@@ -206,44 +214,58 @@ function Search(props: Props) {
|
||||
<SearchInput
|
||||
key={query ? "search" : "recent"}
|
||||
ref={searchInputRef}
|
||||
placeholder={`${t("Search")}…`}
|
||||
placeholder={`${
|
||||
documentId
|
||||
? t("Search in document")
|
||||
: collectionId
|
||||
? t("Search in collection")
|
||||
: t("Search")
|
||||
}…`}
|
||||
onKeyDown={handleKeyDown}
|
||||
defaultValue={query}
|
||||
/>
|
||||
|
||||
{(query || hasFilters) && (
|
||||
<Filters>
|
||||
{document && (
|
||||
<DocumentFilter
|
||||
document={document}
|
||||
onClick={() => {
|
||||
handleFilterChange({ documentId: undefined });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DocumentTypeFilter
|
||||
statusFilter={statusFilter}
|
||||
onSelect={({ statusFilter }) =>
|
||||
handleFilterChange({ statusFilter })
|
||||
}
|
||||
/>
|
||||
<CollectionFilter
|
||||
collectionId={collectionId}
|
||||
onSelect={(collectionId) => handleFilterChange({ collectionId })}
|
||||
/>
|
||||
<UserFilter
|
||||
userId={userId}
|
||||
onSelect={(userId) => handleFilterChange({ userId })}
|
||||
/>
|
||||
<DateFilter
|
||||
dateFilter={dateFilter}
|
||||
onSelect={(dateFilter) => handleFilterChange({ dateFilter })}
|
||||
/>
|
||||
<SearchTitlesFilter
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Search titles only")}
|
||||
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleFilterChange({ titleFilter: ev.target.checked });
|
||||
}}
|
||||
checked={titleFilter}
|
||||
/>
|
||||
</Filters>
|
||||
)}
|
||||
{query ? (
|
||||
<>
|
||||
<Filters>
|
||||
<DocumentTypeFilter
|
||||
statusFilter={statusFilter}
|
||||
onSelect={({ statusFilter }) =>
|
||||
handleFilterChange({ statusFilter })
|
||||
}
|
||||
/>
|
||||
<CollectionFilter
|
||||
collectionId={collectionId}
|
||||
onSelect={(collectionId) =>
|
||||
handleFilterChange({ collectionId })
|
||||
}
|
||||
/>
|
||||
<UserFilter
|
||||
userId={userId}
|
||||
onSelect={(userId) => handleFilterChange({ userId })}
|
||||
/>
|
||||
<DateFilter
|
||||
dateFilter={dateFilter}
|
||||
onSelect={(dateFilter) => handleFilterChange({ dateFilter })}
|
||||
/>
|
||||
<SearchTitlesFilter
|
||||
width={26}
|
||||
height={14}
|
||||
label={t("Search titles only")}
|
||||
onChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleFilterChange({ titleFilter: ev.target.checked });
|
||||
}}
|
||||
checked={titleFilter}
|
||||
/>
|
||||
</Filters>
|
||||
{showEmpty && (
|
||||
<Fade>
|
||||
<Centered column>
|
||||
@@ -282,7 +304,7 @@ function Search(props: Props) {
|
||||
/>
|
||||
</ResultList>
|
||||
</>
|
||||
) : (
|
||||
) : documentId || collectionId ? null : (
|
||||
<RecentSearches
|
||||
ref={recentSearchesCompositeRef}
|
||||
onEscape={handleEscape}
|
||||
@@ -323,6 +345,7 @@ const Filters = styled(Flex)`
|
||||
overflow-y: hidden;
|
||||
overflow-x: auto;
|
||||
padding: 8px 0;
|
||||
gap: 8px;
|
||||
${hideScrollbars()}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
|
||||
25
app/scenes/Search/components/DocumentFilter.tsx
Normal file
25
app/scenes/Search/components/DocumentFilter.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { CloseIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Document from "~/models/Document";
|
||||
import { StyledButton } from "~/components/FilterOptions";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
onClick: React.MouseEventHandler;
|
||||
};
|
||||
|
||||
export function DocumentFilter(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tooltip content={t("Remove document filter")} delay={350}>
|
||||
<StyledButton onClick={props.onClick} icon={<CloseIcon />} neutral>
|
||||
{props.document.title}
|
||||
</StyledButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export type ActionContext = {
|
||||
isCommandBar: boolean;
|
||||
isButton: boolean;
|
||||
inStarredSection?: boolean;
|
||||
activeCollectionId?: string | null;
|
||||
activeCollectionId?: string | undefined;
|
||||
activeDocumentId: string | undefined;
|
||||
currentUserId: string | undefined;
|
||||
currentTeamId: string | undefined;
|
||||
|
||||
@@ -101,6 +101,7 @@ export function searchPath(
|
||||
query?: string,
|
||||
params: {
|
||||
collectionId?: string;
|
||||
documentId?: string;
|
||||
ref?: string;
|
||||
} = {}
|
||||
): string {
|
||||
|
||||
@@ -40,6 +40,8 @@ type SearchOptions = {
|
||||
dateFilter?: DateFilter;
|
||||
/** Status of the documents to return */
|
||||
statusFilter?: StatusFilter[];
|
||||
/** Limit results to a list of documents. */
|
||||
documentIds?: string[];
|
||||
/** Limit results to a list of users that collaborated on the document. */
|
||||
collaboratorIds?: string[];
|
||||
/** The minimum number of words to be returned in the contextual snippet */
|
||||
@@ -367,6 +369,12 @@ export default class SearchHelper {
|
||||
});
|
||||
}
|
||||
|
||||
if (options.documentIds) {
|
||||
where[Op.and].push({
|
||||
id: options.documentIds,
|
||||
});
|
||||
}
|
||||
|
||||
const statusQuery = [];
|
||||
if (options.statusFilter?.includes(StatusFilter.Published)) {
|
||||
statusQuery.push({
|
||||
|
||||
@@ -785,6 +785,7 @@ router.post(
|
||||
const {
|
||||
query,
|
||||
collectionId,
|
||||
documentId,
|
||||
userId,
|
||||
dateFilter,
|
||||
statusFilter = [],
|
||||
@@ -853,6 +854,18 @@ router.post(
|
||||
authorize(user, "readDocument", collection);
|
||||
}
|
||||
|
||||
let documentIds = undefined;
|
||||
if (documentId) {
|
||||
const document = await Document.findByPk(documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
documentIds = [
|
||||
documentId,
|
||||
...(await document.findAllChildDocumentIds()),
|
||||
];
|
||||
}
|
||||
|
||||
let collaboratorIds = undefined;
|
||||
|
||||
if (userId) {
|
||||
@@ -862,6 +875,7 @@ router.post(
|
||||
response = await SearchHelper.searchForUser(user, query, {
|
||||
collaboratorIds,
|
||||
collectionId,
|
||||
documentIds,
|
||||
dateFilter,
|
||||
statusFilter,
|
||||
offset,
|
||||
|
||||
@@ -153,6 +153,9 @@ export const DocumentsSearchSchema = BaseSchema.extend({
|
||||
/** Filter results based on user */
|
||||
userId: z.string().uuid().optional(),
|
||||
|
||||
/** Filter results based on content within a document and it's children */
|
||||
documentId: z.string().uuid().optional(),
|
||||
|
||||
/**
|
||||
* Whether to include archived documents in results
|
||||
*
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"Edit collection": "Edit collection",
|
||||
"Permissions": "Permissions",
|
||||
"Collection permissions": "Collection permissions",
|
||||
"Search in collection": "Search in collection",
|
||||
"Star": "Star",
|
||||
"Unstar": "Unstar",
|
||||
"Delete": "Delete",
|
||||
@@ -52,6 +53,7 @@
|
||||
"Pin to home": "Pin to home",
|
||||
"Pinned to home": "Pinned to home",
|
||||
"Pin": "Pin",
|
||||
"Search in document": "Search in document",
|
||||
"Print": "Print",
|
||||
"Print document": "Print document",
|
||||
"Import document": "Import document",
|
||||
@@ -483,7 +485,6 @@
|
||||
"API token created": "API token created",
|
||||
"Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".": "Name your token something that will help you to remember it's use in the future, for example \"local development\", \"production\", or \"continuous integration\".",
|
||||
"The document archive is empty at the moment.": "The document archive is empty at the moment.",
|
||||
"Search in collection": "Search in collection",
|
||||
"This collection is only visible to those given access": "This collection is only visible to those given access",
|
||||
"Private": "Private",
|
||||
"Recently updated": "Recently updated",
|
||||
@@ -763,6 +764,7 @@
|
||||
"Past week": "Past week",
|
||||
"Past month": "Past month",
|
||||
"Past year": "Past year",
|
||||
"Remove document filter": "Remove document filter",
|
||||
"Any status": "Any status",
|
||||
"Search Results": "Search Results",
|
||||
"Remove search": "Remove search",
|
||||
|
||||
Reference in New Issue
Block a user