feat: Multi-select documents (#13041)

* first pass

* refactor

* refactor

* wip

* Fix BatchableApiMethods docs; remove ApiClient batch test

* Refactor actions

* refactor batch send to share error code handling

* Remove multi-select from read-only
Refactor performBatch to common

* refactor
This commit is contained in:
Tom Moor
2026-08-01 20:45:20 -04:00
committed by GitHub
parent 8bcf4a43a7
commit 890f0451a8
16 changed files with 1332 additions and 288 deletions
+74
View File
@@ -1,9 +1,83 @@
import type { TFunction } from "i18next";
import { InputIcon } from "outline-icons";
import { toast } from "sonner";
import stores from "~/stores";
import type Model from "~/models/base/Model";
import type { Action, ActionContext } from "~/types";
import { client } from "~/utils/ApiClient";
import { createAction } from "..";
/** A model class, as accepted by the action context's `getActiveModels`. */
type ModelClass<T extends Model> = new (...args: never[]) => T;
/**
* Runs a batchable per-item operation across the given items, coalescing the
* requests into a single batch request.
*
* @param items The items to operate on.
* @param operation The operation to perform on each item.
* @returns the number of operations that succeeded.
*/
export async function performBatch<T>(
items: T[],
operation: (item: T) => Promise<unknown> | undefined
): Promise<number> {
const results = await Promise.allSettled(
client.batch(() => items.map((item) => Promise.resolve(operation(item))))
);
return results.filter((result) => result.status === "fulfilled").length;
}
/**
* Whether there is at least one active model of the given class and every one
* of them satisfies the predicate — the common shape of a bulk action's
* `visible`.
*
* @param context The action context.
* @param modelClass The class of models to consider.
* @param predicate Called for each active model.
* @returns true if all active models match.
*/
export function everyActiveModel<T extends Model>(
context: ActionContext,
modelClass: ModelClass<T>,
predicate: (model: T) => boolean
): boolean {
const models = context.getActiveModels(modelClass);
return models.length > 0 && models.every(predicate);
}
/**
* Runs a batchable operation across all active models of the given class,
* coalescing the requests into a single batch and optionally showing a toast.
* The common shape of a bulk action's `perform`.
*
* @param context The action context.
* @param modelClass The class of models to operate on.
* @param operation The operation to perform on each model.
* @param message Given the models and how many succeeded, returns the toast to
* show, or undefined to show none (e.g. to stay silent for a single model).
* @returns the number of operations that succeeded.
*/
export async function performBatchOnActiveModels<T extends Model>(
context: ActionContext,
modelClass: ModelClass<T>,
operation: (model: T) => Promise<unknown> | undefined,
message?: (models: T[], succeeded: number, t: TFunction) => string | undefined
): Promise<number> {
const models = context.getActiveModels(modelClass);
if (!models.length) {
return 0;
}
const succeeded = await performBatch(models, operation);
const text = message?.(models, succeeded, context.t);
if (text) {
toast.success(text);
}
return succeeded;
}
/**
* Creates an action that opens a dialog, taking care of wiring the dialog's
* submit handler to close it again.
+213 -163
View File
@@ -43,6 +43,7 @@ import { ExportContentType } from "@shared/types";
import { isMobile } from "@shared/utils/browser";
import { Week } from "@shared/utils/time";
import type UserMembership from "~/models/UserMembership";
import Document from "~/models/Document";
import { client } from "~/utils/ApiClient";
import DocumentDelete from "~/scenes/DocumentDelete";
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
@@ -63,7 +64,12 @@ import {
createActionWithChildren,
createInternalLinkAction,
} from "~/actions";
import { dialogActionFactory } from "~/actions/definitions/common";
import {
dialogActionFactory,
everyActiveModel,
performBatch,
performBatchOnActiveModels,
} from "~/actions/definitions/common";
import {
ActiveDocumentSection,
DocumentSection,
@@ -451,22 +457,24 @@ export const starDocument = createAction({
section: ActiveDocumentSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!document?.isStarred && stores.policies.abilities(activeDocumentId).star
visible: (context) =>
everyActiveModel(
context,
Document,
(document) =>
!document.isStarred &&
context.stores.policies.abilities(document.id).star
),
perform: async (context) => {
await performBatchOnActiveModels(
context,
Document,
(document) => document.star(),
(documents, succeeded, t) =>
documents.length > 1
? t("{{ count }} documents starred", { count: succeeded })
: undefined
);
},
perform: async ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
await document?.star();
setPersistedState(getHeaderExpandedKey("starred"), true);
},
});
@@ -477,24 +485,24 @@ export const unstarDocument = createAction({
section: ActiveDocumentSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isStarred &&
stores.policies.abilities(activeDocumentId).unstar
);
},
perform: async ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
await document?.unstar();
},
visible: (context) =>
everyActiveModel(
context,
Document,
(document) =>
document.isStarred &&
context.stores.policies.abilities(document.id).unstar
),
perform: (context) =>
performBatchOnActiveModels(
context,
Document,
(document) => document.unstar(),
(documents, succeeded, t) =>
documents.length > 1
? t("{{ count }} documents unstarred", { count: succeeded })
: undefined
),
});
export const publishDocument = createAction({
@@ -544,30 +552,24 @@ export const unpublishDocument = createAction({
analyticsName: "Unpublish document",
section: ActiveDocumentSection,
icon: <UnpublishIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return stores.policies.abilities(activeDocumentId).unpublish;
},
perform: async ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.unpublish();
toast.success(
t("Unpublished {{ documentName }}", {
documentName: document.noun,
})
);
},
visible: (context) =>
everyActiveModel(
context,
Document,
(document) => !!context.stores.policies.abilities(document.id).unpublish
),
perform: (context) =>
performBatchOnActiveModels(
context,
Document,
(document) => document.unpublish(),
(documents, succeeded, t) =>
documents.length === 1
? t("Unpublished {{ documentName }}", {
documentName: documents[0].noun,
})
: t("{{ count }} documents unpublished", { count: succeeded })
),
});
export const subscribeDocument = createAction({
@@ -926,44 +928,41 @@ export const duplicateDocument = createAction({
* of the collection for all collection members to see.
*/
export const pinDocumentToCollection = createAction({
name: ({ activeDocumentId = "", t, stores }) => {
const selectedDocument = stores.documents.get(activeDocumentId);
const collectionName = selectedDocument
? stores.documents.getCollectionForDocument(selectedDocument)?.name
: t("collection");
return t("Pin to {{collectionName}}", {
collectionName,
});
name: ({ getActiveModels, t, stores }) => {
const documents = getActiveModels(Document);
if (documents.length === 1) {
const collectionName = stores.documents.getCollectionForDocument(
documents[0]
)?.name;
return t("Pin to {{collectionName}}", {
collectionName: collectionName ?? t("collection"),
});
}
return t("Pin");
},
analyticsName: "Pin document to collection",
section: ActiveDocumentSection,
icon: <PinIcon />,
iconInContextMenu: false,
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
if (!activeDocumentId || !activeCollectionId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!stores.policies.abilities(activeDocumentId).pin && !document?.pinned
);
},
perform: async ({ activeDocumentId, activeCollectionId, t, stores }) => {
if (!activeDocumentId || !activeCollectionId) {
return;
}
const document = stores.documents.get(activeDocumentId);
await document?.pin(document.collectionId);
const collection = stores.collections.get(activeCollectionId);
if (!collection || !location.pathname.startsWith(collection?.url)) {
toast.success(t("Pinned to collection"));
}
},
visible: (context) =>
everyActiveModel(
context,
Document,
(document) =>
!!document.collectionId &&
!document.pinned &&
!!context.stores.policies.abilities(document.id).pin
),
perform: (context) =>
performBatchOnActiveModels(
context,
Document,
(document) => document.pin(document.collectionId),
(documents, succeeded, t) =>
documents.length === 1
? t("Pinned to collection")
: t("{{ count }} documents pinned", { count: succeeded })
),
});
/**
@@ -1010,6 +1009,31 @@ export const pinDocument = createActionWithChildren({
children: [pinDocumentToCollection, pinDocumentToHome],
});
export const unpinDocument = createAction({
name: ({ t }) => t("Unpin"),
analyticsName: "Unpin document",
section: ActiveDocumentSection,
icon: <PinIcon />,
visible: (context) =>
everyActiveModel(
context,
Document,
(document) =>
document.pinned &&
!!context.stores.policies.abilities(document.id).unpin
),
perform: (context) =>
performBatchOnActiveModels(
context,
Document,
(document) => document.unpin(document.collectionId ?? undefined),
(documents, succeeded, t) =>
documents.length === 1
? t("Unpinned")
: t("{{ count }} documents unpinned", { count: succeeded })
),
});
export const searchInDocument = createInternalLinkAction({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
@@ -1300,43 +1324,54 @@ export const archiveDocument = createAction({
analyticsName: "Archive document",
section: ActiveDocumentSection,
icon: <ArchiveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
visible: (context) =>
everyActiveModel(
context,
Document,
(document) => !!context.stores.policies.abilities(document.id).archive
),
perform: async ({ getActiveModels, stores, t }) => {
const documents = getActiveModels(Document);
if (!documents.length) {
return;
}
return !!stores.policies.abilities(activeDocumentId).archive;
},
perform: async ({ activeDocumentId, stores, t }) => {
const { dialogs, documents } = stores;
if (activeDocumentId) {
const document = documents.get(activeDocumentId);
if (!document) {
return;
}
dialogs.openModal({
title: (
stores.dialogs.openModal({
title:
documents.length === 1 ? (
<DialogTitle
title={t("Are you sure you want to archive this document?")}
model={document}
model={documents[0]}
/>
) : (
t("Are you sure you want to archive {{ count }} documents?", {
count: documents.length,
})
),
content: (
<ConfirmationDialog
onSubmit={async () => {
await document.archive();
toast.success(t("Document archived"));
}}
savingText={`${t("Archiving")}`}
>
{t(
"Archiving this document will remove it from the collection and search results."
)}
</ConfirmationDialog>
),
});
}
content: (
<ConfirmationDialog
onSubmit={async () => {
const succeeded = await performBatch(documents, (document) =>
document.archive()
);
toast.success(
documents.length === 1
? t("Document archived")
: t("{{ count }} documents archived", { count: succeeded })
);
}}
savingText={`${t("Archiving")}`}
>
{documents.length === 1
? t(
"Archiving this document will remove it from the collection and search results."
)
: t(
"Archiving these documents will remove them from their collections and search results."
)}
</ConfirmationDialog>
),
});
},
});
@@ -1345,36 +1380,26 @@ export const restoreDocument = createAction({
analyticsName: "Restore document",
section: ActiveDocumentSection,
icon: <RestoreIcon />,
visible: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return false;
}
const collection = document.collectionId
? stores.collections.get(document.collectionId)
: undefined;
const can = stores.policies.abilities(document.id);
return !!collection?.isActive && !!(can.restore || can.unarchive);
},
perform: async ({ t, stores, activeDocumentId }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return;
}
await document.restore();
toast.success(
t("{{ documentName }} restored", {
documentName: capitalize(document.noun),
})
);
},
visible: (context) =>
everyActiveModel(context, Document, (document) => {
const collection = document.collectionId
? context.stores.collections.get(document.collectionId)
: undefined;
const can = context.stores.policies.abilities(document.id);
return !!collection?.isActive && !!(can.restore || can.unarchive);
}),
perform: (context) =>
performBatchOnActiveModels(
context,
Document,
(document) => document.restore(),
(documents, succeeded, t) =>
documents.length === 1
? t("{{ documentName }} restored", {
documentName: capitalize(documents[0].noun),
})
: t("{{ count }} documents restored", { count: succeeded })
),
});
export const restoreDocumentToCollection = createActionWithChildren({
@@ -1435,19 +1460,22 @@ export const deleteDocument = createAction({
section: ActiveDocumentSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
visible: (context) =>
everyActiveModel(
context,
Document,
(document) => !!context.stores.policies.abilities(document.id).delete
),
perform: ({ getActiveModels, stores, t }) => {
const documents = getActiveModels(Document);
if (!documents.length) {
return;
}
return !!stores.policies.abilities(activeDocumentId).delete;
},
perform: ({ activeDocumentId, stores, t }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
// A single document uses the richer delete dialog (permanent delete, child
// handling); multiple documents use a simple confirmation to move to trash.
if (documents.length === 1) {
const document = documents[0];
stores.dialogs.openModal({
title: (
<DialogTitle
@@ -1464,7 +1492,29 @@ export const deleteDocument = createAction({
/>
),
});
return;
}
stores.dialogs.openModal({
title: t("Delete {{ count }} documents", { count: documents.length }),
content: (
<ConfirmationDialog
danger
submitText={t("Delete")}
savingText={`${t("Deleting")}`}
onSubmit={async () => {
const succeeded = await performBatch(documents, (document) =>
document.delete()
);
toast.success(
t("{{ count }} documents moved to trash", { count: succeeded })
);
}}
>
{t("Deleting these documents will move them to the trash.")}
</ConfirmationDialog>
),
});
},
});
+114 -13
View File
@@ -7,7 +7,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import { Link } from "react-router-dom";
import { DocumentIcon } from "outline-icons";
import { CheckmarkIcon, DocumentIcon } from "outline-icons";
import styled, { css, useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
@@ -15,6 +15,7 @@ import Icon from "@shared/components/Icon";
import { s, hover } from "@shared/styles";
import type Document from "~/models/Document";
import Badge from "~/components/Badge";
import { useModelSelection } from "~/components/ModelSelectionContext";
import DocumentMeta from "~/components/DocumentMeta";
import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight";
@@ -24,6 +25,7 @@ import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import usePolicy from "~/hooks/usePolicy";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { documentPath } from "~/utils/routeHelpers";
@@ -62,6 +64,8 @@ function DocumentListItem(
const locationSidebarContext = useLocationSidebarContext();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const isMobile = useMobile();
const selection = useModelSelection();
const iconRef = React.useRef<HTMLDivElement>(null);
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
@@ -87,6 +91,37 @@ function DocumentListItem(
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived;
// Multi-select is only offered for documents the user can update.
const can = usePolicy(document.id);
const selectable = !!selection && !!can.update;
const isSelected = selection?.isSelected(document.id) ?? false;
const isSelecting =
selectable && ((selection?.isActive ?? false) || isSelected);
const inSelectArea = (event: React.MouseEvent) =>
selectable && !!iconRef.current?.contains(event.target as Node);
// Handled on the link so preventDefault reliably suppresses navigation.
const handleLinkClick = (event: React.MouseEvent) => {
if (selection && inSelectArea(event)) {
event.preventDefault();
if (event.shiftKey) {
selection.selectRange(document.id);
} else {
selection.toggle(document.id);
}
return;
}
rovingTabIndex.onClick?.(event);
};
// Suppress the browser's text selection when shift-clicking to select a range.
const handleLinkMouseDown = (event: React.MouseEvent) => {
if (event.shiftKey && inSelectArea(event)) {
event.preventDefault();
}
};
const isShared = !!(
userMemberships.getByDocumentId(document.id) ||
groupMemberships.getByDocumentId(document.id)
@@ -138,6 +173,7 @@ function DocumentListItem(
$isStarred={document.isStarred}
$isDragging={isDragging}
$menuOpen={menuOpen}
$selectable={selectable}
to={{
pathname: documentPath(document),
search: highlight
@@ -150,21 +186,37 @@ function DocumentListItem(
}}
{...rest}
{...rovingTabIndex}
onClick={handleLinkClick}
onMouseDown={handleLinkMouseDown}
>
<Flex gap={4} auto>
<IconWrapper>
{document.icon ? (
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
) : (
<DocumentIcon
outline={document.isDraft}
color={theme.textSecondary}
/>
<IconWrapper ref={iconRef}>
{selectable && (
<SelectButton
role="checkbox"
aria-checked={isSelected}
aria-label={t("Select")}
$checked={isSelected}
$visible={isSelecting}
tabIndex={-1}
>
{isSelected && <CheckmarkIcon size={16} />}
</SelectButton>
)}
<DocumentIconWrapper $dimmed={isSelecting}>
{document.icon ? (
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
) : (
<DocumentIcon
outline={document.isDraft}
color={theme.textSecondary}
/>
)}
</DocumentIconWrapper>
</IconWrapper>
<Content>
<Heading dir={document.dir}>
@@ -214,6 +266,7 @@ function DocumentListItem(
}
const IconWrapper = styled.div`
position: relative;
flex-shrink: 0;
display: flex;
align-items: flex-start;
@@ -221,6 +274,41 @@ const IconWrapper = styled.div`
width: 24px;
`;
const DocumentIconWrapper = styled.span<{ $dimmed: boolean }>`
display: flex;
transition: opacity 100ms ease;
opacity: ${(props) => (props.$dimmed ? 0 : 1)};
`;
const SelectButton = styled(NudeButton)<{
$checked: boolean;
$visible: boolean;
}>`
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
border: 2px solid ${s("inputBorder")};
color: ${(props) => props.theme.accentText};
opacity: ${(props) => (props.$visible ? 1 : 0)};
transition:
opacity 100ms ease,
background 100ms ease,
border-color 100ms ease;
${(props) =>
props.$checked &&
css`
background: ${props.theme.accent};
border-color: ${props.theme.accent};
`}
`;
const Content = styled.div`
flex-grow: 1;
flex-shrink: 1;
@@ -249,6 +337,7 @@ const DocumentLink = styled(Link)<{
$isStarred?: boolean;
$isDragging?: boolean;
$menuOpen?: boolean;
$selectable?: boolean;
}>`
display: flex;
align-items: center;
@@ -287,6 +376,18 @@ const DocumentLink = styled(Link)<{
opacity: 1;
}
${(props) =>
props.$selectable &&
css`
${SelectButton} {
opacity: 1;
}
${DocumentIconWrapper} {
opacity: 0;
}
`}
${AnimatedStar} {
opacity: 0.5;
+105
View File
@@ -0,0 +1,105 @@
import { observer } from "mobx-react";
import * as React from "react";
import { performAction, resolve } from "~/actions";
import {
archiveDocument,
deleteDocument,
pinDocumentToCollection,
restoreDocument,
starDocument,
unpinDocument,
unpublishDocument,
unstarDocument,
} from "~/actions/definitions/documents";
import type { ModelSelection } from "~/components/ModelSelection";
import { useModelSelection } from "~/components/ModelSelectionContext";
import type { ModelSelectionAction } from "~/components/ModelSelectionToolbar";
import ModelSelectionToolbar from "~/components/ModelSelectionToolbar";
import { ActionContext, ActionContextProvider } from "~/hooks/useActionContext";
import useStores from "~/hooks/useStores";
import type Document from "~/models/Document";
import type { Action } from "~/types";
/**
* The document actions offered in the bulk selection toolbar. These are the
* same action definitions used by document menus — they operate on the active
* models, which the toolbar feeds from the current selection.
*/
const toolbarActions: Action[] = [
starDocument,
unstarDocument,
pinDocumentToCollection,
unpinDocument,
archiveDocument,
unpublishDocument,
restoreDocument,
deleteDocument,
];
/**
* Renders the selection toolbar with the standard document actions, feeding the
* selected documents in as the active models so the shared action definitions
* operate on the whole selection.
*
* @returns the toolbar element, or null when no list selection is in scope.
*/
function DocumentSelectionToolbar() {
const selection = useModelSelection();
const { documents } = useStores();
if (!selection) {
return null;
}
const selectedDocuments = selection.selectedIds
.map((id) => documents.get(id))
.filter((document): document is Document => !!document);
return (
<ActionContextProvider
value={{ activeModels: selectedDocuments, isButton: true }}
>
<Toolbar selection={selection} />
</ActionContextProvider>
);
}
const Toolbar = observer(function Toolbar_({
selection,
}: {
selection: ModelSelection;
}) {
const { dialogs } = useStores();
const context = React.useContext(ActionContext);
if (!context) {
return null;
}
const actions: ModelSelectionAction[] = toolbarActions.map((action) => ({
key: action.id,
label: resolve<string>(action.name, context),
icon: resolve<React.ReactNode>(action.icon, context),
dangerous: action.dangerous,
visible: action.visible ? resolve<boolean>(action.visible, context) : true,
perform: async () => {
const openModals = dialogs.modalStack.size;
await performAction(action, context);
// `openModal` adds to the stack on a macrotask, so wait one before
// checking (its timer, scheduled first, runs before ours). If the action
// opened a dialog, leave the selection alone — cancelling keeps it and it
// clears once the documents leave the list on confirm; otherwise the
// action completed inline, so clear it.
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
if (dialogs.modalStack.size <= openModals) {
selection.clear();
}
},
}));
return <ModelSelectionToolbar selection={selection} actions={actions} />;
});
export default observer(DocumentSelectionToolbar);
+108
View File
@@ -0,0 +1,108 @@
import { ModelSelection } from "./ModelSelection";
describe("ModelSelection", () => {
it("starts empty and inactive", () => {
const selection = new ModelSelection();
expect(selection.size).toBe(0);
expect(selection.isActive).toBe(false);
expect(selection.selectedIds).toEqual([]);
expect(selection.isSelected("a")).toBe(false);
});
it("toggles a model on and off", () => {
const selection = new ModelSelection();
selection.toggle("a");
expect(selection.isSelected("a")).toBe(true);
expect(selection.size).toBe(1);
expect(selection.isActive).toBe(true);
selection.toggle("a");
expect(selection.isSelected("a")).toBe(false);
expect(selection.size).toBe(0);
expect(selection.isActive).toBe(false);
});
it("tracks multiple selected models", () => {
const selection = new ModelSelection();
selection.toggle("a");
selection.toggle("b");
expect(selection.size).toBe(2);
expect(selection.selectedIds).toEqual(["a", "b"]);
});
it("selects every model in the list order", () => {
const selection = new ModelSelection();
selection.setOrder(["a", "b", "c"]);
selection.toggle("b");
selection.selectAll();
expect(selection.size).toBe(3);
expect(selection.selectedIds.sort()).toEqual(["a", "b", "c"]);
});
it("clears all selected models", () => {
const selection = new ModelSelection();
selection.toggle("a");
selection.toggle("b");
selection.clear();
expect(selection.size).toBe(0);
expect(selection.isActive).toBe(false);
expect(selection.selectedIds).toEqual([]);
});
it("prunes selected models no longer present in the list order", () => {
const selection = new ModelSelection();
selection.setOrder(["a", "b", "c"]);
selection.toggle("a");
selection.toggle("b");
// "b" leaves the list (e.g. archived); the selection drops it.
selection.setOrder(["a", "c"]);
expect(selection.selectedIds).toEqual(["a"]);
expect(selection.isSelected("b")).toBe(false);
});
describe("selectRange", () => {
it("falls back to toggle when there is no anchor", () => {
const selection = new ModelSelection();
selection.setOrder(["a", "b", "c"]);
selection.selectRange("b");
expect(selection.selectedIds).toEqual(["b"]);
});
it("selects the inclusive range from the anchor downwards", () => {
const selection = new ModelSelection();
selection.setOrder(["a", "b", "c", "d"]);
selection.toggle("a");
selection.selectRange("c");
expect(selection.selectedIds).toEqual(["a", "b", "c"]);
});
it("selects the inclusive range from the anchor upwards", () => {
const selection = new ModelSelection();
selection.setOrder(["a", "b", "c", "d"]);
selection.toggle("c");
selection.selectRange("a");
expect(selection.selectedIds.sort()).toEqual(["a", "b", "c"]);
});
it("keeps the original anchor for consecutive ranges", () => {
const selection = new ModelSelection();
selection.setOrder(["a", "b", "c", "d"]);
selection.toggle("b");
selection.selectRange("d");
selection.selectRange("a");
expect(selection.selectedIds.sort()).toEqual(["a", "b", "c", "d"]);
});
});
});
+119
View File
@@ -0,0 +1,119 @@
import { action, computed, observable } from "mobx";
/**
* Holds the ephemeral multi-selection state for a single list of models.
* Backed by an observable set so that list items only re-render when their own
* selected state changes. Selection is keyed by model identifier and is
* model-agnostic — resolving identifiers back to models is the caller's
* concern.
*/
export class ModelSelection {
private ids = observable.set<string>();
/** Ordered identifiers of the list, used to resolve shift-click ranges. */
private order: string[] = [];
/** The identifier last toggled, used as the anchor for range selection. */
private anchorId: string | undefined;
/** The number of currently selected models. */
@computed
get size(): number {
return this.ids.size;
}
/** Whether one or more models are currently selected. */
@computed
get isActive(): boolean {
return this.ids.size > 0;
}
/** The identifiers of the currently selected models. */
@computed
get selectedIds(): string[] {
return Array.from(this.ids);
}
/**
* Update the ordered identifiers of the list so that shift-click range
* selection follows the rendered order, dropping any selected identifiers
* that are no longer present (e.g. models removed from the list by an action).
*
* @param order The identifiers in the order they are displayed.
*/
@action
setOrder = (order: string[]): void => {
this.order = order;
const present = new Set(order);
for (const id of Array.from(this.ids)) {
if (!present.has(id)) {
this.ids.delete(id);
}
}
};
/**
* Whether the model with the given identifier is selected.
*
* @param id The model identifier.
* @returns true if the model is selected.
*/
isSelected = (id: string): boolean => this.ids.has(id);
/**
* Toggle the selected state of a model, making it the anchor for any
* subsequent range selection.
*
* @param id The model identifier.
*/
@action
toggle = (id: string): void => {
if (this.ids.has(id)) {
this.ids.delete(id);
} else {
this.ids.add(id);
}
this.anchorId = id;
};
/**
* Select every model between the current anchor and the given identifier
* inclusive, in the list's display order. Falls back to a plain toggle when
* there is no anchor or either identifier is not present in the order.
*
* @param id The model identifier at the far end of the range.
*/
@action
selectRange = (id: string): void => {
if (this.anchorId === undefined) {
this.toggle(id);
return;
}
const from = this.order.indexOf(this.anchorId);
const to = this.order.indexOf(id);
if (from === -1 || to === -1) {
this.toggle(id);
return;
}
const [lo, hi] = from <= to ? [from, to] : [to, from];
for (let i = lo; i <= hi; i++) {
this.ids.add(this.order[i]);
}
// Keep the anchor so consecutive shift-clicks extend from the same origin.
};
/** Select every model currently in the list. */
@action
selectAll = (): void => {
this.order.forEach((id) => this.ids.add(id));
};
/** Deselect all models. */
@action
clear = (): void => {
this.ids.clear();
this.anchorId = undefined;
};
}
+69
View File
@@ -0,0 +1,69 @@
import * as React from "react";
import { ModelSelection } from "~/components/ModelSelection";
import useEventListener from "~/hooks/useEventListener";
import isTextInput from "~/utils/isTextInput";
const ModelSelectionContext = React.createContext<ModelSelection | null>(null);
/**
* Retrieve the model selection for the nearest enclosing list, or null if the
* list does not support multi-selection.
*
* @returns the model selection, or null.
*/
export function useModelSelection(): ModelSelection | null {
return React.useContext(ModelSelectionContext);
}
type Props = {
/** Identifiers of the list's models, in display order, for range selection. */
items: string[];
/** The toolbar rendered while a selection is active. */
toolbar?: React.ReactNode;
/** The list that consumes the selection. */
children: React.ReactNode;
};
/**
* Provides multi-selection state to a list of models, keeps the selection's
* ordering in sync for shift-click ranges, selects all on meta/ctrl+a, clears
* the selection on Escape, and renders the supplied toolbar of bulk actions.
*
* @param props The component props.
* @returns the provider element.
*/
export function ModelSelectionProvider({ items, toolbar, children }: Props) {
const [selection] = React.useState(() => new ModelSelection());
React.useEffect(() => {
selection.setOrder(items);
}, [selection, items]);
useEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === "Escape" && selection.isActive) {
selection.clear();
return;
}
// Select every item on meta/ctrl+a, unless the user is editing text where
// the browser's own select-all should win.
const target = event.target;
if (
event.key === "a" &&
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
items.length > 0 &&
!(target instanceof Element && isTextInput(target))
) {
event.preventDefault();
selection.selectAll();
}
});
return (
<ModelSelectionContext.Provider value={selection}>
{children}
{toolbar}
</ModelSelectionContext.Provider>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import { depths, s } from "@shared/styles";
import type { ModelSelection } from "~/components/ModelSelection";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import { Portal } from "~/components/Portal";
import Text from "~/components/Text";
import Tooltip from "~/components/Tooltip";
/** A bulk action that can be performed against the current selection. */
export type ModelSelectionAction = {
/** A stable key identifying the action. */
key: string;
/** The accessible label for the action. */
label: string;
/** The icon rendered for the action. */
icon: React.ReactNode;
/** Whether the action is destructive and should be styled as such. */
dangerous?: boolean;
/** Whether the action applies to the current selection. */
visible: boolean;
/** Perform the action against the current selection. */
perform: () => Promise<void>;
};
type Props = {
/** The selection this toolbar acts upon. */
selection: ModelSelection;
/** The actions available for the current selection. */
actions: ModelSelectionAction[];
};
function ModelSelectionToolbar({ selection, actions }: Props) {
const { t } = useTranslation();
const [isProcessing, setProcessing] = React.useState(false);
const [isWorking, setWorking] = React.useState(false);
// Snapshot the count and available actions while the selection is active and
// hold them through the exit animation, so the toolbar does not shrink or
// flash an empty state as it animates away after being cleared.
const snapshot = React.useRef({
size: selection.size,
actions: [] as ModelSelectionAction[],
});
if (selection.isActive) {
snapshot.current = {
size: selection.size,
actions: actions.filter((action) => action.visible),
};
}
const { size: displaySize, actions: visibleActions } = snapshot.current;
const handlePerform = async (action: ModelSelectionAction) => {
setProcessing(true);
// Only surface a "Working…" state if the action is slow, to avoid a flash
// on quick operations.
const workingTimer = setTimeout(() => setWorking(true), 1000);
try {
await action.perform();
} finally {
clearTimeout(workingTimer);
setWorking(false);
setProcessing(false);
}
};
return (
<Portal>
<Wrapper $active={selection.isActive} aria-hidden={!selection.isActive}>
<Background align="center" gap={4}>
<Count type="secondary" size="small">
{isWorking
? t("Working…")
: t("{{ count }} selected", { count: displaySize })}
</Count>
{visibleActions.length > 0 && (
<>
<Divider />
{visibleActions.map((action) => (
<Tooltip key={action.key} content={action.label}>
<Action
aria-label={action.label}
disabled={isProcessing}
$dangerous={action.dangerous}
onClick={() => handlePerform(action)}
>
{action.icon}
</Action>
</Tooltip>
))}
</>
)}
<Divider />
<Tooltip content={t("Clear selection")}>
<Action
aria-label={t("Clear selection")}
disabled={isProcessing}
onClick={selection.clear}
>
<CloseIcon />
</Action>
</Tooltip>
</Background>
</Wrapper>
</Portal>
);
}
const Wrapper = styled.div<{ $active: boolean }>`
position: fixed;
bottom: 24px;
left: 50%;
z-index: ${depths.editorToolbar};
transform: translate(-50%, 16px) scale(0.96);
opacity: 0;
pointer-events: none;
transition:
opacity 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275),
transform 150ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
${({ $active }) =>
$active &&
css`
transform: translate(-50%, 0) scale(1);
opacity: 1;
pointer-events: auto;
`}
@media print {
display: none;
}
`;
const Background = styled(Flex)`
background-color: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 8px;
height: 40px;
padding: 0 8px;
`;
const Count = styled(Text)`
margin: 0 4px;
white-space: nowrap;
font-weight: 500;
`;
const Divider = styled.div`
width: 1px;
height: 20px;
background: ${s("divider")};
flex-shrink: 0;
`;
const Action = styled(NudeButton)<{ $dangerous?: boolean }>`
width: 28px;
height: 28px;
color: ${s("textSecondary")};
&:hover:enabled,
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
color: ${(props) =>
props.$dangerous ? props.theme.danger : props.theme.text};
}
&:disabled {
opacity: 0.5;
}
`;
export default observer(ModelSelectionToolbar);
+38 -20
View File
@@ -2,8 +2,11 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import type Document from "~/models/Document";
import DocumentListItem from "~/components/DocumentListItem";
import DocumentSelectionToolbar from "~/components/DocumentSelectionToolbar";
import Error from "~/components/List/Error";
import { ModelSelectionProvider } from "~/components/ModelSelectionContext";
import PaginatedList from "~/components/PaginatedList";
import useStores from "~/hooks/useStores";
type Props = {
documents: Document[];
@@ -34,28 +37,43 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
...rest
}: Props) {
const { t } = useTranslation();
const { policies } = useStores();
// Only updatable documents are selectable, so that is what feeds range and
// select-all; per-item checkboxes are gated on the same ability.
const itemIds = React.useMemo(
() =>
documents
.filter((document) => policies.abilities(document.id).update)
.map((document) => document.id),
[documents, policies]
);
return (
<PaginatedList<Document>
aria-label={t("Documents")}
items={documents}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderError={(props) => <Error {...props} />}
renderItem={(item, _index) => (
<DocumentListItem
key={item.id}
document={item}
showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
showDraft={showDraft}
/>
)}
{...rest}
/>
<ModelSelectionProvider
items={itemIds}
toolbar={<DocumentSelectionToolbar />}
>
<PaginatedList<Document>
aria-label={t("Documents")}
items={documents}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderError={(props) => <Error {...props} />}
renderItem={(item, _index) => (
<DocumentListItem
key={item.id}
document={item}
showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
showDraft={showDraft}
/>
)}
{...rest}
/>
</ModelSelectionProvider>
);
});
+44 -28
View File
@@ -16,9 +16,11 @@ import type {
import { StatusFilter as TStatusFilter } from "@shared/types";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import DocumentListItem from "~/components/DocumentListItem";
import DocumentSelectionToolbar from "~/components/DocumentSelectionToolbar";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import LoadingIndicator from "~/components/LoadingIndicator";
import { ModelSelectionProvider } from "~/components/ModelSelectionContext";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
@@ -44,7 +46,7 @@ import useMobile from "~/hooks/useMobile";
function Search() {
const { t } = useTranslation();
const { documents, searches } = useStores();
const { documents, searches, policies } = useStores();
const isMobile = useMobile();
// routing
@@ -151,6 +153,15 @@ function Search() {
limit: Pagination.defaultLimit,
});
// Only updatable documents are selectable, matching the per-item checkboxes.
const itemIds = React.useMemo(
() =>
data
?.filter((result) => policies.abilities(result.document.id).update)
.map((result) => result.document.id) ?? [],
[data, policies]
);
const updateLocation = (query: string) => {
// If query came from route params, navigate to base search path
const pathname = routeMatch.params.query ? searchPath() : location.pathname;
@@ -352,33 +363,38 @@ function Search() {
</Centered>
</Fade>
) : null}
<ResultList column>
<StyledArrowKeyNavigation
ref={resultListRef}
onEscape={handleEscape}
aria-label={t("Search Results")}
items={data ?? []}
>
{() =>
data?.length && !error
? data.map((result) => (
<DocumentListItem
key={result.document.id}
document={result.document}
highlight={query}
context={result.context}
showCollection
/>
))
: null
}
</StyledArrowKeyNavigation>
<Waypoint
key={data?.length}
onEnter={end || loading ? undefined : next}
debug={env.ENVIRONMENT === "development"}
/>
</ResultList>
<ModelSelectionProvider
items={itemIds}
toolbar={<DocumentSelectionToolbar />}
>
<ResultList column>
<StyledArrowKeyNavigation
ref={resultListRef}
onEscape={handleEscape}
aria-label={t("Search Results")}
items={data ?? []}
>
{() =>
data?.length && !error
? data.map((result) => (
<DocumentListItem
key={result.document.id}
document={result.document}
highlight={query}
context={result.context}
showCollection
/>
))
: null
}
</StyledArrowKeyNavigation>
<Waypoint
key={data?.length}
onEnter={end || loading ? undefined : next}
debug={env.ENVIRONMENT === "development"}
/>
</ResultList>
</ModelSelectionProvider>
</>
) : documentId ? null : (
<RecentSearches ref={recentSearchesRef} onEscape={handleEscape} />
+165 -42
View File
@@ -1,5 +1,5 @@
import retry from "fetch-retry";
import { trim } from "es-toolkit/compat";
import { chunk, trim } from "es-toolkit/compat";
import queryString from "query-string";
import EDITOR_VERSION from "@shared/editor/version";
import type { JSONObject } from "@shared/types";
@@ -23,7 +23,7 @@ import {
UnprocessableEntityError,
UpdateRequiredError,
} from "./errors";
import { CSRF } from "@shared/constants";
import { BatchableApiMethods, BatchMaxRequests, CSRF } from "@shared/constants";
import { getCSRFToken } from "./csrf";
import AuthenticationHelper from "@shared/helpers/AuthenticationHelper";
@@ -55,6 +55,28 @@ interface FetchOptions {
baseUrl?: string;
}
/** A request captured during a batch, awaiting dispatch in a `/batch` call. */
interface BatchedRequest {
method: string;
body?: JSONObject;
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
}
/** A single sub-response within a `/batch` response. */
interface BatchSubResponse {
ok: boolean;
status: number;
data?: unknown;
policies?: unknown;
/** Structured error code, mirroring a top-level error response's `error`. */
error?: string;
message?: string;
}
/** Methods that may be collected into a single `/batch` request. */
const batchableMethods = new Set<string>(BatchableApiMethods);
class ApiClient {
baseUrl: string;
@@ -64,6 +86,9 @@ class ApiClient {
// oxlint-disable-next-line no-explicit-any
private inflightRequests = new Map<string, Promise<any>>();
/** Requests collected while a batch is open, or undefined when not batching. */
private batchQueue?: BatchedRequest[];
private onUnauthorized?: UnauthorizedHandler;
constructor(options: Options = {}) {
@@ -234,14 +259,8 @@ class ApiClient {
return response.json();
}
// Handle 401, notify session owner to log out
if (response.status === 401) {
if (!this.shareId) {
await this.onUnauthorized?.("unauthorized");
}
throw new AuthorizationError();
}
// The gateway or an upstream proxy failed before the app could respond; the
// raw body is captured for diagnosis.
if (response.status === 502) {
const text = await response.text();
const err = new BadGatewayError(text);
@@ -255,9 +274,8 @@ class ApiClient {
throw err;
}
// Handle failed responses
// Parse the structured error payload, if present.
const error: ApiErrorResponse = {};
try {
const parsed: ApiErrorResponse = await response.json();
error.message = parsed.message || "";
@@ -267,65 +285,92 @@ class ApiClient {
// we're trying to parse an error so JSON may not be valid
}
if (response.status === 400 && error.error === "editor_update_required") {
const err = await this.toError(response.status, error.error, error.message);
// Log failures that aren't mapped to a specific error type.
if (err.constructor === RequestError) {
Logger.error("Request failed", err, { ...error, url: urlToFetch });
}
// Still need to throw to trigger retry
throw err;
};
/**
* Maps a failed response's status and error code to the corresponding error
* type, triggering the unauthorized handler for authentication failures.
* Shared by top-level requests and batched sub-requests so both surface
* identical errors and side effects.
*
* @param status The response status code.
* @param code The structured error code, if any.
* @param message The human-readable error message, if any.
* @returns the error to throw or reject with.
*/
private toError = async (
status: number,
code: string | undefined,
message: string | undefined
): Promise<Error> => {
if (status === 401) {
if (!this.shareId) {
await this.onUnauthorized?.("unauthorized");
}
return new AuthorizationError();
}
if (status === 400 && code === "editor_update_required") {
window.location.reload();
throw new UpdateRequiredError(error.message);
return new UpdateRequiredError(message);
}
if (response.status === 400) {
throw new BadRequestError(error.message);
if (status === 400) {
return new BadRequestError(message);
}
if (response.status === 402) {
throw new PaymentRequiredError(error.message);
if (status === 402) {
return new PaymentRequiredError(message);
}
if (response.status === 403) {
if (error.error === "user_suspended") {
if (status === 403) {
if (code === "user_suspended") {
await this.onUnauthorized?.("user_suspended");
}
if (error.error === "csrf_error") {
throw new AuthorizationError(
if (code === "csrf_error") {
return new AuthorizationError(
"CSRF token invalid, please try reloading."
);
}
throw new AuthorizationError(error.message);
return new AuthorizationError(message);
}
if (response.status === 404) {
throw new NotFoundError(error.message);
if (status === 404) {
return new NotFoundError(message);
}
if (response.status === 503) {
throw new ServiceUnavailableError(error.message);
if (status === 503) {
return new ServiceUnavailableError(message);
}
if (response.status === 422) {
throw new UnprocessableEntityError(error.message);
if (status === 422) {
return new UnprocessableEntityError(message);
}
if (response.status === 429) {
throw new RateLimitExceededError(
if (status === 429) {
return new RateLimitExceededError(
`Too many requests, try again in a minute.`
);
}
// The client, or an intermediate proxy, closed the connection before the
// response was received there is nothing actionable to report.
if (response.status === 499) {
throw new ClientClosedRequestError(error.message);
if (status === 499) {
return new ClientClosedRequestError(message);
}
const err = new RequestError(`Error ${response.status}`);
Logger.error("Request failed", err, {
...error,
url: urlToFetch,
});
// Still need to throw to trigger retry
throw err;
return new RequestError(`Error ${status}`);
};
/**
@@ -357,7 +402,48 @@ class ApiClient {
path: string,
data?: JSONObject | FormData,
options?: FetchOptions
): Promise<T> => this.deduplicate<T>(path, "POST", data, options);
): Promise<T> => {
const method = path.replace(/^\//, "");
if (
this.batchQueue &&
!(data instanceof FormData) &&
batchableMethods.has(method)
) {
return new Promise<T>((resolve, reject) => {
this.batchQueue!.push({
method,
body: data,
resolve: resolve as (value: unknown) => void,
reject,
});
});
}
return this.deduplicate<T>(path, "POST", data, options);
};
/**
* Collects every batchable POST request issued during the synchronous
* execution of `fn` and dispatches them as a single `/batch` request once
* `fn` returns. Non-batchable requests, and requests made after `fn` returns,
* are sent normally; nested calls join the enclosing batch.
*
* @param fn A function that issues the requests to be batched.
* @returns whatever `fn` returns.
*/
batch = <T>(fn: () => T): T => {
if (this.batchQueue) {
return fn();
}
const queue: BatchedRequest[] = [];
this.batchQueue = queue;
try {
return fn();
} finally {
this.batchQueue = undefined;
void this.flushBatch(queue);
}
};
/**
* Performs a PUT request against the API. Identical in-flight requests are
@@ -410,6 +496,43 @@ class ApiClient {
this.inflightRequests.set(key, promise);
return promise;
};
/**
* Dispatches the requests collected during a batch, splitting them into
* serial `/batch` calls that respect the server's per-batch limit, and
* settles each caller's promise with its corresponding sub-response — shaped
* like a standard API envelope so callers need no special handling.
*
* @param queue The requests collected during a batch.
*/
private flushBatch = async (queue: BatchedRequest[]): Promise<void> => {
for (const group of chunk(queue, BatchMaxRequests)) {
try {
const res = await this.fetch<{ data: BatchSubResponse[] }>(
"/batch",
"POST",
{ requests: group.map(({ method, body }) => ({ method, body })) }
);
for (let index = 0; index < group.length; index++) {
const request = group[index];
const result = res?.data?.[index];
if (result?.ok) {
request.resolve({ data: result.data, policies: result.policies });
} else {
request.reject(
await this.toError(
result?.status ?? 500,
result?.error,
result?.message
)
);
}
}
} catch (err) {
group.forEach((request) => request.reject(err));
}
}
};
}
/** Shared API client instance configured against the default base URL. */
+39 -1
View File
@@ -147,7 +147,7 @@ describe("#batch", () => {
const res = await server.post("/api/batch", user, {
body: {
requests: [
{ method: "stars.create", body: { documentId: documentOne.id } },
{ method: "documents.nonexistent", body: { id: documentOne.id } },
],
},
});
@@ -158,6 +158,44 @@ describe("#batch", () => {
expect(body.data[0].error).toEqual("invalid_request");
});
it("should dispatch star requests across resources", async () => {
const res = await server.post("/api/batch", user, {
body: {
requests: [
{ method: "stars.create", body: { documentId: documentOne.id } },
{
method: "documents.update",
body: { id: documentOne.id, title: "Starred" },
},
],
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data[0].ok).toBe(true);
expect(body.data[0].data.documentId).toEqual(documentOne.id);
expect(body.data[1].ok).toBe(true);
expect(body.data[1].data.title).toEqual("Starred");
});
it("should dispatch pin requests in a batch", async () => {
const admin = await buildAdmin();
const doc = await buildDocument({
teamId: admin.teamId,
userId: admin.id,
});
const res = await server.post("/api/batch", admin, {
body: {
requests: [{ method: "pins.create", body: { documentId: doc.id } }],
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data[0].ok).toBe(true);
expect(body.data[0].data.documentId).toEqual(doc.id);
});
it("should reject a known endpoint that is not allowlisted", async () => {
// documents.list is a real route, but reads aren't permitted in a batch.
const res = await server.post("/api/batch", user, {
+5 -14
View File
@@ -11,10 +11,13 @@ import validate from "@server/middlewares/validate";
import type { APIContext, AppContext } from "@server/types";
import { AuthenticationType } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import { BatchableApiMethods } from "@shared/constants";
import { toError } from "@shared/utils/error";
import collections from "../collections";
import documents from "../documents";
import apiErrorHandler from "../middlewares/apiErrorHandler";
import pins from "../pins";
import stars from "../stars";
import * as T from "./schema";
const router = new Router();
@@ -37,22 +40,10 @@ const enforceRateLimit = defaultRateLimiter();
* curated to simple JSON mutations — no reads (pagination), redirects, file
* responses, or endpoints that set response headers.
*/
const allowedMethods = new Set<string>([
"documents.update",
"documents.move",
"documents.archive",
"documents.restore",
"documents.unpublish",
"documents.delete",
"collections.update",
"collections.move",
"collections.archive",
"collections.restore",
"collections.delete",
]);
const allowedMethods = new Set<string>(BatchableApiMethods);
/** Routers searched for an allowed method's middleware stack. */
const dispatchableRouters: Router[] = [documents, collections];
const dispatchableRouters: Router[] = [documents, collections, stars, pins];
/** The number of sub-requests dispatched in parallel, to limit pool pressure. */
const BatchConcurrency = 2;
+1 -3
View File
@@ -1,9 +1,7 @@
import { z } from "zod";
import { BatchMaxRequests } from "@shared/constants";
import { BaseSchema } from "../schema";
/** The maximum number of sub-requests permitted in a single batch. */
export const BatchMaxRequests = 25;
export const BatchSchema = BaseSchema.extend({
body: z.object({
requests: z
+30
View File
@@ -43,6 +43,36 @@ export const CSRF = {
fieldName: "_csrf",
};
/** The maximum number of sub-requests permitted in a single `/batch` request. */
export const BatchMaxRequests = 25;
/**
* RPC methods that may be coalesced into a single `/batch` request. Deliberately
* curated to simple JSON mutations — no reads, redirects, file responses, or
* endpoints that set response headers. Shared by the client (which collects
* these into a batch) and the server (which only dispatches allowlisted methods).
*
* When adding a method, also add its router to `dispatchableRouters` in
* server/routes/api/batch/batch.ts so the server can resolve its middleware.
*/
export const BatchableApiMethods = [
"documents.update",
"documents.move",
"documents.archive",
"documents.restore",
"documents.unpublish",
"documents.delete",
"collections.update",
"collections.move",
"collections.archive",
"collections.restore",
"collections.delete",
"stars.create",
"stars.delete",
"pins.create",
"pins.delete",
] as const;
export const TeamPreferenceDefaults: TeamPreferences = {
[TeamPreference.SeamlessEdit]: true,
[TeamPreference.ViewersCanExport]: true,
+32 -4
View File
@@ -64,11 +64,17 @@
"Nested document": "Nested document",
"Before": "Before",
"After": "After",
"{{ count }} documents starred": "{{ count }} documents starred",
"{{ count }} documents starred_plural": "{{ count }} documents starred",
"{{ count }} documents unstarred": "{{ count }} documents unstarred",
"{{ count }} documents unstarred_plural": "{{ count }} documents unstarred",
"Publish": "Publish",
"Published {{ documentName }}": "Published {{ documentName }}",
"Publish document": "Publish document",
"Unpublish": "Unpublish",
"Unpublished {{ documentName }}": "Unpublished {{ documentName }}",
"{{ count }} documents unpublished": "{{ count }} documents unpublished",
"{{ count }} documents unpublished_plural": "{{ count }} documents unpublished",
"Subscription inherited from collection": "Subscription inherited from collection",
"Share document": "Share document",
"Download": "Download",
@@ -82,12 +88,17 @@
"Text copied to clipboard": "Text copied to clipboard",
"Copy public link": "Copy public link",
"Duplicate document": "Duplicate document",
"collection": "collection",
"Pin to {{collectionName}}": "Pin to {{collectionName}}",
"Pin": "Pin",
"Pinned to collection": "Pinned to collection",
"{{ count }} documents pinned": "{{ count }} documents pinned",
"{{ count }} documents pinned_plural": "{{ count }} documents pinned",
"Pin to home": "Pin to home",
"Pinned to home": "Pinned to home",
"Pin": "Pin",
"Unpin": "Unpin",
"Unpinned": "Unpinned",
"{{ count }} documents unpinned": "{{ count }} documents unpinned",
"{{ count }} documents unpinned_plural": "{{ count }} documents unpinned",
"Search in document": "Search in document",
"Print": "Print",
"Print document": "Print document",
@@ -101,11 +112,24 @@
"Move": "Move",
"Move {{ documentType }}": "Move {{ documentType }}",
"Are you sure you want to archive this document?": "Are you sure you want to archive this document?",
"Are you sure you want to archive {{ count }} documents?": "Are you sure you want to archive {{ count }} documents?",
"Are you sure you want to archive {{ count }} documents?_plural": "Are you sure you want to archive {{ count }} documents?",
"Document archived": "Document archived",
"{{ count }} documents archived": "{{ count }} documents archived",
"{{ count }} documents archived_plural": "{{ count }} documents archived",
"Archiving this document will remove it from the collection and search results.": "Archiving this document will remove it from the collection and search results.",
"Archiving these documents will remove them from their collections and search results.": "Archiving these documents will remove them from their collections and search results.",
"{{ documentName }} restored": "{{ documentName }} restored",
"{{ count }} documents restored": "{{ count }} documents restored",
"{{ count }} documents restored_plural": "{{ count }} documents restored",
"Choose a collection": "Choose a collection",
"Delete {{ documentName }}": "Delete {{ documentName }}",
"Delete {{ count }} documents": "Delete {{ count }} documents",
"Delete {{ count }} documents_plural": "Delete {{ count }} documents",
"Deleting": "Deleting",
"{{ count }} documents moved to trash": "{{ count }} documents moved to trash",
"{{ count }} documents moved to trash_plural": "{{ count }} documents moved to trash",
"Deleting these documents will move them to the trash.": "Deleting these documents will move them to the trash.",
"Permanently delete": "Permanently delete",
"Permanently delete {{ documentName }}": "Permanently delete {{ documentName }}",
"Empty trash": "Empty trash",
@@ -186,7 +210,6 @@
"Login to workspace": "Login to workspace",
"template": "template",
"Template deleted": "Template deleted",
"Deleting": "Deleting",
"Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent.": "Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent.",
"Move to workspace": "Move to workspace",
"Template moved": "Template moved",
@@ -274,7 +297,6 @@
"Deleted Collection": "Deleted Collection",
"Collection options": "Collection options",
"Document options": "Document options",
"Unpin": "Unpin",
"Export started": "Export started",
"A link to your file will be sent through email soon": "A link to your file will be sent through email soon",
"Preparing your download": "Preparing your download",
@@ -298,6 +320,7 @@
"Couldnt move the document, try again?": "Couldnt move the document, try again?",
"Move to <em>{{ location }}</em>": "Move to <em>{{ location }}</em>",
"Couldnt move the template, try again?": "Couldnt move the template, try again?",
"Select": "Select",
"New": "New",
"Only visible to you": "Only visible to you",
"Draft": "Draft",
@@ -425,6 +448,10 @@
"Sorry, an error occurred.": "Sorry, an error occurred.",
"Click to retry": "Click to retry",
"Back": "Back",
"Working…": "Working…",
"{{ count }} selected": "{{ count }} selected",
"{{ count }} selected_plural": "{{ count }} selected",
"Clear selection": "Clear selection",
"Can view the document": "Can view the document",
"Can view and edit the document": "Can view and edit the document",
"Manage": "Manage",
@@ -739,6 +766,7 @@
"Group options": "Group options",
"Cancel": "Cancel",
"Import menu options": "Import menu options",
"collection": "collection",
"New document in <em>{{ collectionName }}</em>": "New document in <em>{{ collectionName }}</em>",
"New child document": "New child document",
"Save in workspace": "Save in workspace",