feat: Inline collection editing (#1865)

This commit is contained in:
Tom Moor
2021-02-12 16:20:49 -08:00
committed by GitHub
parent 2611376b21
commit 1dbcc12648
14 changed files with 298 additions and 101 deletions

23
app/components/Arrow.js Normal file
View File

@ -0,0 +1,23 @@
// @flow
import * as React from "react";
export default function Arrow() {
return (
<svg
width="13"
height="30"
viewBox="0 0 13 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z"
/>
<path
fill="currentColor"
d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z"
/>
</svg>
);
}

View File

@ -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 (
<MaxHeight data-editing={isEditing} data-expanded={isExpanded}>
<Input
$isEditable={can.update}
data-editing={isEditing}
data-expanded={isExpanded}
>
<span onClick={can.update ? handleStartEditing : undefined}>
{collections.isSaving && <LoadingIndicator />}
{collection.hasDescription || isEditing || isDirty ? (
<React.Suspense fallback={<Placeholder>Loading</Placeholder>}>
<Editor
id={collection.id}
key={key}
defaultValue={collection.description}
onChange={handleChange}
placeholder={placeholder}
readOnly={!isEditing}
autoFocus={isEditing}
onBlur={handleStopEditing}
maxLength={1000}
disableEmbeds
readOnlyWriteCheckboxes
grow
/>
</React.Suspense>
) : (
can.update && <Placeholder>{placeholder}</Placeholder>
)}
</span>
</Input>
{!isEditing && (
<Disclosure
onClick={handleClickDisclosure}
aria-label={isExpanded ? t("Collapse") : t("Expand")}
size={30}
>
<Arrow />
</Disclosure>
)}
</MaxHeight>
);
}
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);

View File

@ -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;
}
& * {

View File

@ -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<Props, HTMLButtonElement>(
return (
<Positioner style={style}>
<ToggleButton ref={ref} $direction={direction} onClick={onClick}>
<svg
width="13"
height="30"
viewBox="0 0 13 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z"
/>
<path
fill="currentColor"
d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z"
/>
</svg>
<Arrow />
</ToggleButton>
</Positioner>
);

View File

@ -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() {

View File

@ -1,3 +0,0 @@
// @flow
import Toasts from "./Toasts";
export default Toasts;

View File

@ -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);
};
}

View File

@ -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<Props> {
@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<Props> {
}
};
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<Props> {
delay={500}
placement="bottom"
>
<Button onClick={this.onNewDocument} icon={<PlusIcon />}>
<Button
as={Link}
to={this.collection ? newDocumentUrl(this.collection.id) : ""}
disabled={!this.collection}
icon={<PlusIcon />}
>
{t("New doc")}
</Button>
</Tooltip>
@ -186,9 +179,8 @@ class CollectionScene extends React.Component<Props> {
}
render() {
const { documents, theme, t } = this.props;
const { documents, t } = this.props;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
if (!this.isFetching && !this.collection) return <Search notFound />;
const pinnedDocuments = this.collection
@ -197,7 +189,6 @@ class CollectionScene extends React.Component<Props> {
const collection = this.collection;
const collectionName = collection ? collection.name : "";
const hasPinnedDocuments = !!pinnedDocuments.length;
const hasDescription = collection ? collection.hasDescription : false;
return (
<CenteredContent>
@ -218,7 +209,7 @@ class CollectionScene extends React.Component<Props> {
</HelpText>
<Wrapper>
<Link to={newDocumentUrl(collection.id)}>
<Button icon={<NewDocumentIcon color={theme.buttonText} />}>
<Button icon={<NewDocumentIcon color="currentColor" />}>
{t("Create a document")}
</Button>
</Link>
@ -257,17 +248,7 @@ class CollectionScene extends React.Component<Props> {
<CollectionIcon collection={collection} size={40} expanded />{" "}
{collection.name}
</Heading>
{hasDescription && (
<React.Suspense fallback={<p>Loading</p>}>
<Editor
id={collection.id}
key={collection.description}
defaultValue={collection.description}
readOnly
/>
</React.Suspense>
)}
<CollectionDescription collection={collection} />
{hasPinnedDocuments && (
<>
@ -396,10 +377,5 @@ const Wrapper = styled(Flex)`
`;
export default withTranslation()<CollectionScene>(
inject(
"collections",
"policies",
"documents",
"ui"
)(withTheme(CollectionScene))
inject("collections", "policies", "documents", "ui")(CollectionScene)
);

View File

@ -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<Props> {
@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<Props> {
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<Props> {
}
};
handleDescriptionChange = (getValue: () => string) => {
this.description = getValue();
};
handleNameChange = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
};
@ -120,15 +113,6 @@ class CollectionEdit extends React.Component<Props> {
icon={this.icon}
/>
</Flex>
<InputRich
id={this.props.collection.id}
label={t("Description")}
onChange={this.handleDescriptionChange}
defaultValue={this.description || ""}
placeholder={t("More details about this collection…")}
minHeight={68}
maxHeight={200}
/>
<InputSelect
label={t("Sort in sidebar")}
options={[

View File

@ -14,7 +14,6 @@ import Flex from "components/Flex";
import HelpText from "components/HelpText";
import IconPicker, { icons } from "components/IconPicker";
import Input from "components/Input";
import InputRich from "components/InputRich";
import Switch from "components/Switch";
type Props = {
@ -29,7 +28,6 @@ type Props = {
@observer
class CollectionNew extends React.Component<Props> {
@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<Props> {
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<Props> {
this.hasOpenedIconPicker = true;
};
handleDescriptionChange = (getValue: () => string) => {
this.description = getValue();
};
handlePrivateChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
this.private = ev.target.checked;
};
@ -115,9 +108,9 @@ class CollectionNew extends React.Component<Props> {
<form onSubmit={this.handleSubmit}>
<HelpText>
<Trans>
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.
</Trans>
</HelpText>
<Flex>
@ -138,14 +131,6 @@ class CollectionNew extends React.Component<Props> {
icon={this.icon}
/>
</Flex>
<InputRich
label={t("Description")}
onChange={this.handleDescriptionChange}
defaultValue={this.description || ""}
placeholder={t("More details about this collection…")}
minHeight={68}
maxHeight={200}
/>
<Switch
id="private"
label={t("Private collection")}

View File

@ -154,7 +154,7 @@
"react-waypoint": "^9.0.2",
"react-window": "^1.8.6",
"reakit": "^1.3.4",
"rich-markdown-editor": "^11.2.0-0",
"rich-markdown-editor": "^11.3.0-0",
"semver": "^7.3.2",
"sequelize": "^6.3.4",
"sequelize-cli": "^6.2.0",

View File

@ -8,6 +8,10 @@
"Drafts": "Drafts",
"Templates": "Templates",
"Deleted Collection": "Deleted Collection",
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
"Add description": "Add description",
"Collapse": "Collapse",
"Expand": "Expand",
"Submenu": "Submenu",
"New": "New",
"Only visible to you": "Only visible to you",
@ -108,8 +112,6 @@
"Export Data": "Export Data",
"Integrations": "Integrations",
"Installation": "Installation",
"Expand": "Expand",
"Collapse": "Collapse",
"Unstar": "Unstar",
"Star": "Star",
"Appearance": "Appearance",
@ -204,8 +206,6 @@
"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",
"Description": "Description",
"More details about this collection…": "More details about this collection…",
"Alphabetical": "Alphabetical",
"Private collection": "Private collection",
"A private collection will only be visible to invited team members.": "A private collection will only be visible to invited team members.",
@ -237,7 +237,7 @@
"Never signed in": "Never signed in",
"Invited": "Invited",
"Admin": "Admin",
"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 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.": "Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.",
"Creating": "Creating",
"Create": "Create",
"Recently viewed": "Recently viewed",

View File

@ -10833,10 +10833,10 @@ retry-as-promised@^3.2.0:
dependencies:
any-promise "^1.3.0"
rich-markdown-editor@^11.2.0-0:
version "11.2.0-0"
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-11.2.0-0.tgz#8f031e2367133f3aac22cb47d150e347460d4987"
integrity sha512-qqL44VDToMEmTQZ68r+rv9ZjgtN6s5WiXtZFIOYRGq9pUO7brvd/+WWpXY0z4dNqlAMV5nLGXGIshwKRAMbg/g==
rich-markdown-editor@^11.3.0-0:
version "11.3.0-0"
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-11.3.0-0.tgz#0034da293928e4211c5c39544038fd28a7d78a67"
integrity sha512-6iNmmiYYOXSoifkIemRG1GIp6gkEo6yAnw4FjkFTslOP0B/ZoHFworU7I3qqJ84efEhYPzEGfrGBclRPBaggAg==
dependencies:
copy-to-clipboard "^3.0.8"
lodash "^4.17.11"