mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b9275dff0 | |||
| 39f0f78ff4 | |||
| b2a0a9cf21 | |||
| c0ee1aa3d7 | |||
| b9e34e4227 | |||
| b405e1e985 | |||
| e0e6b3f3db | |||
| b2b0bd8c8f | |||
| 1a8d75b81b | |||
| 6c3816e07c | |||
| d264848024 | |||
| 65a3d1ac47 | |||
| af98549ca7 | |||
| ce1d2a90c0 |
@@ -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,
|
||||
];
|
||||
@@ -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]} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
@@ -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: ${
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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
@@ -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("Couldn’t 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("Couldn’t 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 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>
|
||||
|
||||
+103
-38
@@ -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("Couldn’t move the document, try again?"));
|
||||
}
|
||||
|
||||
if (errorCount === documents.length) {
|
||||
toast.error(
|
||||
t("Couldn’t 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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
"Couldn’t delete the {{noun}}, try again?": "Couldn’t 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",
|
||||
"Couldn’t archive the {{noun}}, try again?": "Couldn’t 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 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",
|
||||
"Couldn’t move the {{noun}}, try again?": "Couldn’t move the {{noun}}, try again?",
|
||||
"Document moved": "Document moved",
|
||||
"Couldn’t move the document, try again?": "Couldn’t 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",
|
||||
"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