mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf4791ce55 | |||
| 4ad3620ec9 | |||
| 101a3f5137 | |||
| 4704404105 | |||
| 3bfc8747dd | |||
| 15b51290b5 |
@@ -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]} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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]} />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 you’d like the option of referencing or restoring these documents in
|
||||
the future, consider archiving them instead.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
If you’d 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 you’d 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
@@ -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("Couldn’t 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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If you’d 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 you’d like the option of referencing or restoring these documents in the future, consider archiving them instead.": "If you’d like the option of referencing or restoring these documents in the future, consider archiving them instead.",
|
||||
"If you’d like the option of referencing or restoring the document in the future, consider archiving it instead.": "If you’d 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",
|
||||
"Couldn’t move the document, try again?": "Couldn’t 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",
|
||||
"Couldn’t create the document, try again?": "Couldn’t 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.",
|
||||
|
||||
Reference in New Issue
Block a user