diff --git a/app/components/DropToImport.js b/app/components/DropToImport.js deleted file mode 100644 index 5442ffb7da..0000000000 --- a/app/components/DropToImport.js +++ /dev/null @@ -1,123 +0,0 @@ -// @flow -import invariant from "invariant"; -import { observable } from "mobx"; -import { observer, inject } from "mobx-react"; -import * as React from "react"; -import Dropzone from "react-dropzone"; -import { withRouter, type RouterHistory, type Match } from "react-router-dom"; -import styled, { css } from "styled-components"; -import DocumentsStore from "stores/DocumentsStore"; -import UiStore from "stores/UiStore"; -import LoadingIndicator from "components/LoadingIndicator"; - -const EMPTY_OBJECT = {}; -let importingLock = false; - -type Props = { - children: React.Node, - collectionId: string, - documentId?: string, - ui: UiStore, - documents: DocumentsStore, - disabled: boolean, - location: Object, - match: Match, - history: RouterHistory, - staticContext: Object, -}; - -@observer -class DropToImport extends React.Component { - @observable isImporting: boolean = false; - - onDropAccepted = async (files = []) => { - if (importingLock) return; - - this.isImporting = true; - importingLock = true; - - try { - let collectionId = this.props.collectionId; - const documentId = this.props.documentId; - const redirect = files.length === 1; - - if (documentId && !collectionId) { - const document = await this.props.documents.fetch(documentId); - invariant(document, "Document not available"); - collectionId = document.collectionId; - } - - for (const file of files) { - const doc = await this.props.documents.import( - file, - documentId, - collectionId, - { publish: true } - ); - - if (redirect) { - this.props.history.push(doc.url); - } - } - } catch (err) { - this.props.ui.showToast(`Could not import file. ${err.message}`, { - type: "error", - }); - } finally { - this.isImporting = false; - importingLock = false; - } - }; - - render() { - const { documents } = this.props; - - if (this.props.disabled) return this.props.children; - - return ( - - {({ - getRootProps, - getInputProps, - isDragActive, - isDragAccept, - isDragReject, - }) => ( - - - {this.isImporting && } - {this.props.children} - - )} - - ); - } -} - -const DropzoneContainer = styled("div")` - border-radius: 4px; - - ${({ isDragActive, theme }) => - isDragActive && - css` - background: ${theme.slateDark}; - a { - color: ${theme.white} !important; - } - svg { - fill: ${theme.white}; - } - `} -`; - -export default inject("documents", "ui")(withRouter(DropToImport)); diff --git a/app/components/Mask.js b/app/components/Mask.js index eeeabe865d..96776bfcce 100644 --- a/app/components/Mask.js +++ b/app/components/Mask.js @@ -23,7 +23,7 @@ class Mask extends React.Component { } render() { - return ; + return ; } } diff --git a/app/components/Scene.js b/app/components/Scene.js index eaf31732bb..6c2c5247de 100644 --- a/app/components/Scene.js +++ b/app/components/Scene.js @@ -12,6 +12,7 @@ type Props = {| children: React.Node, breadcrumb?: React.Node, actions?: React.Node, + centered?: boolean, |}; function Scene({ @@ -21,6 +22,7 @@ function Scene({ actions, breadcrumb, children, + centered, }: Props) { return ( @@ -38,7 +40,11 @@ function Scene({ actions={actions} breadcrumb={breadcrumb} /> - {children} + {centered !== false ? ( + {children} + ) : ( + children + )} ); } diff --git a/app/components/Sidebar/components/CollectionLink.js b/app/components/Sidebar/components/CollectionLink.js index e5db821345..5366fcd512 100644 --- a/app/components/Sidebar/components/CollectionLink.js +++ b/app/components/Sidebar/components/CollectionLink.js @@ -7,9 +7,9 @@ import styled from "styled-components"; import Collection from "models/Collection"; import Document from "models/Document"; import CollectionIcon from "components/CollectionIcon"; -import DropToImport from "components/DropToImport"; import DocumentLink from "./DocumentLink"; import DropCursor from "./DropCursor"; +import DropToImport from "./DropToImport"; import EditableTitle from "./EditableTitle"; import SidebarLink from "./SidebarLink"; import useStores from "hooks/useStores"; diff --git a/app/components/Sidebar/components/Collections.js b/app/components/Sidebar/components/Collections.js index 8567c7ea07..bd55a1e15b 100644 --- a/app/components/Sidebar/components/Collections.js +++ b/app/components/Sidebar/components/Collections.js @@ -18,6 +18,7 @@ type Props = { }; function Collections({ onCreateCollection }: Props) { + const [isFetching, setFetching] = React.useState(false); const { ui, policies, documents, collections } = useStores(); const isPreloaded: boolean = !!collections.orderedData.length; const { t } = useTranslation(); @@ -27,10 +28,18 @@ function Collections({ onCreateCollection }: Props) { ); React.useEffect(() => { - if (!collections.isLoaded) { - collections.fetchPage({ limit: 100 }); + async function load() { + if (!collections.isLoaded && !isFetching) { + try { + setFetching(true); + await collections.fetchPage({ limit: 100 }); + } finally { + setFetching(false); + } + } } - }); + load(); + }, [collections, isFetching]); const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({ accept: "collection", diff --git a/app/components/Sidebar/components/DocumentLink.js b/app/components/Sidebar/components/DocumentLink.js index 02296e5dae..2618f72590 100644 --- a/app/components/Sidebar/components/DocumentLink.js +++ b/app/components/Sidebar/components/DocumentLink.js @@ -7,9 +7,9 @@ import { useTranslation } from "react-i18next"; import styled from "styled-components"; import Collection from "models/Collection"; import Document from "models/Document"; -import DropToImport from "components/DropToImport"; import Fade from "components/Fade"; import DropCursor from "./DropCursor"; +import DropToImport from "./DropToImport"; import EditableTitle from "./EditableTitle"; import SidebarLink from "./SidebarLink"; import useStores from "hooks/useStores"; diff --git a/app/components/Sidebar/components/DropToImport.js b/app/components/Sidebar/components/DropToImport.js new file mode 100644 index 0000000000..0521abdfa0 --- /dev/null +++ b/app/components/Sidebar/components/DropToImport.js @@ -0,0 +1,83 @@ +// @flow +import { observer } from "mobx-react"; +import * as React from "react"; +import Dropzone from "react-dropzone"; +import { useTranslation } from "react-i18next"; +import styled, { css } from "styled-components"; +import LoadingIndicator from "components/LoadingIndicator"; +import useImportDocument from "hooks/useImportDocument"; +import useStores from "hooks/useStores"; + +type Props = {| + children: React.Node, + collectionId: string, + documentId?: string, + disabled: boolean, + staticContext: Object, +|}; + +function DropToImport({ disabled, children, collectionId, documentId }: Props) { + const { t } = useTranslation(); + const { ui, documents } = useStores(); + const { handleFiles, isImporting } = useImportDocument( + collectionId, + documentId + ); + + const handleRejection = React.useCallback(() => { + ui.showToast( + t("Document not supported – try Markdown, Plain text, HTML, or Word"), + { type: "error" } + ); + }, [t, ui]); + + if (disabled) { + return children; + } + + return ( + + {({ + getRootProps, + getInputProps, + isDragActive, + isDragAccept, + isDragReject, + }) => ( + + + {isImporting && } + {children} + + )} + + ); +} + +const DropzoneContainer = styled.div` + border-radius: 4px; + + ${({ isDragActive, theme }) => + isDragActive && + css` + background: ${theme.slateDark}; + a { + color: ${theme.white} !important; + } + svg { + fill: ${theme.white}; + } + `} +`; + +export default observer(DropToImport); diff --git a/app/hooks/useImportDocument.js b/app/hooks/useImportDocument.js new file mode 100644 index 0000000000..6f1fd91e5f --- /dev/null +++ b/app/hooks/useImportDocument.js @@ -0,0 +1,69 @@ +// @flow +import invariant from "invariant"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useHistory } from "react-router-dom"; +import useStores from "hooks/useStores"; + +let importingLock = false; + +export default function useImportDocument( + collectionId: string, + documentId?: string +): {| handleFiles: (files: File[]) => Promise, isImporting: boolean |} { + const { documents, ui } = useStores(); + const [isImporting, setImporting] = React.useState(false); + const { t } = useTranslation(); + const history = useHistory(); + + const handleFiles = React.useCallback( + async (files = []) => { + if (importingLock) { + return; + } + + // Because this is the onChange handler it's possible for the change to be + // from previously selecting a file to not selecting a file – aka empty + if (!files.length) { + return; + } + + setImporting(true); + importingLock = true; + + try { + let cId = collectionId; + const redirect = files.length === 1; + + if (documentId && !collectionId) { + const document = await documents.fetch(documentId); + invariant(document, "Document not available"); + cId = document.collectionId; + } + + for (const file of files) { + const doc = await documents.import(file, documentId, cId, { + publish: true, + }); + + if (redirect) { + history.push(doc.url); + } + } + } catch (err) { + ui.showToast(`${t("Could not import file")}. ${err.message}`, { + type: "error", + }); + } finally { + setImporting(false); + importingLock = false; + } + }, + [t, ui, documents, history, collectionId, documentId] + ); + + return { + handleFiles, + isImporting, + }; +} diff --git a/app/scenes/Collection.js b/app/scenes/Collection.js index f15b217e90..850346449c 100644 --- a/app/scenes/Collection.js +++ b/app/scenes/Collection.js @@ -1,17 +1,11 @@ // @flow -import { observable } from "mobx"; -import { observer, inject } from "mobx-react"; +import { observer } from "mobx-react"; import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons"; import * as React from "react"; -import { withTranslation, Trans, type TFunction } from "react-i18next"; -import { Redirect, Link, Switch, Route, type Match } from "react-router-dom"; -import styled from "styled-components"; - -import CollectionsStore from "stores/CollectionsStore"; -import DocumentsStore from "stores/DocumentsStore"; -import PoliciesStore from "stores/PoliciesStore"; -import UiStore from "stores/UiStore"; -import Collection from "models/Collection"; +import Dropzone from "react-dropzone"; +import { useTranslation, Trans } from "react-i18next"; +import { useParams, Redirect, Link, Switch, Route } from "react-router-dom"; +import styled, { css } from "styled-components"; import CollectionPermissions from "scenes/CollectionPermissions"; import Search from "scenes/Search"; import { Action, Separator } from "components/Actions"; @@ -25,6 +19,7 @@ import Flex from "components/Flex"; import Heading from "components/Heading"; import HelpText from "components/HelpText"; import InputSearch from "components/InputSearch"; +import LoadingIndicator from "components/LoadingIndicator"; import { ListPlaceholder } from "components/LoadingPlaceholder"; import Mask from "components/Mask"; import Modal from "components/Modal"; @@ -34,315 +29,371 @@ import Subheading from "components/Subheading"; import Tab from "components/Tab"; import Tabs from "components/Tabs"; import Tooltip from "components/Tooltip"; +import useImportDocument from "hooks/useImportDocument"; +import useStores from "hooks/useStores"; +import useUnmount from "hooks/useUnmount"; import CollectionMenu from "menus/CollectionMenu"; -import { AuthorizationError } from "utils/errors"; import { newDocumentUrl, collectionUrl } from "utils/routeHelpers"; -type Props = { - ui: UiStore, - documents: DocumentsStore, - collections: CollectionsStore, - policies: PoliciesStore, - match: Match, - t: TFunction, -}; +function CollectionScene() { + const params = useParams(); + const { t } = useTranslation(); + const { documents, policies, collections, ui } = useStores(); + const [isFetching, setFetching] = React.useState(); + const [error, setError] = React.useState(); + const [permissionsModalOpen, setPermissionsModalOpen] = React.useState(false); -@observer -class CollectionScene extends React.Component { - @observable collection: ?Collection; - @observable isFetching: boolean = true; - @observable permissionsModalOpen: boolean = false; + const collectionId = params.id || ""; + const collection = collections.get(collectionId); + const can = policies.abilities(collectionId || ""); + const { handleFiles, isImporting } = useImportDocument(collectionId); - componentDidMount() { - const { id } = this.props.match.params; - if (id) { - this.loadContent(id); + React.useEffect(() => { + if (collection) { + ui.setActiveCollection(collection); } - } + }, [ui, collection]); - componentDidUpdate(prevProps: Props) { - const { id } = this.props.match.params; + React.useEffect(() => { + setError(null); + documents.fetchPinned({ collectionId }); + }, [documents, collectionId]); - if (this.collection) { - const { collection } = this; - const policy = this.props.policies.get(collection.id); - - if (!policy) { - this.loadContent(collection.id); - } - } - - if (id && id !== prevProps.match.params.id) { - this.loadContent(id); - } - } - - componentWillUnmount() { - this.props.ui.clearActiveCollection(); - } - - loadContent = async (id: string) => { - try { - const collection = await this.props.collections.fetch(id); - - if (collection) { - this.props.ui.setActiveCollection(collection); - this.collection = collection; - - await this.props.documents.fetchPinned({ - collectionId: id, - }); - } - } catch (error) { - if (error instanceof AuthorizationError) { - this.collection = null; - } - } finally { - this.isFetching = false; - } - }; - - onPermissions = (ev: SyntheticEvent<>) => { - ev.preventDefault(); - this.permissionsModalOpen = true; - }; - - handlePermissionsModalClose = () => { - this.permissionsModalOpen = false; - }; - - renderActions() { - const { match, policies, t } = this.props; - const can = policies.abilities(match.params.id || ""); - - return ( - <> - {can.update && ( - <> - - - - - - - - - - - )} - - ( - - -    - - - - - - - ) : ( - <> - - {" "} - {collection.name}{" "} - {!collection.permission && ( + } + } + load(); + }, [collections, isFetching, collection, error, collectionId, can]); + + useUnmount(ui.clearActiveCollection); + + const handlePermissionsModalOpen = React.useCallback(() => { + setPermissionsModalOpen(true); + }, []); + + const handlePermissionsModalClose = React.useCallback(() => { + setPermissionsModalOpen(false); + }, []); + + const handleRejection = React.useCallback(() => { + ui.showToast( + t("Document not supported – try Markdown, Plain text, HTML, or Word"), + { type: "error" } + ); + }, [t, ui]); + + if (!collection && error) { + return ; + } + + const pinnedDocuments = collection + ? documents.pinnedInCollection(collection.id) + : []; + const collectionName = collection ? collection.name : ""; + const hasPinnedDocuments = !!pinnedDocuments.length; + + return collection ? ( + + +   + {collection.name} + + } + actions={ + <> + {can.update && ( + <> + + + + - {t("Private")} + + + + + )} + + ( + + +    + + + + + + + ) : ( + <> + + {" "} + {collection.name}{" "} + {!collection.permission && ( + + {t("Private")} + + )} + + - - - {t("Documents")} - - - {t("Recently updated")} - - - {t("Recently published")} - - - {t("Least recently updated")} - - - {t("A–Z")} - - - - - - - - + + {" "} + {t("Pinned")} + + + )} - fetch={documents.fetchLeastRecentlyUpdated} - options={{ collectionId: collection.id }} - showPin - /> - - - - - - - - - - - - - - - + + + + {t("Documents")} + + + {t("Recently updated")} + + + {t("Recently published")} + + + {t("Least recently updated")} + + + {t("A–Z")} + + + + + + + + + + + + + + + + + + + + + + + + )} + {t("Drop documents to import")} + + )} - - ) : ( - - - - - - - ); - } + + + ) : ( + + + + + + + ); } +const DropMessage = styled(HelpText)` + opacity: 0; + pointer-events: none; +`; + +const DropzoneContainer = styled.div` + min-height: calc(100% - 56px); + position: relative; + + ${({ isDragActive, theme }) => + isDragActive && + css` + &:after { + display: block; + content: ""; + position: absolute; + top: 24px; + right: 24px; + bottom: 24px; + left: 24px; + background: ${theme.background}; + border-radius: 8px; + border: 1px dashed ${theme.divider}; + z-index: 1; + } + + ${DropMessage} { + opacity: 1; + z-index: 2; + position: absolute; + text-align: center; + top: 50%; + left: 50%; + transform: translateX(-50%); + } + `} +`; + const Centered = styled(Flex)` text-align: center; margin: 40vh auto 0; @@ -361,6 +412,4 @@ const Empty = styled(Flex)` margin: 10px 0; `; -export default withTranslation()( - inject("collections", "policies", "documents", "ui")(CollectionScene) -); +export default observer(CollectionScene); diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 9827947d74..499252549b 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -98,6 +98,7 @@ "New collection": "New collection", "Collections": "Collections", "Untitled": "Untitled", + "Document not supported – try Markdown, Plain text, HTML, or Word": "Document not supported – try Markdown, Plain text, HTML, or Word", "Home": "Home", "Starred": "Starred", "Settings": "Settings", @@ -120,6 +121,7 @@ "Installation": "Installation", "Unstar": "Unstar", "Star": "Star", + "Could not import file": "Could not import file", "Appearance": "Appearance", "System": "System", "Light": "Light", @@ -212,6 +214,7 @@ "Recently published": "Recently published", "Least recently updated": "Least recently updated", "A–Z": "A–Z", + "Drop documents to import": "Drop documents to import", "The collection was updated": "The collection was updated", "You can edit the name and other details at any time, however doing so often might confuse your team mates.": "You can edit the name and other details at any time, however doing so often might confuse your team mates.", "Name": "Name",