Compare commits

...

6 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] bf4791ce55 Initial plan 2025-12-03 22:51:22 +00:00
Salihu 4ad3620ec9 use DocumentArchive component for single and bulk archiving 2025-12-03 23:49:20 +01:00
Salihu 101a3f5137 use DocumentDelete component for bulk and single delete 2025-12-03 23:16:09 +01:00
Salihu 4704404105 use DocumentMove component for bulk and single move 2025-12-03 21:22:47 +01:00
Salihu 3bfc8747dd rework bulk selection functionality 2025-12-03 20:33:32 +01:00
Salihu 15b51290b5 select mutiple documents 2025-12-03 20:24:52 +01:00
12 changed files with 844 additions and 143 deletions
+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";
@@ -82,6 +81,7 @@ import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { ActionV2, ActionV2Group, ActionV2Separator } from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DocumentArchive from "~/scenes/DocumentArchive";
const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
@@ -1018,7 +1018,7 @@ export const moveDocumentToCollection = createActionV2({
title: t("Move {{ documentType }}", {
documentType: document.noun,
}),
content: <DocumentMove document={document} />,
content: <DocumentMove documents={[document]} />,
});
}
},
@@ -1084,19 +1084,7 @@ export const archiveDocument = createActionV2({
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]} />,
});
}
},
@@ -1220,12 +1208,7 @@ export const deleteDocument = createActionV2({
title: t("Delete {{ documentName }}", {
documentName: document.noun,
}),
content: (
<DocumentDelete
document={document}
onSubmit={stores.dialogs.closeAllModals}
/>
),
content: <DocumentDelete documents={[document]} />,
});
}
},
+242
View File
@@ -0,0 +1,242 @@
import { observer } from "mobx-react";
import { ArchiveIcon, CrossIcon, MoveIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useStores from "~/hooks/useStores";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentArchive from "~/scenes/DocumentArchive";
function BulkSelectionToolbar() {
const { t } = useTranslation();
const { documents, dialogs, policies, ui } = useStores();
const selectedCount = documents.selectedCount;
const selectedDocuments = documents.selectedDocuments;
const canArchiveAll = selectedDocuments.every(
(doc) => policies.abilities(doc.id).archive
);
const canDeleteAll = selectedDocuments.every(
(doc) => policies.abilities(doc.id).delete
);
const canMoveAll = selectedDocuments.every(
(doc) => policies.abilities(doc.id).move
);
const handleClear = React.useCallback(
(ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
documents.clearSelection();
},
[documents]
);
const handleArchive = React.useCallback(
(ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
dialogs.openModal({
title: t("Archive {{ count }} documents", { count: selectedCount }),
content: (
<DocumentArchive
documents={selectedDocuments}
onSubmit={documents.clearSelection}
/>
),
});
},
[dialogs, selectedCount, selectedDocuments, t]
);
const handleDelete = React.useCallback(
(ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
dialogs.openModal({
title: t("Delete {{ count }} documents", { count: selectedCount }),
content: (
<DocumentDelete
documents={selectedDocuments}
onSubmit={() => {
dialogs.closeAllModals();
documents.clearSelection();
}}
/>
),
});
},
[dialogs, selectedCount, selectedDocuments, t, documents]
);
const handleMove = React.useCallback(
(ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
dialogs.openModal({
title: t("Move {{ count }} documents", { count: selectedCount }),
content: (
<DocumentMove
documents={selectedDocuments}
onSubmit={documents.clearSelection}
/>
),
});
},
[dialogs, selectedCount, selectedDocuments, t, documents]
);
const sidebarWidth = ui.sidebarWidth;
if (selectedCount === 0) {
return null;
}
return (
<Wrapper $sidebarWidth={sidebarWidth}>
<MenuContainer>
<Header>
<CountText>
{t("{{ count }} selected", { count: selectedCount })}
</CountText>
<Tooltip content={t("Clear selection")} placement="top">
<ClearButton onClick={handleClear}>
<CrossIcon size={18} />
</ClearButton>
</Tooltip>
</Header>
<MenuSeparator />
{canArchiveAll && (
<MenuItem onClick={handleArchive}>
<MenuIconWrapper>
<ArchiveIcon />
</MenuIconWrapper>
<MenuLabel>{t("Archive")}</MenuLabel>
</MenuItem>
)}
{canMoveAll && (
<MenuItem onClick={handleMove}>
<MenuIconWrapper>
<MoveIcon />
</MenuIconWrapper>
<MenuLabel>{t("Move")}</MenuLabel>
</MenuItem>
)}
{canDeleteAll && (
<MenuItem onClick={handleDelete} $dangerous>
<MenuIconWrapper>
<TrashIcon />
</MenuIconWrapper>
<MenuLabel>{t("Delete")}</MenuLabel>
</MenuItem>
)}
</MenuContainer>
</Wrapper>
);
}
const Wrapper = styled.div<{ $sidebarWidth: number }>`
position: fixed;
bottom: 24px;
left: ${(props) => props.$sidebarWidth + 16}px;
z-index: ${depths.menu};
`;
const MenuContainer = styled.div`
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 6px;
padding: 6px;
min-width: 180px;
`;
const Header = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px 4px;
`;
const CountText = styled.span`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: ${s("textTertiary")};
letter-spacing: 0.04em;
`;
const ClearButton = styled(NudeButton)`
width: 24px;
height: 24px;
color: ${s("textTertiary")};
&:hover {
color: ${s("text")};
background: ${s("sidebarControlHoverBackground")};
}
`;
const MenuSeparator = styled.hr`
margin: 6px 0;
border: none;
border-top: 1px solid ${s("divider")};
`;
const MenuItem = styled.button<{ $dangerous?: boolean }>`
display: flex;
align-items: center;
width: 100%;
min-height: 32px;
font-size: 16px;
cursor: var(--pointer);
user-select: none;
white-space: nowrap;
background: none;
color: ${s("textSecondary")};
margin: 0;
border: 0;
border-radius: 4px;
padding: 12px;
&:hover {
color: ${s("accentText")};
background: ${(props) =>
props.$dangerous ? props.theme.danger : props.theme.accent};
svg {
color: ${s("accentText")};
fill: ${s("accentText")};
}
}
${breakpoint("tablet")`
padding: 4px 12px;
font-size: 14px;
`}
`;
const MenuIconWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 6px;
margin-left: -4px;
color: ${s("textSecondary")};
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
`;
const MenuLabel = styled.span`
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
`;
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.selectedCount > 0;
const handleCheckboxChange = React.useCallback(() => {
if (documents.isSelected(node.id)) {
documents.deselect(node.id);
} else {
documents.select(node.id);
}
}, [documents, node.id]);
React.useEffect(() => {
if (
isActiveDocument &&
@@ -434,6 +446,10 @@ function InnerDocumentLink(
isDraft={isDraft}
ref={ref}
menu={menuElement}
isSelected={isSelected}
showCheckbox
hasAnySelection={hasAnySelection}
onCheckboxChange={handleCheckboxChange}
/>
</DropToImport>
</div>
@@ -14,6 +14,7 @@ import NavLink, { Props as NavLinkProps } from "./NavLink";
import { ActionV2WithChildren } 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?: ActionV2WithChildren;
/** Whether the item is selected for bulk operations */
isSelected?: boolean;
/** Whether to show the selection checkbox */
showCheckbox?: boolean;
/** Whether any document is selected (makes checkbox always visible) */
hasAnySelection?: boolean;
/** Callback fired when the checkbox is toggled */
onCheckboxChange?: () => void;
};
const activeDropStyle = {
@@ -88,6 +97,10 @@ function SidebarLink(
disabled,
unreadBadge,
contextAction,
isSelected = false,
showCheckbox,
hasAnySelection,
onCheckboxChange,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
@@ -141,6 +154,15 @@ function SidebarLink(
[onDisclosureClick]
);
const handleCheckBoxClick = React.useCallback(
(ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
onCheckboxChange?.();
},
[onCheckboxChange]
);
const DisclosureComponent = icon ? HiddenDisclosure : Disclosure;
return (
@@ -149,6 +171,9 @@ function SidebarLink(
$isActiveDrop={isActiveDrop}
$isDraft={isDraft}
$disabled={disabled}
$isSelected={isSelected}
$hasCheckbox={showCheckbox}
$hasAnySelection={hasAnySelection}
style={style}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
onClick={handleClick}
@@ -166,6 +191,15 @@ function SidebarLink(
{...rest}
>
<Content>
{showCheckbox && (
<CheckboxWrapper $alwaysVisible={hasAnySelection}>
<CheckboxIcon
checked={isSelected}
onClick={handleCheckBoxClick}
aria-label={t("Select")}
/>
</CheckboxWrapper>
)}
{hasDisclosure && (
<DisclosureComponent
expanded={expanded}
@@ -174,7 +208,9 @@ function SidebarLink(
tabIndex={-1}
/>
)}
{icon && <IconWrapper>{icon}</IconWrapper>}
{icon && (
<IconWrapper $hideForCheckbox={hasAnySelection}>{icon}</IconWrapper>
)}
<Label $ellipsis={typeof label === "string"}>{label}</Label>
{unreadBadge && <UnreadBadge style={unreadStyle} />}
</Content>
@@ -185,12 +221,24 @@ function SidebarLink(
}
// accounts for whitespace around icon
export const IconWrapper = styled.span`
export const IconWrapper = styled.span<{ $hideForCheckbox?: boolean }>`
margin-left: -4px;
height: 24px;
overflow: hidden;
flex-shrink: 0;
transition: opacity 200ms ease-in-out;
transition: all 150ms ease-in-out;
display: ${(props) => (props.$hideForCheckbox ? "none" : "block")};
`;
const CheckboxWrapper = styled(EventBoundary)<{ $alwaysVisible?: boolean }>`
display: flex;
align-items: center;
justify-content: center;
margin-left: -4px;
margin-right: 4px;
flex-shrink: 0;
opacity: ${(props) => (props.$alwaysVisible ? 1 : 0)};
transition: opacity 150ms ease-in-out;
`;
const Content = styled.span`
@@ -239,6 +287,9 @@ const Link = styled(NavLink)<{
$isActiveDrop?: boolean;
$isDraft?: boolean;
$disabled?: boolean;
$isSelected?: boolean;
$hasCheckbox?: boolean;
$hasAnySelection?: boolean;
}>`
&:hover,
&:active {
@@ -250,6 +301,7 @@ const Link = styled(NavLink)<{
}
${(props) => props.$isActiveDrop && `--background: ${props.theme.slateDark};`}
${(props) => props.$isSelected && `--background: ${props.theme.accent}15;`}
display: flex;
position: relative;
@@ -326,6 +378,18 @@ const Link = styled(NavLink)<{
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.text};
}
/* Show checkbox on hover and hide icon when checkbox is enabled */
${(props) =>
props.$hasCheckbox &&
css`
&:hover ${CheckboxWrapper} {
opacity: 1;
}
&:hover ${IconWrapper} {
display: none;
}
`}
}
& ${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,
+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]} />,
});
}
};
+91
View File
@@ -0,0 +1,91 @@
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 { documents: documentsStore, 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())
);
if (isBulkAction) {
const successCount = results.filter(
(r) => r.status === "fulfilled"
).length;
const errorCount = results.filter(
(r) => r.status === "rejected"
).length;
if (errorCount === 0) {
toast.success(
t("{{ count }} documents archived", { count: successCount })
);
} else {
toast.warning(
t("{{ successCount }} archived, {{ errorCount }} failed", {
successCount,
errorCount,
})
);
}
} else {
toast.success(t("Document archived"));
}
onSubmit?.();
dialogs.closeAllModals();
} catch (err) {
toast.error(err.message);
} finally {
setArchiving(false);
}
},
[onSubmit, documents, documentsStore, 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 <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);
+178 -76
View File
@@ -15,70 +15,116 @@ 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 childrenCount = doc.childDocuments.length;
return total + childrenCount;
}, 0),
[documents]
);
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();
// Show toast messages
if (isBulkAction) {
const message = failedIds.length
? t("{{ successCount }} deleted, {{ errorCount }} failed", {
successCount,
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 {
onSubmit?.();
setDeleting(false);
dialogs.closeAllModals();
}
},
[onSubmit, ui, document, documents, history, collection]
[
documents,
userMemberships,
groupMemberships,
ui,
documentsStore,
collectionsStore,
history,
dialogs,
onSubmit,
isBulkAction,
t,
]
);
const handleArchive = React.useCallback(
@@ -87,68 +133,124 @@ function DocumentDelete({ document, onSubmit }: Props) {
setArchiving(true);
try {
await document.archive();
onSubmit();
const results = await Promise.allSettled(
documents.map((doc) => doc.archive())
);
// Show toast messages
if (isBulkAction) {
const successCount = results.filter(
(r) => r.status === "fulfilled"
).length;
const errorCount = results.filter(
(r) => r.status === "rejected"
).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 {
onSubmit?.();
setArchiving(false);
dialogs.closeAllModals();
}
},
[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>
+92 -31
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,97 @@ function DocumentMove({ document }: Props) {
return;
}
setMoving(true);
try {
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"));
onSubmit?.();
if (!isBulkAction) {
toast.success(t("Document moved"));
} else {
if (errorCount === 0) {
toast.success(
t("{{ count }} documents moved", { count: successCount })
);
} else {
toast.warning(
t("{{ successCount }} moved, {{ errorCount }} failed", {
successCount,
errorCount,
})
);
}
}
dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldnt move the document, try again?"));
} finally {
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>
+114
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,110 @@ export default class DocumentsStore extends Store<Document> {
? this.rootStore.collections.get(document.collectionId)
: undefined;
}
// Selection methods for bulk operations
/**
* Returns the number of selected documents.
*/
@computed
get selectedCount(): number {
return this.selectedIds.size;
}
/**
* 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);
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;
}
}
/**
* Toggles the selection of a document.
*
* @param id - the document id to toggle.
*/
@action
toggleSelection(id: string): void {
if (this.selectedIds.has(id)) {
this.deselect(id);
} else {
this.select(id);
}
}
/**
* Selects all documents from the given list.
*
* @param ids - array of document ids to select.
*/
@action
selectAll(ids: string[]): void {
ids.forEach((id) => this.selectedIds.add(id));
if (!this.isSelectionMode && ids.length > 0) {
this.isSelectionMode = true;
}
}
/**
* Enters selection mode without selecting any documents.
*/
@action
enterSelectionMode(): void {
this.isSelectionMode = true;
}
/**
* Clears all selections and exits selection mode.
*/
@action
clearSelection(): void {
this.selectedIds.clear();
this.isSelectionMode = false;
}
}
+36 -5
View File
@@ -97,8 +97,6 @@
"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 +181,15 @@
"currently viewing": "currently viewing",
"previously edited": "previously edited",
"You": "You",
"Archive {{ count }} documents": "Archive {{ count }} documents",
"Archive {{ count }} documents_plural": "Archive {{ count }} documents",
"Delete {{ count }} documents": "Delete {{ count }} documents",
"Delete {{ count }} documents_plural": "Delete {{ count }} documents",
"Move {{ count }} documents": "Move {{ count }} documents",
"Move {{ count }} documents_plural": "Move {{ count }} documents",
"{{ 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",
@@ -452,6 +459,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",
@@ -461,6 +469,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",
@@ -771,15 +780,37 @@
"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?",
"{{ count }} documents archived": "{{ count }} documents archived",
"{{ count }} documents archived_plural": "{{ count }} documents archived",
"{{ successCount }} archived, {{ errorCount }} failed": "{{ successCount }} archived, {{ errorCount }} failed",
"Are you sure you want to archive <em>{{ count }} documents</em>? They will be removed from collections and search results.": "Are you sure you want to archive <em>{{ count }} documents</em>? They will be removed from collections and search results.",
"Are you sure you want to archive <em>{{ count }} documents</em>? They will be removed from collections and search results._plural": "Are you sure you want to archive <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.",
"{{ successCount }} deleted, {{ errorCount }} failed": "{{ successCount }} deleted, {{ errorCount }} failed",
"{{ count }} documents deleted": "{{ count }} documents deleted",
"{{ count }} documents deleted_plural": "{{ count }} documents deleted",
"Document deleted.": "Document deleted.",
"Document archived.": "Document archived.",
"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",
"Document moved": "Document moved",
"{{ count }} documents moved": "{{ count }} documents moved",
"{{ count }} documents moved_plural": "{{ count }} documents moved",
"{{ successCount }} moved, {{ errorCount }} failed": "{{ successCount }} moved, {{ errorCount }} failed",
"Couldnt move the document, try again?": "Couldnt move the document, 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.",