mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
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:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 @@
|
||||
"Couldn’t move the document, try again?": "Couldn’t move the document, try again?",
|
||||
"Move to <em>{{ location }}</em>": "Move to <em>{{ location }}</em>",
|
||||
"Couldn’t move the template, try again?": "Couldn’t 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",
|
||||
|
||||
Reference in New Issue
Block a user