diff --git a/app/components/Arrow.js b/app/components/Arrow.js new file mode 100644 index 00000000..2b6ade16 --- /dev/null +++ b/app/components/Arrow.js @@ -0,0 +1,23 @@ +// @flow +import * as React from "react"; + +export default function Arrow() { + return ( + + + + + ); +} diff --git a/app/components/CollectionDescription.js b/app/components/CollectionDescription.js new file mode 100644 index 00000000..553503df --- /dev/null +++ b/app/components/CollectionDescription.js @@ -0,0 +1,212 @@ +// @flow +import { observer } from "mobx-react"; +import { transparentize } from "polished"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import Collection from "models/Collection"; +import Arrow from "components/Arrow"; +import ButtonLink from "components/ButtonLink"; +import Editor from "components/Editor"; +import LoadingIndicator from "components/LoadingIndicator"; +import NudeButton from "components/NudeButton"; +import useDebouncedCallback from "hooks/useDebouncedCallback"; +import useStores from "hooks/useStores"; + +type Props = {| + collection: Collection, +|}; + +function CollectionDescription({ collection }: Props) { + const { collections, ui, policies } = useStores(); + const { t } = useTranslation(); + const [isExpanded, setExpanded] = React.useState(false); + const [isEditing, setEditing] = React.useState(false); + const [isDirty, setDirty] = React.useState(false); + const can = policies.abilities(collection.id); + + const handleStartEditing = React.useCallback(() => { + setEditing(true); + }, []); + + const handleStopEditing = React.useCallback(() => { + setEditing(false); + }, []); + + const handleClickDisclosure = React.useCallback( + (event) => { + event.preventDefault(); + + if (isExpanded && document.activeElement) { + document.activeElement.blur(); + } + + setExpanded(!isExpanded); + }, + [isExpanded] + ); + + const handleSave = useDebouncedCallback(async (getValue) => { + try { + await collection.save({ + description: getValue(), + }); + setDirty(false); + } catch (err) { + ui.showToast( + t("Sorry, an error occurred saving the collection", { + type: "error", + }) + ); + throw err; + } + }, 1000); + + const handleChange = React.useCallback( + (getValue) => { + setDirty(true); + handleSave(getValue); + }, + [handleSave] + ); + + React.useEffect(() => { + setEditing(false); + }, [collection.id]); + + const placeholder = `${t("Add a description")}…`; + const key = isEditing || isDirty ? "draft" : collection.updatedAt; + + return ( + + + + {collections.isSaving && } + {collection.hasDescription || isEditing || isDirty ? ( + Loading…}> + + + ) : ( + can.update && {placeholder} + )} + + + {!isEditing && ( + + + + )} + + ); +} + +const Disclosure = styled(NudeButton)` + opacity: 0; + color: ${(props) => props.theme.divider}; + position: absolute; + top: calc(25vh - 50px); + left: 50%; + z-index: 1; + transform: rotate(-90deg) translateX(-50%); + transition: opacity 100ms ease-in-out; + + &:focus, + &:hover { + opacity: 1; + } + + &:active { + color: ${(props) => props.theme.sidebarText}; + } +`; + +const Placeholder = styled(ButtonLink)` + color: ${(props) => props.theme.placeholder}; + cursor: text; + min-height: 27px; +`; + +const MaxHeight = styled.div` + position: relative; + max-height: 25vh; + overflow: hidden; + margin: -8px; + padding: 8px; + + &[data-editing="true"], + &[data-expanded="true"] { + max-height: initial; + overflow: initial; + + ${Disclosure} { + top: initial; + bottom: 0; + transform: rotate(90deg) translateX(-50%); + } + } + + &:hover ${Disclosure} { + opacity: 1; + } +`; + +const Input = styled.div` + margin: -8px; + padding: 8px; + border-radius: 8px; + transition: ${(props) => props.theme.backgroundTransition}; + + &:after { + content: ""; + position: absolute; + top: calc(25vh - 50px); + left: 0; + right: 0; + height: 50px; + pointer-events: none; + background: linear-gradient( + 180deg, + ${(props) => transparentize(1, props.theme.background)} 0%, + ${(props) => props.theme.background} 100% + ); + } + + &[data-editing="true"], + &[data-expanded="true"] { + &:after { + background: transparent; + } + } + + &[data-editing="true"] { + background: ${(props) => props.theme.secondaryBackground}; + } + + .block-menu-trigger, + .heading-anchor { + display: none !important; + } +`; + +export default observer(CollectionDescription); diff --git a/app/components/Editor.js b/app/components/Editor.js index 103d8777..37d01c5f 100644 --- a/app/components/Editor.js +++ b/app/components/Editor.js @@ -27,13 +27,16 @@ export type Props = {| autoFocus?: boolean, template?: boolean, placeholder?: string, + maxLength?: number, scrollTo?: string, + handleDOMEvents?: Object, readOnlyWriteCheckboxes?: boolean, onBlur?: (event: SyntheticEvent<>) => any, onFocus?: (event: SyntheticEvent<>) => any, onPublish?: (event: SyntheticEvent<>) => any, onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any, onCancel?: () => any, + onDoubleClick?: () => any, onChange?: (getValue: () => string) => any, onSearchLink?: (title: string) => any, onHoverLink?: (event: MouseEvent) => any, @@ -177,7 +180,7 @@ const StyledEditor = styled(RichMarkdownEditor)` justify-content: start; > div { - transition: ${(props) => props.theme.backgroundTransition}; + background: transparent; } & * { diff --git a/app/components/Sidebar/components/Toggle.js b/app/components/Sidebar/components/Toggle.js index 4533c520..01a68791 100644 --- a/app/components/Sidebar/components/Toggle.js +++ b/app/components/Sidebar/components/Toggle.js @@ -2,6 +2,7 @@ import * as React from "react"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; +import Arrow from "components/Arrow"; type Props = { direction: "left" | "right", @@ -14,22 +15,7 @@ const Toggle = React.forwardRef( return ( - - - - + ); diff --git a/app/components/Toasts/components/Toast.js b/app/components/Toast.js similarity index 100% rename from app/components/Toasts/components/Toast.js rename to app/components/Toast.js diff --git a/app/components/Toasts/Toasts.js b/app/components/Toasts.js similarity index 94% rename from app/components/Toasts/Toasts.js rename to app/components/Toasts.js index c82bedea..df2502fd 100644 --- a/app/components/Toasts/Toasts.js +++ b/app/components/Toasts.js @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; import * as React from "react"; import styled from "styled-components"; -import Toast from "./components/Toast"; +import Toast from "components/Toast"; import useStores from "hooks/useStores"; function Toasts() { diff --git a/app/components/Toasts/index.js b/app/components/Toasts/index.js deleted file mode 100644 index 13373bf8..00000000 --- a/app/components/Toasts/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import Toasts from "./Toasts"; -export default Toasts; diff --git a/app/hooks/useDebouncedCallback.js b/app/hooks/useDebouncedCallback.js new file mode 100644 index 00000000..9ba68d32 --- /dev/null +++ b/app/hooks/useDebouncedCallback.js @@ -0,0 +1,31 @@ +// @flow +import * as React from "react"; + +export default function useDebouncedCallback( + callback: (any) => mixed, + wait: number +) { + // track args & timeout handle between calls + const argsRef = React.useRef(); + const timeout = React.useRef(); + + function cleanup() { + if (timeout.current) { + clearTimeout(timeout.current); + } + } + + // make sure our timeout gets cleared if consuming component gets unmounted + React.useEffect(() => cleanup, []); + + return function (...args: any) { + argsRef.current = args; + cleanup(); + + timeout.current = setTimeout(() => { + if (argsRef.current) { + callback(...argsRef.current); + } + }, wait); + }; +} diff --git a/app/scenes/Collection.js b/app/scenes/Collection.js index c638db1f..7dc729a6 100644 --- a/app/scenes/Collection.js +++ b/app/scenes/Collection.js @@ -1,12 +1,11 @@ // @flow import { observable } from "mobx"; import { observer, inject } 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, { withTheme } from "styled-components"; +import styled from "styled-components"; import CollectionsStore from "stores/CollectionsStore"; import DocumentsStore from "stores/DocumentsStore"; @@ -20,9 +19,9 @@ import Search from "scenes/Search"; import Actions, { Action, Separator } from "components/Actions"; import Button from "components/Button"; import CenteredContent from "components/CenteredContent"; +import CollectionDescription from "components/CollectionDescription"; import CollectionIcon from "components/CollectionIcon"; import DocumentList from "components/DocumentList"; -import Editor from "components/Editor"; import Flex from "components/Flex"; import Heading from "components/Heading"; import HelpText from "components/HelpText"; @@ -37,7 +36,6 @@ import Tab from "components/Tab"; import Tabs from "components/Tabs"; import Tooltip from "components/Tooltip"; import CollectionMenu from "menus/CollectionMenu"; -import { type Theme } from "types"; import { AuthorizationError } from "utils/errors"; import { newDocumentUrl, collectionUrl } from "utils/routeHelpers"; @@ -47,7 +45,6 @@ type Props = { collections: CollectionsStore, policies: PoliciesStore, match: Match, - theme: Theme, t: TFunction, }; @@ -57,7 +54,6 @@ class CollectionScene extends React.Component { @observable isFetching: boolean = true; @observable permissionsModalOpen: boolean = false; @observable editModalOpen: boolean = false; - @observable redirectTo: ?string; componentDidMount() { const { id } = this.props.match.params; @@ -108,14 +104,6 @@ class CollectionScene extends React.Component { } }; - onNewDocument = (ev: SyntheticEvent<>) => { - ev.preventDefault(); - - if (this.collection) { - this.redirectTo = newDocumentUrl(this.collection.id); - } - }; - onPermissions = (ev: SyntheticEvent<>) => { ev.preventDefault(); this.permissionsModalOpen = true; @@ -157,7 +145,12 @@ class CollectionScene extends React.Component { delay={500} placement="bottom" > - @@ -186,9 +179,8 @@ class CollectionScene extends React.Component { } render() { - const { documents, theme, t } = this.props; + const { documents, t } = this.props; - if (this.redirectTo) return ; if (!this.isFetching && !this.collection) return ; const pinnedDocuments = this.collection @@ -197,7 +189,6 @@ class CollectionScene extends React.Component { const collection = this.collection; const collectionName = collection ? collection.name : ""; const hasPinnedDocuments = !!pinnedDocuments.length; - const hasDescription = collection ? collection.hasDescription : false; return ( @@ -218,7 +209,7 @@ class CollectionScene extends React.Component { - @@ -257,17 +248,7 @@ class CollectionScene extends React.Component { {" "} {collection.name} - - {hasDescription && ( - Loading…

}> - -
- )} + {hasPinnedDocuments && ( <> @@ -396,10 +377,5 @@ const Wrapper = styled(Flex)` `; export default withTranslation()( - inject( - "collections", - "policies", - "documents", - "ui" - )(withTheme(CollectionScene)) + inject("collections", "policies", "documents", "ui")(CollectionScene) ); diff --git a/app/scenes/CollectionEdit.js b/app/scenes/CollectionEdit.js index 84ede833..a759821a 100644 --- a/app/scenes/CollectionEdit.js +++ b/app/scenes/CollectionEdit.js @@ -11,7 +11,6 @@ import Flex from "components/Flex"; import HelpText from "components/HelpText"; import IconPicker from "components/IconPicker"; import Input from "components/Input"; -import InputRich from "components/InputRich"; import InputSelect from "components/InputSelect"; import Switch from "components/Switch"; @@ -27,7 +26,6 @@ type Props = { class CollectionEdit extends React.Component { @observable name: string = this.props.collection.name; @observable sharing: boolean = this.props.collection.sharing; - @observable description: string = this.props.collection.description; @observable icon: string = this.props.collection.icon; @observable color: string = this.props.collection.color || "#4E5C6E"; @observable private: boolean = this.props.collection.private; @@ -43,7 +41,6 @@ class CollectionEdit extends React.Component { try { await this.props.collection.save({ name: this.name, - description: this.description, icon: this.icon, color: this.color, private: this.private, @@ -69,10 +66,6 @@ class CollectionEdit extends React.Component { } }; - handleDescriptionChange = (getValue: () => string) => { - this.description = getValue(); - }; - handleNameChange = (ev: SyntheticInputEvent<*>) => { this.name = ev.target.value; }; @@ -120,15 +113,6 @@ class CollectionEdit extends React.Component { icon={this.icon} /> - { @observable name: string = ""; - @observable description: string = ""; @observable icon: string = ""; @observable color: string = "#4E5C6E"; @observable sharing: boolean = true; @@ -43,7 +41,6 @@ class CollectionNew extends React.Component { const collection = new Collection( { name: this.name, - description: this.description, sharing: this.sharing, icon: this.icon, color: this.color, @@ -90,10 +87,6 @@ class CollectionNew extends React.Component { this.hasOpenedIconPicker = true; }; - handleDescriptionChange = (getValue: () => string) => { - this.description = getValue(); - }; - handlePrivateChange = (ev: SyntheticInputEvent) => { this.private = ev.target.checked; }; @@ -115,9 +108,9 @@ class CollectionNew extends React.Component {
- Collections are for grouping your knowledge base. They work best - when organized around a topic or internal team — Product or - Engineering for example. + Collections are for grouping your documents. They work best when + organized around a topic or internal team — Product or Engineering + for example. @@ -138,14 +131,6 @@ class CollectionNew extends React.Component { icon={this.icon} /> -