diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 0541841af..ba9b0a3dd 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -581,15 +581,11 @@ export const moveDocument = createAction({ } stores.dialogs.openModal({ - title: t("Move {{ documentName }}", { - documentName: document.noun, + title: t("Move {{ documentType }}", { + documentType: document.noun, }), - content: ( - - ), + isCentered: true, + content: , }); } }, diff --git a/app/components/DocumentExplorer.tsx b/app/components/DocumentExplorer.tsx index ccfc6105c..c2df49ad4 100644 --- a/app/components/DocumentExplorer.tsx +++ b/app/components/DocumentExplorer.tsx @@ -1,5 +1,5 @@ import FuzzySearch from "fuzzy-search"; -import { includes, difference, concat, filter, flatten } from "lodash"; +import { includes, difference, concat, filter } from "lodash"; import { observer } from "mobx-react"; import { StarredIcon, DocumentIcon } from "outline-icons"; import * as React from "react"; @@ -18,11 +18,10 @@ import EmojiIcon from "~/components/Icons/EmojiIcon"; import { Outline } from "~/components/Input"; import InputSearch from "~/components/InputSearch"; import Text from "~/components/Text"; -import useCollectionTrees from "~/hooks/useCollectionTrees"; import useMobile from "~/hooks/useMobile"; import useStores from "~/hooks/useStores"; import { isModKey } from "~/utils/keyboard"; -import { flattenTree, ancestors, descendants } from "~/utils/tree"; +import { ancestors, descendants } from "~/utils/tree"; type Props = { /** Action taken upon submission of selected item, could be publish, move etc. */ @@ -30,14 +29,16 @@ type Props = { /** A side-effect of item selection */ onSelect: (item: NavigationNode | null) => void; + + /** Items to be shown in explorer */ + items: NavigationNode[]; }; -function DocumentExplorer({ onSubmit, onSelect }: Props) { +function DocumentExplorer({ onSubmit, onSelect, items }: Props) { const isMobile = useMobile(); const { collections, documents } = useStores(); const { t } = useTranslation(); const theme = useTheme(); - const collectionTrees = useCollectionTrees(); const [searchTerm, setSearchTerm] = React.useState(); const [selectedNode, selectNode] = React.useState( @@ -58,16 +59,11 @@ function DocumentExplorer({ onSubmit, onSelect }: Props) { const VERTICAL_PADDING = 6; const HORIZONTAL_PADDING = 24; - const allNodes = React.useMemo( - () => flatten(collectionTrees.map(flattenTree)), - [collectionTrees] - ); - const searchIndex = React.useMemo(() => { - return new FuzzySearch(allNodes, ["title"], { + return new FuzzySearch(items, ["title"], { caseSensitive: false, }); - }, [allNodes]); + }, [items]); React.useEffect(() => { if (searchTerm) { @@ -83,12 +79,12 @@ function DocumentExplorer({ onSubmit, onSelect }: Props) { if (searchTerm) { results = searchIndex.search(searchTerm); } else { - results = allNodes.filter((r) => r.type === "collection"); + results = items.filter((item) => item.type === "collection"); } setInitialScrollOffset(0); setNodes(results); - }, [searchTerm, allNodes, searchIndex]); + }, [searchTerm, items, searchIndex]); React.useEffect(() => { onSelect(selectedNode); @@ -148,7 +144,14 @@ function DocumentExplorer({ onSubmit, onSelect }: Props) { return selectedNodeId === nodeId; }; + const hasChildren = (node: number) => { + return nodes[node].children.length > 0; + }; + const toggleCollapse = (node: number) => { + if (!hasChildren(node)) { + return; + } if (isExpanded(node)) { collapse(node); } else { @@ -237,7 +240,7 @@ function DocumentExplorer({ onSubmit, onSelect }: Props) { icon={icon} title={title} depth={node.depth as number} - hasChildren={node.children.length > 0} + hasChildren={hasChildren(index)} /> ); }; diff --git a/app/components/PathToDocument.tsx b/app/components/PathToDocument.tsx deleted file mode 100644 index d35b1d149..000000000 --- a/app/components/PathToDocument.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { observer } from "mobx-react"; -import { GoToIcon } from "outline-icons"; -import * as React from "react"; -import styled from "styled-components"; -import { DocumentPath } from "~/stores/CollectionsStore"; -import Collection from "~/models/Collection"; -import Document from "~/models/Document"; -import Flex from "~/components/Flex"; -import CollectionIcon from "~/components/Icons/CollectionIcon"; - -type Props = { - result: DocumentPath; - document?: Document | null | undefined; - collection: Collection | null | undefined; - onSuccess?: () => void; - style?: React.CSSProperties; - ref?: (element: React.ElementRef<"div"> | null | undefined) => void; -}; - -@observer -class PathToDocument extends React.Component { - handleClick = async (ev: React.SyntheticEvent) => { - ev.preventDefault(); - const { document, result, onSuccess } = this.props; - if (!document) { - return; - } - - if (result.type === "document") { - await document.move(result.collectionId, result.id); - } else { - await document.move(result.collectionId); - } - - if (onSuccess) { - onSuccess(); - } - }; - - render() { - const { result, collection, document, ref, style } = this.props; - const Component = document ? ResultWrapperLink : ResultWrapper; - if (!result) { - return
; - } - - return ( - // @ts-expect-error ts-migrate(2604) FIXME: JSX element type 'Component' does not have any con... Remove this comment to see the full error message - - {collection && } -   - {result.path - .map((doc) => {doc.title}) - // @ts-expect-error ts-migrate(2739) FIXME: Type 'Element[]' is missing the following properti... Remove this comment to see the full error message - .reduce((prev, curr) => [prev, , curr])} - {document && ( - - {" "} - {document.title} - - )} - - ); - } -} - -const DocumentTitle = styled(Flex)``; - -const Title = styled.span` - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -`; - -const StyledGoToIcon = styled(GoToIcon)` - fill: ${(props) => props.theme.divider}; -`; - -const ResultWrapper = styled.div` - display: flex; - margin-bottom: 10px; - user-select: none; - - color: ${(props) => props.theme.text}; - cursor: default; - - svg { - flex-shrink: 0; - } -`; - -const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))` - padding: 8px 4px; - - ${DocumentTitle} { - display: none; - } - - svg { - flex-shrink: 0; - } - - &:hover, - &:active, - &:focus { - background: ${(props) => props.theme.listItemHoverBackground}; - outline: none; - - ${DocumentTitle} { - display: flex; - } - } -`; - -export default PathToDocument; diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx index 190956667..fd5b1d4d4 100644 --- a/app/scenes/Document/components/Document.tsx +++ b/app/scenes/Document/components/Document.tsx @@ -6,7 +6,6 @@ import * as React from "react"; import { WithTranslation, withTranslation } from "react-i18next"; import { Prompt, - Route, RouteComponentProps, StaticContext, withRouter, @@ -28,7 +27,6 @@ import ConnectionStatus from "~/components/ConnectionStatus"; import ErrorBoundary from "~/components/ErrorBoundary"; import Flex from "~/components/Flex"; import LoadingIndicator from "~/components/LoadingIndicator"; -import Modal from "~/components/Modal"; import PageTitle from "~/components/PageTitle"; import PlaceholderDocument from "~/components/PlaceholderDocument"; import RegisterKeyDown from "~/components/RegisterKeyDown"; @@ -39,7 +37,6 @@ import { replaceTitleVariables } from "~/utils/date"; import { emojiToUrl } from "~/utils/emoji"; import { isModKey } from "~/utils/keyboard"; import { - documentMoveUrl, documentHistoryUrl, editDocumentUrl, documentUrl, @@ -226,15 +223,15 @@ class DocumentScene extends React.Component { } }; - goToMove = (ev: KeyboardEvent) => { - if (!this.props.readOnly) { - return; - } + onMove = (ev: React.MouseEvent | KeyboardEvent) => { ev.preventDefault(); - const { document, abilities } = this.props; - + const { document, dialogs, t, abilities } = this.props; if (abilities.move) { - this.props.history.push(documentMoveUrl(document)); + dialogs.openModal({ + title: t("Move document"), + isCentered: true, + content: , + }); } }; @@ -476,7 +473,7 @@ class DocumentScene extends React.Component { }} /> )} - + @@ -502,21 +499,6 @@ class DocumentScene extends React.Component { column auto > - ( - - - - )} - /> void; }; -function DocumentMove({ document, onRequestClose }: Props) { - const [searchTerm, setSearchTerm] = useState(); - const { collections, documents } = useStores(); +function DocumentMove({ document }: Props) { + const { dialogs } = useStores(); const { showToast } = useToasts(); const { t } = useTranslation(); + const collectionTrees = useCollectionTrees(); + const [selectedPath, selectPath] = React.useState( + null + ); - const searchIndex = useMemo(() => { - const paths = collections.pathsToDocuments; - - // Build index - const indexeableDocuments: DocumentPath[] = []; - - paths.forEach((path) => { - const doc = documents.get(path.id); - - if (!doc || !doc.isTemplate) { - indexeableDocuments.push(path); - } - }); - - return new FuzzySearch(indexeableDocuments, ["title"], { - caseSensitive: false, - sort: true, - }); - }, [documents, collections.pathsToDocuments]); - - const results = useMemo(() => { - const onlyShowCollections = document.isTemplate; - let results: DocumentPath[] = []; - - if (collections.isLoaded) { - if (searchTerm) { - results = searchIndex.search(searchTerm); - } else { - results = searchIndex.haystack; - } - } - - if (onlyShowCollections) { - results = results.filter((result) => result.type === "collection"); - } else { - // Exclude document if on the path to result, or the same result - results = results.filter( - (result) => - !result.path.map((doc) => doc.id).includes(document.id) && - last(result.path.map((doc) => doc.id)) !== document.parentDocumentId - ); - } - - return results; - }, [document, collections, searchTerm, searchIndex]); - - const handleSuccess = () => { - showToast(t("Document moved"), { - type: "info", - }); - onRequestClose(); - }; - - const handleFilter = (ev: React.ChangeEvent) => { - setSearchTerm(ev.target.value); - }; - - const renderPathToCurrentDocument = () => { - const result = collections.getPathForDocument(document.id); - - if (result) { - return ( - - ); - } - - return null; - }; - - const row = ({ - index, - data, - style, - }: { - index: number; - data: DocumentPath[]; - style: React.CSSProperties; - }) => { - const result = data[index]; - return ( - + const moveOptions = React.useMemo(() => { + // filter out the document itself and also its parent doc if any + const nodes = flatten(collectionTrees.map(flattenTree)).filter( + (node) => node.id !== document.id && node.id !== document.parentDocumentId ); + if (document.isTemplate) { + // only show collections with children stripped off to prevent node expansion + return nodes + .filter((node) => node.type === "collection") + .map((node) => ({ ...node, children: [] })); + } + return nodes; + }, [ + collectionTrees, + document.id, + document.parentDocumentId, + document.isTemplate, + ]); + + const move = async () => { + if (!selectedPath) { + showToast(t("Select a location to move"), { + type: "info", + }); + return; + } + + 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); + } + + showToast(t("Document moved"), { + type: "success", + }); + + dialogs.closeAllModals(); + } catch (err) { + showToast(t("Couldn’t move the document, try again?"), { + type: "error", + }); + } }; - const data = results; - - if (!document || !collections.isLoaded) { - return null; - } - return ( - -
- - {renderPathToCurrentDocument()} - -
- -
- - - - - {({ width, height }: { width: number; height: number }) => ( - - data[index].id} - > - {row} - - - )} - - -
-
+ + +
+ + {selectedPath ? ( + , + }} + /> + ) : ( + t("Select a location to move") + )} + + +
+
); } -const Input = styled(InputSearch)` - ${Outline} { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - padding: 4px 0; - } +const FlexContainer = styled(Flex)` + margin-left: -24px; + margin-right: -24px; + margin-bottom: -24px; + outline: none; `; -const Results = styled.div` - padding: 0; - height: 40vh; - border: 1px solid ${(props) => props.theme.inputBorder}; - border-top: 0; - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; +const Footer = styled(Flex)` + height: 64px; + border-top: 1px solid ${(props) => props.theme.horizontalRule}; + padding-left: 24px; + padding-right: 24px; `; -const Section = styled(Flex)` - margin-bottom: 24px; +const StyledText = styled(Text)` + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 0; `; export default observer(DocumentMove); diff --git a/app/scenes/DocumentPublish.tsx b/app/scenes/DocumentPublish.tsx index fbdfa6de8..593e80837 100644 --- a/app/scenes/DocumentPublish.tsx +++ b/app/scenes/DocumentPublish.tsx @@ -1,3 +1,4 @@ +import { flatten } from "lodash"; import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; @@ -8,8 +9,10 @@ import Button from "~/components/Button"; import DocumentExplorer from "~/components/DocumentExplorer"; import Flex from "~/components/Flex"; import Text from "~/components/Text"; +import useCollectionTrees from "~/hooks/useCollectionTrees"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; +import { flattenTree } from "~/utils/tree"; type Props = { /** Document to publish */ @@ -20,10 +23,14 @@ function DocumentPublish({ document }: Props) { const { dialogs } = useStores(); const { showToast } = useToasts(); const { t } = useTranslation(); - + const collectionTrees = useCollectionTrees(); const [selectedPath, selectPath] = React.useState( null ); + const publishOptions = React.useMemo( + () => flatten(collectionTrees.map(flattenTree)), + [collectionTrees] + ); const publish = async () => { if (!selectedPath) { @@ -60,7 +67,11 @@ function DocumentPublish({ document }: Props) { return ( - +