Compare commits

...

14 Commits

Author SHA1 Message Date
Salihu 9b9275dff0 re-run tests 2025-12-06 12:20:17 +01:00
Salihu 39f0f78ff4 code cleanup 2025-12-06 12:07:48 +01:00
Salihu b2a0a9cf21 use actions for bulk selection menu 2025-12-06 00:31:33 +01:00
Salihu c0ee1aa3d7 code cleanup 2025-12-04 23:12:52 +01:00
Salihu b9e34e4227 code cleanup 2025-12-04 22:55:42 +01:00
Salihu b405e1e985 clean up code 2025-12-04 22:24:35 +01:00
Salihu e0e6b3f3db fetch documents when selected 2025-12-04 21:24:03 +01:00
Salihu b2b0bd8c8f fix bugs 2025-12-04 21:21:19 +01:00
Salihu 1a8d75b81b fix bugs 2025-12-04 20:38:50 +01:00
Salihu 6c3816e07c use DocumentArchive component for single and bulk archiving 2025-12-04 20:38:50 +01:00
Salihu d264848024 use DocumentDelete component for bulk and single delete 2025-12-04 20:38:50 +01:00
Salihu 65a3d1ac47 use DocumentMove component for bulk and single move 2025-12-04 20:38:50 +01:00
Salihu af98549ca7 rework bulk selection functionality 2025-12-04 20:38:48 +01:00
Salihu ce1d2a90c0 select mutiple documents 2025-12-04 19:58:39 +01:00
15 changed files with 855 additions and 153 deletions
+123
View File
@@ -0,0 +1,123 @@
import { ArchiveIcon, MoveIcon, TrashIcon } from "outline-icons";
import DocumentMove from "~/scenes/DocumentMove";
import { createAction } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentArchive from "~/scenes/DocumentArchive";
import Document from "~/models/Document";
type Props = {
documents: Document[];
};
/**
* Archive multiple documents at once.
*/
export const bulkArchiveDocuments = ({ documents }: Props) =>
createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Bulk archive documents",
section: ActiveDocumentSection,
icon: <ArchiveIcon />,
visible: ({ stores }) => {
if (documents.length === 0) {
return false;
}
return documents.every(({ id }) => stores.policies.abilities(id).archive);
},
perform: async ({ stores, t }) => {
const { dialogs, documents: documentsStore } = stores;
const count = documents.length;
if (count === 0) {
return;
}
dialogs.openModal({
title: t("Archive {{ count }} documents", { count }),
content: (
<DocumentArchive
documents={documents}
onSubmit={() => documentsStore.clearSelection()}
/>
),
});
},
});
/**
* Move multiple documents at once.
*/
export const bulkMoveDocuments = ({ documents }: Props) =>
createAction({
name: ({ t }) => `${t("Move")}`,
analyticsName: "Bulk move documents",
section: ActiveDocumentSection,
icon: <MoveIcon />,
visible: ({ stores }) => {
if (documents.length === 0) {
return false;
}
return documents.every(({ id }) => stores.policies.abilities(id).move);
},
perform: ({ stores, t }) => {
const { dialogs, documents: documentsStore } = stores;
const count = documents.length;
if (count === 0) {
return;
}
dialogs.openModal({
title: t("Move {{ count }} documents", { count }),
content: (
<DocumentMove
documents={documents}
onSubmit={() => documentsStore.clearSelection()}
/>
),
});
},
});
/**
* Delete multiple documents at once.
*/
export const bulkDeleteDocuments = ({ documents }: Props) =>
createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Bulk delete documents",
section: ActiveDocumentSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ stores }) => {
if (documents.length === 0) {
return false;
}
return documents.every(({ id }) => stores.policies.abilities(id).delete);
},
perform: async ({ stores, t }) => {
const { dialogs, documents: documentsStore } = stores;
const count = documents.length;
if (count === 0) {
return;
}
dialogs.openModal({
title: t("Delete {{ count }} documents", { count }),
content: (
<DocumentDelete
documents={documents}
onSubmit={() => documentsStore.clearSelection()}
/>
),
});
},
});
export const rootBulkDocumentActions = [
bulkArchiveDocuments,
bulkMoveDocuments,
bulkDeleteDocuments,
];
+4 -21
View File
@@ -47,7 +47,6 @@ import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
import MarkdownIcon from "~/components/Icons/MarkdownIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
@@ -81,6 +80,7 @@ import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Action, ActionGroup, ActionSeparator } from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DocumentArchive from "~/scenes/DocumentArchive";
const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
@@ -1028,7 +1028,7 @@ export const moveDocumentToCollection = createAction({
title: t("Move {{ documentType }}", {
documentType: document.noun,
}),
content: <DocumentMove document={document} />,
content: <DocumentMove documents={[document]} />,
});
}
},
@@ -1094,19 +1094,7 @@ export const archiveDocument = createAction({
dialogs.openModal({
title: t("Are you sure you want to archive this document?"),
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: <DocumentArchive documents={[document]} />,
});
}
},
@@ -1230,12 +1218,7 @@ export const deleteDocument = createAction({
title: t("Delete {{ documentName }}", {
documentName: document.noun,
}),
content: (
<DocumentDelete
document={document}
onSubmit={stores.dialogs.closeAllModals}
/>
),
content: <DocumentDelete documents={[document]} />,
});
}
},
+108
View File
@@ -0,0 +1,108 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import {
MenuHeader,
MenuSeparator,
} from "~/components/primitives/components/Menu";
import { Portal } from "~/components/Portal";
import { toMobileMenuItems } from "~/components/Menu/transformer";
import { actionToMenuItem } from "~/actions";
import { useBulkDocumentMenuAction } from "~/hooks/useBulkDocumentMenuAction";
import useActionContext from "~/hooks/useActionContext";
import useStores from "~/hooks/useStores";
import { ActionVariant } from "~/types";
import NudeButton from "./NudeButton";
import { CrossIcon } from "outline-icons";
function BulkSelectionToolbar() {
const { t } = useTranslation();
const { documents, ui } = useStores();
const selectedCount = documents.selectedDocumentIds.length;
const selectedDocuments = documents.selectedDocuments;
const sidebarWidth = ui.sidebarWidth;
const handleClearSelection = React.useCallback(() => {
documents.clearSelection();
}, [documents]);
const rootAction = useBulkDocumentMenuAction({
documents: selectedDocuments,
});
const actionContext = useActionContext({
isMenu: true,
});
const menuItems = React.useMemo(() => {
if (!rootAction.children || selectedCount === 0) {
return [];
}
return (rootAction.children as ActionVariant[]).map((childAction) =>
actionToMenuItem(childAction, actionContext)
);
}, [rootAction.children, selectedCount, actionContext]);
const content = toMobileMenuItems(menuItems, handleClearSelection, () => {});
if (selectedCount === 0) {
return null;
}
return (
<Portal>
<Wrapper $sidebarWidth={sidebarWidth}>
<MenuContainer>
<Header>
<MenuHeader>
{t("{{ count }} selected", { count: selectedCount })}
</MenuHeader>
<ClearButton
onClick={handleClearSelection}
tooltip={{
content: t("Clear selection"),
}}
>
<CrossIcon size={18} />
</ClearButton>
</Header>
<MenuSeparator />
{content}
</MenuContainer>
</Wrapper>
</Portal>
);
}
const ClearButton = styled(NudeButton)`
&:hover {
color: ${s("text")};
background: ${s("sidebarControlHoverBackground")};
}
`;
const Header = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
const Wrapper = styled.div<{ $sidebarWidth: number }>`
position: fixed;
bottom: 24px;
left: ${(props) => props.$sidebarWidth + 16}px;
z-index: ${depths.menu};
`;
const MenuContainer = styled.div`
min-width: 180px;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 6px;
padding: 6px;
`;
export default observer(BulkSelectionToolbar);
+2
View File
@@ -30,6 +30,7 @@ import SidebarLink from "./components/SidebarLink";
import Starred from "./components/Starred";
import ToggleButton from "./components/ToggleButton";
import TrashLink from "./components/TrashLink";
import BulkSelectionToolbar from "../BulkSelectionToolbar";
function AppSidebar() {
const { t } = useTranslation();
@@ -131,6 +132,7 @@ function AppSidebar() {
<SidebarAction action={inviteUser} />
</Section>
</Scrollable>
<BulkSelectionToolbar />
</DndProvider>
)}
</Sidebar>
@@ -78,6 +78,18 @@ function InnerDocumentLink(
const sidebarContext = useSidebarContext();
const user = useCurrentUser();
// Selection state for bulk operations
const isSelected = documents.isSelected(node.id);
const hasAnySelection = documents.selectedDocumentIds.length > 0;
const handleSelectionChange = React.useCallback(() => {
if (isSelected) {
documents.deselect(node.id);
} else {
documents.select(node.id);
}
}, [documents, node.id, isSelected]);
React.useEffect(() => {
if (
isActiveDocument &&
@@ -434,6 +446,12 @@ function InnerDocumentLink(
isDraft={isDraft}
ref={ref}
menu={menuElement}
onSelectionChange={handleSelectionChange}
selectionState={{
isSelected,
hasAnySelection,
showCheckbox: true,
}}
/>
</DropToImport>
</div>
@@ -14,6 +14,7 @@ import NavLink, { Props as NavLinkProps } from "./NavLink";
import { ActionWithChildren } from "~/types";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { useTranslation } from "react-i18next";
import { CheckboxIcon } from "outline-icons";
/**
* Props for the SidebarLink component.
@@ -56,6 +57,14 @@ type Props = Omit<NavLinkProps, "to"> & {
scrollIntoViewIfNeeded?: boolean;
/** Optional context menu action to display */
contextAction?: ActionWithChildren;
/** State of the selection checkbox */
selectionState?: {
isSelected: boolean;
showCheckbox: boolean;
hasAnySelection: boolean;
};
/** Callback fired when the selection checkbox is toggled */
onSelectionChange?: () => void;
};
const activeDropStyle = {
@@ -88,6 +97,12 @@ function SidebarLink(
disabled,
unreadBadge,
contextAction,
selectionState = {
isSelected: false,
showCheckbox: false,
hasAnySelection: false,
},
onSelectionChange,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
@@ -96,6 +111,7 @@ function SidebarLink(
const { t } = useTranslation();
const theme = useTheme();
const { handleMouseEnter, handleMouseLeave } = useClickIntent(onClickIntent);
const { isSelected, showCheckbox, hasAnySelection } = selectionState;
const style = React.useMemo(
() => ({
paddingLeft: `${(depth || 0) * 16 + (icon ? -8 : 12)}px`,
@@ -149,6 +165,7 @@ function SidebarLink(
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
$hasCheckbox={showCheckbox}
style={style}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
onClick={handleClick}
@@ -166,6 +183,17 @@ function SidebarLink(
{...rest}
>
<Content>
{showCheckbox && (
<CheckboxWrapper $alwaysVisible={hasAnySelection}>
<NudeButton
type="button"
onClick={onSelectionChange}
aria-label={t("Select")}
>
<CheckboxIcon checked={isSelected} />
</NudeButton>
</CheckboxWrapper>
)}
{hasDisclosure && (
<DisclosureComponent
expanded={expanded}
@@ -184,13 +212,23 @@ function SidebarLink(
);
}
// accounts for whitespace around icon
export const IconWrapper = styled.span`
margin-left: -4px;
margin-right: 4px;
height: 24px;
overflow: hidden;
flex-shrink: 0;
transition: opacity 200ms ease-in-out;
transition: opacity 150ms ease-in-out;
`;
const CheckboxWrapper = styled(EventBoundary)<{ $alwaysVisible?: boolean }>`
display: flex;
align-items: center;
justify-content: center;
margin-left: -11px;
flex-shrink: 0;
opacity: ${(props) => (props.$alwaysVisible ? 1 : 0)};
transition: opacity 150ms ease-in-out;
`;
const Content = styled.span`
@@ -239,6 +277,7 @@ const Link = styled(NavLink)<{
$isActiveDrop?: boolean;
$isDraft?: boolean;
$disabled?: boolean;
$hasCheckbox?: boolean;
}>`
&:hover,
&:active {
@@ -326,6 +365,14 @@ const Link = styled(NavLink)<{
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.text};
}
${(props) =>
props.$hasCheckbox &&
css`
&:hover ${CheckboxWrapper} {
opacity: 1;
}
`}
}
& ${Actions} {
@@ -24,12 +24,7 @@ function TrashLink() {
title: t("Delete {{ documentName }}", {
documentName: document?.noun,
}),
content: (
<DocumentDelete
document={document}
onSubmit={dialogs.closeAllModals}
/>
),
content: <DocumentDelete documents={[document]} />,
});
},
canDrop: (item) => policies.abilities(item.id).delete,
@@ -67,7 +67,8 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
!props.disabled &&
`
&[data-highlighted],
&:focus-visible {
&:focus-visible,
&:hover {
color: ${props.theme.accentText};
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
+35
View File
@@ -0,0 +1,35 @@
import { useMemo } from "react";
import {
bulkArchiveDocuments,
bulkDeleteDocuments,
bulkMoveDocuments,
} from "~/actions/definitions/bulkDocuments";
import Document from "~/models/Document";
import { useMenuAction } from "./useMenuAction";
type Props = {
/** Documents that are selected */
documents: Document[];
};
/**
* Hook that creates bulk document menu actions.
*
* @param props - documents and callbacks.
* @returns root menu action with children for bulk operations.
*/
export function useBulkDocumentMenuAction({ documents }: Props) {
const actions = useMemo(() => {
if (!documents.length) {
return [];
}
return [
bulkArchiveDocuments({ documents }),
bulkMoveDocuments({ documents }),
bulkDeleteDocuments({ documents }),
];
}, [documents]);
return useMenuAction(actions);
}
+1 -1
View File
@@ -250,7 +250,7 @@ class DocumentScene extends React.Component<Props> {
if (abilities.move) {
dialogs.openModal({
title: t("Move document"),
content: <DocumentMove document={document} />,
content: <DocumentMove documents={[document]} />,
});
}
};
+98
View File
@@ -0,0 +1,98 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import Document from "~/models/Document";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
type Props = {
documents: Document[];
onSubmit?: () => void;
};
function DocumentArchive({ documents, onSubmit }: Props) {
const { t } = useTranslation();
const { dialogs } = useStores();
const [isArchiving, setArchiving] = React.useState(false);
const isBulkAction = documents.length > 1;
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setArchiving(true);
try {
const results = await Promise.allSettled(
documents.map((document) => document.archive())
);
const errorCount = results.filter(
(r) => r.status === "rejected"
).length;
if (errorCount === documents.length) {
throw new Error(
t("Couldn't archive the {{noun}}, try again?", {
noun: isBulkAction ? "documents" : "document",
})
);
}
if (isBulkAction) {
const successCount = results.filter(
(r) => r.status === "fulfilled"
).length;
if (errorCount === 0) {
toast.success(
t("{{ count }} documents archived", { count: successCount })
);
} else {
toast.warning(
t("{{ errorCount }} documents failed to archive, try again?", {
errorCount,
})
);
}
} else {
toast.success(t("Document archived"));
}
onSubmit?.();
dialogs.closeAllModals();
} catch (err) {
toast.error(err.message);
} finally {
setArchiving(false);
}
},
[onSubmit, documents, t, isBulkAction, dialogs]
);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
{isBulkAction ? (
<Trans
count={documents.length}
defaults="Are you sure you want to archive these <em>{{ count }} documents</em>? They will be removed from collections and search results."
values={{ count: documents.length }}
components={{ em: <strong /> }}
/>
) : (
<Trans defaults="Archiving this document will remove it from the collection and search results." />
)}
</Text>
<Flex justify="flex-end" gap={8}>
<Button type="submit">
{isArchiving ? `${t("Archiving")}` : t("Archive")}
</Button>
</Flex>
</form>
);
}
export default observer(DocumentArchive);
+197 -76
View File
@@ -15,70 +15,125 @@ import {
} from "~/utils/routeHelpers";
type Props = {
document: Document;
onSubmit: () => void;
documents: Document[];
onSubmit?: () => void;
};
function DocumentDelete({ document, onSubmit }: Props) {
function DocumentDelete({ documents, onSubmit }: Props) {
const { t } = useTranslation();
const { ui, documents, collections, userMemberships, groupMemberships } =
useStores();
const {
ui,
dialogs,
documents: documentsStore,
collections: collectionsStore,
userMemberships,
groupMemberships,
} = useStores();
const history = useHistory();
const [isDeleting, setDeleting] = React.useState(false);
const [isArchiving, setArchiving] = React.useState(false);
const canArchive =
!document.isDraft && !document.isArchived && !document.template;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
const nestedDocumentsCount = collection
? collection.getChildrenForDocument(document.id).length
: 0;
const isBulkAction = documents.length > 1;
const canArchiveAll = documents.every(
(doc) => !doc.isDraft && !doc.isArchived && !doc.template
);
const nestedDocumentsCount = React.useMemo(
() =>
documents.reduce((total, doc) => {
const collection = collectionsStore.get(doc.collectionId || "");
const childrenCount = collection?.getChildrenForDocument(doc.id).length;
return total + (childrenCount ?? 0);
}, 0),
[documents, collectionsStore]
);
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setDeleting(true);
try {
await document.delete();
const failedIds: string[] = [];
let successCount = 0;
userMemberships
.getByDocumentId(document.id)
?.removeDocument(document.id);
groupMemberships
.getByDocumentId(document.id)
?.removeDocument(document.id);
// only redirect if we're currently viewing the document that's deleted
if (ui.activeDocumentId === document.id) {
// If the document has a parent and it's available in the store then
// redirect to it
if (document.parentDocumentId) {
const parent = documents.get(document.parentDocumentId);
if (parent) {
history.push(documentPath(parent));
onSubmit();
return;
}
// Delete documents
for (const document of documents) {
try {
await document.delete();
userMemberships
.getByDocumentId(document.id)
?.removeDocument(document.id);
groupMemberships
.getByDocumentId(document.id)
?.removeDocument(document.id);
successCount++;
} catch {
failedIds.push(document.id);
}
// If template, redirect to the template settings.
// Otherwise redirect to the collection (or) home.
const path = document.template
? settingsPath("templates")
: collectionPath(collection?.path || "/");
history.push(path);
}
onSubmit();
if (failedIds.length === documents.length) {
throw new Error(
t("Couldnt delete the {{noun}}, try again?", {
noun: isBulkAction ? "documents" : "document",
})
);
}
onSubmit?.();
dialogs.closeAllModals();
// Show toast messages
if (isBulkAction) {
const message = failedIds.length
? t("{{ errorCount }} documents failed to delete, try again?", {
errorCount: failedIds.length,
})
: t("{{ count }} documents deleted", { count: successCount });
failedIds.length ? toast.warning(message) : toast.success(message);
} else {
toast.success(t("Document deleted"));
}
// only redirect if we're currently viewing one of the documents that have been deleted
const activeDocument = documents.find(
(doc) => ui.activeDocumentId === doc.id
);
if (activeDocument && !failedIds.includes(activeDocument.id)) {
const parent = activeDocument.parentDocumentId
? documentsStore.get(activeDocument.parentDocumentId)
: null;
const path = parent
? documentPath(parent)
: activeDocument.template
? settingsPath("templates")
: collectionPath(
collectionsStore.get(activeDocument.collectionId || "")
?.path || "/"
);
history.push(path);
}
} catch (err) {
toast.error(err.message);
} finally {
setDeleting(false);
}
},
[onSubmit, ui, document, documents, history, collection]
[
documents,
userMemberships,
groupMemberships,
ui,
documentsStore,
collectionsStore,
history,
dialogs,
onSubmit,
isBulkAction,
t,
]
);
const handleArchive = React.useCallback(
@@ -87,68 +142,134 @@ function DocumentDelete({ document, onSubmit }: Props) {
setArchiving(true);
try {
await document.archive();
onSubmit();
const results = await Promise.allSettled(
documents.map((doc) => doc.archive())
);
const errorCount = results.filter(
(r) => r.status === "rejected"
).length;
if (errorCount === documents.length) {
throw new Error(
t("Couldnt archive the {{noun}}, try again?", {
noun: isBulkAction ? "documents" : "document",
})
);
}
onSubmit?.();
dialogs.closeAllModals();
// Show toast messages
if (isBulkAction) {
const successCount = results.filter(
(r) => r.status === "fulfilled"
).length;
const message = errorCount
? t("{{ successCount }} archived, {{ errorCount }} failed", {
successCount,
errorCount,
})
: t("{{ count }} documents archived", { count: successCount });
errorCount ? toast.warning(message) : toast.success(message);
} else {
toast.success(t("Document archived"));
}
} catch (err) {
toast.error(err.message);
} finally {
setArchiving(false);
}
},
[onSubmit, document]
[documents, dialogs, isBulkAction, t, onSubmit]
);
const NoChildBody = () =>
isBulkAction ? (
<Trans
count={documents.length}
defaults="Are you sure you want to delete these <em>{{ count }} documents</em>? This action will delete all their history."
values={{ count: documents.length }}
components={{ em: <strong /> }}
/>
) : (
<Trans
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>."
values={{
documentTitle: documents[0].titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
);
const HasChildBody = () =>
isBulkAction ? (
<Trans
count={documents.length}
defaults="Are you sure about that? Deleting these <em>{{ count }} documents</em> will delete all their history and their combined <em>{{ any }} nested documents.</em>."
values={{ count: documents.length, any: nestedDocumentsCount }}
components={{
em: <strong />,
}}
/>
) : (
<Trans
count={nestedDocumentsCount}
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>."
values={{
documentTitle: documents[0].titleWithDefault,
any: nestedDocumentsCount,
}}
components={{
em: <strong />,
}}
/>
);
const ArchiveInsteadBody = () =>
isBulkAction ? (
<Trans>
If youd like the option of referencing or restoring these documents in
the future, consider archiving them instead.
</Trans>
) : (
<Trans>
If youd like the option of referencing or restoring the document in the
future, consider archiving it instead.
</Trans>
);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
{document.isTemplate ? (
{!isBulkAction && documents[0].isTemplate ? (
<Trans
defaults="Are you sure you want to delete the <em>{{ documentTitle }}</em> template?"
values={{
documentTitle: document.titleWithDefault,
documentTitle: documents[0].titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
) : nestedDocumentsCount < 1 ? (
<Trans
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>."
values={{
documentTitle: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
<NoChildBody />
) : (
<Trans
count={nestedDocumentsCount}
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>."
values={{
documentTitle: document.titleWithDefault,
any: nestedDocumentsCount,
}}
components={{
em: <strong />,
}}
/>
<HasChildBody />
)}
</Text>
{canArchive && (
{canArchiveAll && (
<Text as="p" type="secondary">
<Trans>
If youd like the option of referencing or restoring the{" "}
{{
noun: document.noun,
}}{" "}
in the future, consider archiving it instead.
</Trans>
<ArchiveInsteadBody />
</Text>
)}
<Flex justify="flex-end" gap={8}>
{canArchive && (
{canArchiveAll && (
<Button type="button" onClick={handleArchive} neutral>
{isArchiving ? `${t("Archiving")}` : t("Archive")}
</Button>
+103 -38
View File
@@ -14,23 +14,38 @@ import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
type Props = {
document: Document;
documents: Document[];
onSubmit?: () => void;
};
function DocumentMove({ document }: Props) {
function DocumentMove({ documents, onSubmit }: Props) {
const { dialogs, policies } = useStores();
const { t } = useTranslation();
const collectionTrees = useCollectionTrees();
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
const [isMoving, setMoving] = useState(false);
const isBulkAction = documents.length > 1;
const documentIds = useMemo(
() => new Set(documents.map((doc) => doc.id)),
[documents]
);
const items = useMemo(() => {
// Recursively filter out the document itself and its existing parent doc, if any.
const filterSourceDocument = (node: NavigationNode): NavigationNode => ({
...node,
children: node.children
?.filter(
(c) => c.id !== document.id && c.id !== document.parentDocumentId
)
?.filter((c) => {
// if multiple documents are selected we want to only filter out the selected documents.
if (isBulkAction) {
return !documentIds.has(c.id);
}
return (
c.id !== documents[0].id && c.id !== documents[0].parentDocumentId
);
})
.map(filterSourceDocument),
});
@@ -45,19 +60,14 @@ function DocumentMove({ document }: Props) {
// If the document we're moving is a template, only show collections as
// move targets.
if (document.isTemplate) {
const hasTemplates = documents.some((doc) => doc.isTemplate);
if (hasTemplates) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [
policies,
collectionTrees,
document.id,
document.parentDocumentId,
document.isTemplate,
]);
}, [policies, collectionTrees, documentIds, documents, isBulkAction]);
const move = async () => {
if (!selectedPath) {
@@ -65,46 +75,101 @@ function DocumentMove({ document }: Props) {
return;
}
try {
const { type, id: parentDocumentId } = selectedPath;
setMoving(true);
const collectionId = selectedPath.collectionId as string;
const { type, id: parentDocumentId } = selectedPath;
const collectionId = selectedPath.collectionId as string;
if (type === "document") {
await document.move({ collectionId, parentDocumentId });
} else {
await document.move({ collectionId });
let successCount = 0;
let errorCount = 0;
for (const document of documents) {
try {
if (type === "document") {
await document.move({ collectionId, parentDocumentId });
} else {
await document.move({ collectionId });
}
successCount++;
} catch {
errorCount++;
}
toast.success(t("Document moved"));
dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldnt move the document, try again?"));
}
if (errorCount === documents.length) {
toast.error(
t("Couldnt move the {{noun}}, try again?", {
noun: isBulkAction ? "documents" : "document",
})
);
setMoving(false);
return;
}
onSubmit?.();
if (!isBulkAction) {
toast.success(t("Document moved"));
} else {
if (errorCount === 0) {
toast.success(
t("{{ count }} documents moved", { count: successCount })
);
} else {
toast.warning(
t("{{ errorCount }} documents failed to move, try again?", {
errorCount,
})
);
}
}
dialogs.closeAllModals();
setMoving(false);
};
const SelectedPathFooter = ({ title }: { title: string }) =>
isBulkAction ? (
<Trans
defaults="Move {{ count }} documents to <em>{{ location }}</em>"
values={{
count: documents.length,
location: title || t("Untitled"),
}}
components={{
em: <strong />,
}}
/>
) : (
<Trans
defaults="Move to <em>{{ location }}</em>"
values={{
location: title || t("Untitled"),
}}
components={{
em: <strong />,
}}
/>
);
const NoSelectedPathFooter = isBulkAction
? t("Select a location to move {{ count }} documents", {
count: documents.length,
})
: t("Select a location to move");
return (
<FlexContainer column>
<DocumentExplorer items={items} onSubmit={move} onSelect={selectPath} />
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
{selectedPath ? (
<Trans
defaults="Move to <em>{{ location }}</em>"
values={{
location: selectedPath.title || t("Untitled"),
}}
components={{
em: <strong />,
}}
/>
<SelectedPathFooter title={selectedPath.title} />
) : (
t("Select a location to move")
NoSelectedPathFooter
)}
</StyledText>
<Button disabled={!selectedPath} onClick={move}>
{t("Move")}
<Button disabled={!selectedPath || isMoving} onClick={move}>
{isMoving ? `${t("Moving")}` : t("Move")}
</Button>
</Footer>
</FlexContainer>
+72
View File
@@ -52,6 +52,14 @@ export default class DocumentsStore extends Store<Document> {
@observable
movingDocumentId: string | null | undefined;
/** Set of selected document IDs for bulk operations */
@observable
selectedIds: Set<string> = new Set();
/** Whether selection mode is active */
@observable
isSelectionMode = false;
importFileTypes: string[] = [
".md",
".doc",
@@ -772,4 +780,68 @@ export default class DocumentsStore extends Store<Document> {
? this.rootStore.collections.get(document.collectionId)
: undefined;
}
// Selection methods for bulk operations
/**
* Returns an array of selected document IDs.
*/
@computed
get selectedDocumentIds(): string[] {
return Array.from(this.selectedIds);
}
/**
* Returns the selected documents.
*/
@computed
get selectedDocuments(): Document[] {
return compact(this.selectedDocumentIds.map((id) => this.get(id)));
}
/**
* Checks if a document is selected.
*
* @param id - the document id to check.
* @returns true if the document is selected.
*/
isSelected(id: string): boolean {
return this.selectedIds.has(id);
}
/**
* Selects a document.
*
* @param id - the document id to select.
*/
@action
select(id: string): void {
this.selectedIds.add(id);
void this.fetch(id);
if (!this.isSelectionMode) {
this.isSelectionMode = true;
}
}
/**
* Deselects a document.
*
* @param id - the document id to deselect.
*/
@action
deselect(id: string): void {
this.selectedIds.delete(id);
if (this.selectedIds.size === 0) {
this.isSelectionMode = false;
}
}
/**
* Clears all selections and exits selection mode.
*/
@action
clearSelection(): void {
this.selectedIds.clear();
this.isSelectionMode = false;
}
}
+42 -8
View File
@@ -4,6 +4,14 @@
"Revoke": "Revoke",
"Revoke API key": "Revoke API key",
"Revoke token": "Revoke token",
"Archive": "Archive",
"Archive {{ count }} documents": "Archive {{ count }} documents",
"Archive {{ count }} documents_plural": "Archive {{ count }} documents",
"Move": "Move",
"Move {{ count }} documents": "Move {{ count }} documents",
"Move {{ count }} documents_plural": "Move {{ count }} documents",
"Delete {{ count }} documents": "Delete {{ count }} documents",
"Delete {{ count }} documents_plural": "Delete {{ count }} documents",
"Open collection": "Open collection",
"New collection": "New collection",
"Create a collection": "Create a collection",
@@ -24,7 +32,6 @@
"Subscribed to document notifications": "Subscribed to document notifications",
"Unsubscribe": "Unsubscribe",
"Unsubscribed from document notifications": "Unsubscribed from document notifications",
"Archive": "Archive",
"Archive collection": "Archive collection",
"Collection archived": "Collection archived",
"Archiving": "Archiving",
@@ -93,12 +100,9 @@
"Open random document": "Open random document",
"Search documents for \"{{searchQuery}}\"": "Search documents for \"{{searchQuery}}\"",
"Move to workspace": "Move to workspace",
"Move": "Move",
"Move to collection": "Move to collection",
"Move {{ documentType }}": "Move {{ documentType }}",
"Are you sure you want to archive this document?": "Are you sure you want to archive this document?",
"Document archived": "Document 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.",
"{{ documentName }} restored": "{{ documentName }} restored",
"Choose a collection": "Choose a collection",
"Delete {{ documentName }}": "Delete {{ documentName }}",
@@ -183,6 +187,9 @@
"currently viewing": "currently viewing",
"previously edited": "previously edited",
"You": "You",
"{{ count }} selected": "{{ count }} selected",
"{{ count }} selected_plural": "{{ count }} selected",
"Clear selection": "Clear selection",
"Avatar of {{ name }}": "Avatar of {{ name }}",
"Viewers": "Viewers",
"Collections are used to group documents and choose permissions": "Collections are used to group documents and choose permissions",
@@ -457,6 +464,7 @@
"Shared with me": "Shared with me",
"Show more": "Show more",
"Link options": "Link options",
"Select": "Select",
"Could not load starred documents": "Could not load starred documents",
"Starred": "Starred",
"Up to date": "Up to date",
@@ -466,6 +474,7 @@
"{{ documentName }} cannot be moved within {{ parentDocumentName }}": "{{ documentName }} cannot be moved within {{ parentDocumentName }}",
"You can't reorder documents in an alphabetically sorted collection": "You can't reorder documents in an alphabetically sorted collection",
"The {{ documentName }} cannot be moved here": "The {{ documentName }} cannot be moved here",
"Document archived": "Document archived",
"Return to App": "Back to App",
"Installation": "Installation",
"Unstar document": "Unstar document",
@@ -776,15 +785,40 @@
"Observing {{ userName }}": "Observing {{ userName }}",
"Backlinks": "Backlinks",
"This document is large which may affect performance": "This document is large which may affect performance",
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
"Couldn't archive the {{noun}}, try again?": "Couldn't archive the {{noun}}, try again?",
"{{ count }} documents archived": "{{ count }} documents archived",
"{{ count }} documents archived_plural": "{{ count }} documents archived",
"{{ errorCount }} documents failed to archive, try again?": "{{ errorCount }} documents failed to archive, try again?",
"Are you sure you want to archive these <em>{{ count }} documents</em>? They will be removed from collections and search results.": "Are you sure you want to archive these <em>{{ count }} documents</em>? They will be removed from collections and search results.",
"Are you sure you want to archive these <em>{{ count }} documents</em>? They will be removed from collections and search results._plural": "Are you sure you want to archive these <em>{{ count }} documents</em>? They will be removed from collections and search results.",
"Archiving this document will remove it from the collection and search results.": "Archiving this document will remove it from the collection and search results.",
"Couldnt delete the {{noun}}, try again?": "Couldnt delete the {{noun}}, try again?",
"{{ errorCount }} documents failed to delete, try again?": "{{ errorCount }} documents failed to delete, try again?",
"{{ count }} documents deleted": "{{ count }} documents deleted",
"{{ count }} documents deleted_plural": "{{ count }} documents deleted",
"Document deleted": "Document deleted",
"Couldnt archive the {{noun}}, try again?": "Couldnt archive the {{noun}}, try again?",
"{{ successCount }} archived, {{ errorCount }} failed": "{{ successCount }} archived, {{ errorCount }} failed",
"Are you sure you want to delete these <em>{{ count }} documents</em>? This action will delete all their history.": "Are you sure you want to delete these <em>{{ count }} documents</em>? This action will delete all their history.",
"Are you sure you want to delete these <em>{{ count }} documents</em>? This action will delete all their history._plural": "Are you sure you want to delete these <em>{{ count }} documents</em>? This action will delete all their history.",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.",
"Are you sure about that? Deleting these <em>{{ count }} documents</em> will delete all their history and their combined <em>{{ any }} nested documents.</em>.": "Are you sure about that? Deleting these <em>{{ count }} documents</em> will delete all their history and their combined <em>{{ any }} nested documents.</em>.",
"Are you sure about that? Deleting these <em>{{ count }} documents</em> will delete all their history and their combined <em>{{ any }} nested documents.</em>._plural": "Are you sure about that? Deleting these <em>{{ count }} documents</em> will delete all their history and their combined <em>{{ any }} nested documents.</em>.",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>._plural": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested documents</em>.",
"If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>._plural": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.",
"If youd like the option of referencing or restoring these documents in the future, consider archiving them instead.": "If youd like the option of referencing or restoring these documents in the future, consider archiving them instead.",
"If youd like the option of referencing or restoring the document in the future, consider archiving it instead.": "If youd like the option of referencing or restoring the document in the future, consider archiving it instead.",
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
"Select a location to move": "Select a location to move",
"Couldnt move the {{noun}}, try again?": "Couldnt move the {{noun}}, try again?",
"Document moved": "Document moved",
"Couldnt move the document, try again?": "Couldnt move the document, try again?",
"{{ count }} documents moved": "{{ count }} documents moved",
"{{ count }} documents moved_plural": "{{ count }} documents moved",
"{{ errorCount }} documents failed to move, try again?": "{{ errorCount }} documents failed to move, try again?",
"Move {{ count }} documents to <em>{{ location }}</em>": "Move {{ count }} documents to <em>{{ location }}</em>",
"Move to <em>{{ location }}</em>": "Move to <em>{{ location }}</em>",
"Select a location to move {{ count }} documents": "Select a location to move {{ count }} documents",
"Select a location to move {{ count }} documents_plural": "Select a location to move {{ count }} documents",
"Couldnt create the document, try again?": "Couldnt create the document, try again?",
"Document permanently deleted": "Document permanently deleted",
"Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.": "Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.",