mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
dd2e8f258d
* Initial plan * Support double-click submit in document explorer * Remove test * Fix double-click submit in document explorer Single click now sets the selection instead of toggling it, so the two clicks preceding a dblclick no longer flicker the selection on/off. Submit handlers accept the node directly to avoid the stale-state race across the click sequence, and button onClick handlers are wrapped so the synthetic MouseEvent isn't passed in as the path argument. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * PR feedback --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Tom Moor <tom@getoutline.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
115 lines
3.7 KiB
TypeScript
115 lines
3.7 KiB
TypeScript
import { observer } from "mobx-react";
|
||
import { useState, useMemo } from "react";
|
||
import { useTranslation, Trans } from "react-i18next";
|
||
import { toast } from "sonner";
|
||
import type { NavigationNode } from "@shared/types";
|
||
import { descendants, flattenTree } from "@shared/utils/tree";
|
||
import type Document from "~/models/Document";
|
||
import Button from "~/components/Button";
|
||
import Text from "~/components/Text";
|
||
import useCollectionTrees from "~/hooks/useCollectionTrees";
|
||
import useStores from "~/hooks/useStores";
|
||
import { FlexContainer, Footer } from "./Components";
|
||
import DocumentExplorer from "./DocumentExplorer";
|
||
|
||
type Props = {
|
||
document: Document;
|
||
};
|
||
|
||
function DocumentMove({ document }: Props) {
|
||
const { dialogs, policies } = useStores();
|
||
const { t } = useTranslation();
|
||
const collectionTrees = useCollectionTrees();
|
||
const [moving, setMoving] = useState<boolean>(false);
|
||
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
|
||
|
||
const items = useMemo(() => {
|
||
// Collect the IDs of the document itself and all of its descendants so they
|
||
// can be excluded from the move targets (moving to self or a descendant
|
||
// would create a cycle; moving to the exact same location is a no-op).
|
||
const allNodes = collectionTrees.flatMap(flattenTree);
|
||
const sourceNode = allNodes.find((node) => node.id === document.id);
|
||
const excludedIds = new Set<string>([document.id]);
|
||
if (sourceNode) {
|
||
descendants(sourceNode).forEach((n) => excludedIds.add(n.id));
|
||
}
|
||
|
||
// Recursively filter out the document itself and its descendants.
|
||
// The document's current parent is intentionally kept so that siblings
|
||
// remain visible as valid move targets.
|
||
const filterSourceDocument = (node: NavigationNode): NavigationNode => ({
|
||
...node,
|
||
children: node.children
|
||
?.filter((c) => !excludedIds.has(c.id))
|
||
.map(filterSourceDocument),
|
||
});
|
||
|
||
const nodes = collectionTrees
|
||
.map(filterSourceDocument)
|
||
// Filter out collections that we don't have permission to create documents in.
|
||
.filter((node) =>
|
||
node.collectionId
|
||
? policies.get(node.collectionId)?.abilities.createDocument
|
||
: true
|
||
);
|
||
|
||
return nodes;
|
||
}, [policies, collectionTrees, document.id]);
|
||
|
||
const move = async (path = selectedPath) => {
|
||
if (!path) {
|
||
toast.message(t("Select a location to move"));
|
||
return;
|
||
}
|
||
|
||
try {
|
||
setMoving(true);
|
||
const { type, id: parentDocumentId } = path;
|
||
|
||
const collectionId = path.collectionId as string;
|
||
|
||
if (type === "document") {
|
||
await document.move({ collectionId, parentDocumentId });
|
||
} else {
|
||
await document.move({ collectionId });
|
||
}
|
||
|
||
toast.success(t("Document moved"));
|
||
|
||
dialogs.closeAllModals();
|
||
} catch (_err) {
|
||
toast.error(t("Couldn’t move the document, try again?"));
|
||
} finally {
|
||
setMoving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<FlexContainer column>
|
||
<DocumentExplorer items={items} onSubmit={move} onSelect={selectPath} />
|
||
<Footer justify="space-between" align="center" gap={8}>
|
||
<Text ellipsis type="secondary">
|
||
{selectedPath ? (
|
||
<Trans
|
||
defaults="Move to <em>{{ location }}</em>"
|
||
values={{
|
||
location: selectedPath.title || t("Untitled"),
|
||
}}
|
||
components={{
|
||
em: <strong />,
|
||
}}
|
||
/>
|
||
) : (
|
||
t("Select a location to move")
|
||
)}
|
||
</Text>
|
||
<Button disabled={!selectedPath || moving} onClick={() => move()}>
|
||
{moving ? `${t("Moving")}…` : t("Move")}
|
||
</Button>
|
||
</Footer>
|
||
</FlexContainer>
|
||
);
|
||
}
|
||
|
||
export default observer(DocumentMove);
|