Compare commits

..

15 Commits

Author SHA1 Message Date
Tom Moor d4c594423f More email styling 2021-07-08 22:38:42 -04:00
Tom Moor 2bf237d54b Merge branch 'main' into email-diff 2021-07-08 21:20:18 -04:00
Tom Moor 3565e68725 Merge branch 'main' into email-diff 2021-07-06 20:43:51 -04:00
Tom Moor 61039e9d0d Allow images in email diff 2021-06-25 09:41:34 -07:00
Tom Moor 6d09122d56 test: Deletion 2021-06-24 20:10:42 -07:00
Tom Moor 5fb6097153 Improved diff 2021-06-23 23:58:32 -07:00
Tom Moor ec17874568 Remove test harness 2021-06-22 07:35:38 -07:00
Tom Moor 40c3e9e85f test 2021-06-22 07:27:55 -07:00
Tom Moor 9f739f3788 Merge main 2021-06-22 07:26:45 -07:00
Tom Moor f6837b4742 wip 2021-06-20 23:15:04 -07:00
Tom Moor 1560e3c9f7 refactor 2021-06-20 12:49:15 -07:00
Tom Moor ca74908dc5 test 2021-06-20 00:20:37 -07:00
Tom Moor de7ec1119b Integrate into mailer, basic styling 2021-06-19 23:50:36 -07:00
Tom Moor 2093b4297f Merge main 2021-06-19 17:05:19 -07:00
Nan Yu 3df82c500b wip 2021-02-21 11:52:00 -08:00
172 changed files with 2981 additions and 4865 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import { bounceIn } from "styles/animations";
import { bounceIn } from "shared/styles/animations";
type Props = {|
count: number,
+2 -4
View File
@@ -12,15 +12,13 @@ import LoadingIndicator from "components/LoadingIndicator";
import NudeButton from "components/NudeButton";
import useDebouncedCallback from "hooks/useDebouncedCallback";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
collection: Collection,
|};
function CollectionDescription({ collection }: Props) {
const { collections, policies } = useStores();
const { showToast } = useToasts();
const { collections, ui, policies } = useStores();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
@@ -55,7 +53,7 @@ function CollectionDescription({ collection }: Props) {
});
setDirty(false);
} catch (err) {
showToast(
ui.showToast(
t("Sorry, an error occurred saving the collection", {
type: "error",
})
-2
View File
@@ -15,7 +15,6 @@ type Props = {|
target?: "_blank",
as?: string | React.ComponentType<*>,
hide?: () => void,
level?: number,
|};
const MenuItem = ({
@@ -89,7 +88,6 @@ export const MenuAnchor = styled.a`
margin: 0;
border: 0;
padding: 12px;
padding-left: ${(props) => 12 + props.level * 10}px;
width: 100%;
min-height: 32px;
background: none;
+41 -9
View File
@@ -9,11 +9,49 @@ import {
MenuItem as BaseMenuItem,
} from "reakit/Menu";
import styled from "styled-components";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import Separator from "./Separator";
import ContextMenu from ".";
import { type MenuItem as TMenuItem } from "types";
type TMenuItem =
| {|
title: React.Node,
to: string,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
href: string,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
visible?: boolean,
disabled?: boolean,
style?: Object,
hover?: boolean,
items: TMenuItem[],
|}
| {|
type: "separator",
visible?: boolean,
|}
| {|
type: "heading",
visible?: boolean,
title: React.Node,
|};
type Props = {|
items: TMenuItem[],
@@ -90,8 +128,7 @@ function Template({ items, ...menu }: Props): React.Node {
key={index}
disabled={item.disabled}
selected={item.selected}
level={item.level}
target={item.href.startsWith("#") ? undefined : "_blank"}
target="_blank"
{...menu}
>
{item.title}
@@ -130,11 +167,6 @@ function Template({ items, ...menu }: Props): React.Node {
return <Separator key={index} />;
}
if (item.type === "heading") {
return <Header>{item.title}</Header>;
}
console.warn("Unrecognized menu item", item);
return null;
});
}
+14 -29
View File
@@ -4,18 +4,16 @@ import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import usePrevious from "hooks/usePrevious";
import {
fadeIn,
fadeAndSlideUp,
fadeAndSlideDown,
mobileContextMenu,
} from "styles/animations";
fadeAndScaleIn,
fadeAndSlideIn,
} from "shared/styles/animations";
import usePrevious from "hooks/usePrevious";
type Props = {|
"aria-label": string,
visible?: boolean,
placement?: string,
animating?: boolean,
children: React.Node,
onOpen?: () => void,
@@ -46,25 +44,13 @@ export default function ContextMenu({
return (
<>
<Menu hideOnClickOutside preventBodyScroll {...rest}>
{(props) => {
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
// positioning.
const topAnchor = props.style.top === "0";
const rightAnchor = props.placement === "bottom-end";
return (
<Position {...props}>
<Background
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
);
}}
{(props) => (
<Position {...props}>
<Background dir="auto">
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
)}
</Menu>
{(rest.visible || rest.animating) && (
<Portal>
@@ -105,7 +91,7 @@ const Position = styled.div`
`;
const Background = styled.div`
animation: ${mobileContextMenu} 200ms ease;
animation: ${fadeAndSlideIn} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
background: ${(props) => props.theme.menuBackground};
@@ -123,10 +109,9 @@ const Background = styled.div`
}
${breakpoint("tablet")`
animation: ${(props) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: ${(props) =>
props.rightAnchor === "bottom-end" ? "75%" : "25%"} 0;
props.left !== undefined ? "25%" : "75%"} 0;
max-width: 276px;
background: ${(props) => props.theme.menuBackground};
box-shadow: ${(props) => props.theme.menuShadow};
@@ -15,7 +15,7 @@ import RevisionsStore from "stores/RevisionsStore";
import Button from "components/Button";
import Flex from "components/Flex";
import PlaceholderList from "components/List/Placeholder";
import { ListPlaceholder } from "components/LoadingPlaceholder";
import Revision from "./components/Revision";
import { documentHistoryUrl, documentUrl } from "utils/routeHelpers";
@@ -120,7 +120,7 @@ class DocumentHistory extends React.Component<Props> {
</Header>
{showLoading ? (
<Loading>
<PlaceholderList count={5} />
<ListPlaceholder count={5} />
</Loading>
) : (
<ArrowKeyNavigation
+3 -4
View File
@@ -15,7 +15,6 @@ import Flex from "components/Flex";
import Highlight from "components/Highlight";
import StarButton, { AnimatedStar } from "components/Star";
import Tooltip from "components/Tooltip";
import useBoolean from "hooks/useBoolean";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
@@ -47,7 +46,7 @@ function DocumentListItem(props: Props, ref) {
const { policies } = useStores();
const currentUser = useCurrentUser();
const currentTeam = useCurrentTeam();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [menuOpen, setMenuOpen] = React.useState(false);
const {
document,
showNestedDocuments,
@@ -144,8 +143,8 @@ function DocumentListItem(props: Props, ref) {
<DocumentMenu
document={document}
showPin={showPin}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
modal={false}
/>
</Actions>
+6 -8
View File
@@ -8,7 +8,6 @@ import Document from "models/Document";
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
import Flex from "components/Flex";
import Time from "components/Time";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
const Container = styled(Flex)`
@@ -51,9 +50,7 @@ function DocumentMeta({
...rest
}: Props) {
const { t } = useTranslation();
const { collections } = useStores();
const user = useCurrentUser();
const { collections, auth } = useStores();
const {
modifiedSinceViewed,
updatedAt,
@@ -72,8 +69,6 @@ function DocumentMeta({
return null;
}
const collection = collections.get(document.collectionId);
const lastUpdatedByCurrentUser = user.id === updatedBy.id;
let content;
if (deletedAt) {
@@ -108,12 +103,15 @@ function DocumentMeta({
);
} else {
content = (
<Modified highlight={modifiedSinceViewed && !lastUpdatedByCurrentUser}>
<Modified highlight={modifiedSinceViewed}>
{t("updated")} <Time dateTime={updatedAt} addSuffix />
</Modified>
);
}
const collection = collections.get(document.collectionId);
const updatedByMe = auth.user && auth.user.id === updatedBy.id;
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
return null;
@@ -139,7 +137,7 @@ function DocumentMeta({
return (
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{lastUpdatedByCurrentUser ? t("You") : updatedBy.name}&nbsp;
{updatedByMe ? t("You") : updatedBy.name}&nbsp;
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
<span>
+6 -6
View File
@@ -4,13 +4,12 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { light } from "shared/theme";
import { light } from "shared/styles/theme";
import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import embeds from "../embeds";
import useMediaQuery from "hooks/useMediaQuery";
import useToasts from "hooks/useToasts";
import { type Theme } from "types";
import { isModKey } from "utils/keyboard";
import { uploadFile } from "utils/uploadFile";
@@ -59,9 +58,8 @@ type PropsWithRef = Props & {
};
function Editor(props: PropsWithRef) {
const { id, shareId, history } = props;
const { id, ui, shareId, history } = props;
const { t } = useTranslation();
const { showToast } = useToasts();
const isPrinting = useMediaQuery("print");
const onUploadImage = React.useCallback(
@@ -108,9 +106,11 @@ function Editor(props: PropsWithRef) {
const onShowToast = React.useCallback(
(message: string) => {
showToast(message);
if (ui) {
ui.showToast(message);
}
},
[showToast]
[ui]
);
const dictionary = React.useMemo(() => {
+17 -36
View File
@@ -3,7 +3,6 @@ import * as Sentry from "@sentry/react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction, Trans } from "react-i18next";
import styled from "styled-components";
import Button from "components/Button";
import CenteredContent from "components/CenteredContent";
@@ -12,11 +11,10 @@ import PageTitle from "components/PageTitle";
import { githubIssuesUrl } from "../../shared/utils/routeHelpers";
import env from "env";
type Props = {|
type Props = {
children: React.Node,
reloadOnChunkMissing?: boolean,
t: TFunction,
|};
};
@observer
class ErrorBoundary extends React.Component<Props> {
@@ -57,8 +55,6 @@ class ErrorBoundary extends React.Component<Props> {
};
render() {
const { t } = this.props;
if (this.error) {
const error = this.error;
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
@@ -67,21 +63,15 @@ class ErrorBoundary extends React.Component<Props> {
if (isChunkError) {
return (
<CenteredContent>
<PageTitle title={t("Module failed to load")} />
<h1>
<Trans>Loading Failed</Trans>
</h1>
<PageTitle title="Module failed to load" />
<h1>Loading Failed</h1>
<HelpText>
<Trans>
Sorry, part of the application failed to load. This may be
because it was updated since you opened the tab or because of a
failed network request. Please try reloading.
</Trans>
Sorry, part of the application failed to load. This may be because
it was updated since you opened the tab or because of a failed
network request. Please try reloading.
</HelpText>
<p>
<Button onClick={this.handleReload}>
<Trans>Reload</Trans>
</Button>
<Button onClick={this.handleReload}>Reload</Button>
</p>
</CenteredContent>
);
@@ -89,32 +79,23 @@ class ErrorBoundary extends React.Component<Props> {
return (
<CenteredContent>
<PageTitle title={t("Something Unexpected Happened")} />
<h1>
<Trans>Something Unexpected Happened</Trans>
</h1>
<PageTitle title="Something Unexpected Happened" />
<h1>Something Unexpected Happened</h1>
<HelpText>
<Trans
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
values={{
notified: isReported
? ` ${t("our engineers have been notified")}`
: undefined,
}}
/>
Sorry, an unrecoverable error occurred
{isReported && " our engineers have been notified"}. Please try
reloading the page, it may have been a temporary glitch.
</HelpText>
{this.showDetails && <Pre>{error.toString()}</Pre>}
<p>
<Button onClick={this.handleReload}>
<Trans>Reload</Trans>
</Button>{" "}
<Button onClick={this.handleReload}>Reload</Button>{" "}
{this.showDetails ? (
<Button onClick={this.handleReportBug} neutral>
<Trans>Report a Bug</Trans>
Report a Bug
</Button>
) : (
<Button onClick={this.handleShowDetails} neutral>
<Trans>Show Detail</Trans>
Show Details
</Button>
)}
</p>
@@ -133,4 +114,4 @@ const Pre = styled.pre`
white-space: pre-wrap;
`;
export default withTranslation()<ErrorBoundary>(ErrorBoundary);
export default ErrorBoundary;
+1 -1
View File
@@ -1,6 +1,6 @@
// @flow
import styled from "styled-components";
import { fadeIn } from "styles/animations";
import { fadeIn } from "shared/styles/animations";
const Fade = styled.span`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
+1 -1
View File
@@ -45,7 +45,7 @@ const Container = styled.div`
align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify};
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
gap: ${({ gap }) => (gap ? `${gap}px` : "initial")};
gap: ${({ gap }) => `${gap}px` || "initial"};
min-height: 0;
min-width: 0;
`;
+1 -5
View File
@@ -72,10 +72,6 @@ const Actions = styled(Flex)`
flex-basis: 0;
min-width: auto;
padding-left: 8px;
${breakpoint("tablet")`
position: unset;
`};
`;
const Wrapper = styled(Flex)`
@@ -88,12 +84,12 @@ const Wrapper = styled(Flex)`
transform: translate3d(0, 0, 0);
backdrop-filter: blur(20px);
min-height: 56px;
justify-content: flex-start;
@media print {
display: none;
}
justify-content: flex-start;
${breakpoint("tablet")`
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
justify-content: "center";
+2 -2
View File
@@ -4,10 +4,10 @@ import { transparentize } from "polished";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import { fadeAndSlideIn } from "shared/styles/animations";
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore";
import HoverPreviewDocument from "components/HoverPreviewDocument";
import { fadeAndSlideDown } from "styles/animations";
import { isInternalUrl } from "utils/urls";
const DELAY_OPEN = 300;
@@ -136,7 +136,7 @@ function HoverPreview({ node, ...rest }: Props) {
}
const Animate = styled.div`
animation: ${fadeAndSlideDown} 150ms ease;
animation: ${fadeAndSlideIn} 150ms ease;
@media print {
display: none;
-4
View File
@@ -29,10 +29,6 @@ const RealInput = styled.input`
background: none;
color: ${(props) => props.theme.text};
height: 30px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:disabled,
&::placeholder {
+38 -38
View File
@@ -1,58 +1,58 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
import { Trans } from "react-i18next";
import styled, { withTheme } from "styled-components";
import UiStore from "stores/UiStore";
import Editor from "components/Editor";
import HelpText from "components/HelpText";
import { LabelText, Outline } from "components/Input";
import useStores from "hooks/useStores";
type Props = {|
label: string,
minHeight?: number,
maxHeight?: number,
readOnly?: boolean,
ui: UiStore,
|};
function InputRich({ label, minHeight, maxHeight, ...rest }: Props) {
const [focused, setFocused] = React.useState<boolean>(false);
const { ui } = useStores();
@observer
class InputRich extends React.Component<Props> {
@observable editorComponent: React.ComponentType<any>;
@observable focused: boolean = false;
const handleBlur = React.useCallback(() => {
setFocused(false);
}, []);
handleBlur = () => {
this.focused = false;
};
const handleFocus = React.useCallback(() => {
setFocused(true);
}, []);
handleFocus = () => {
this.focused = true;
};
return (
<>
<LabelText>{label}</LabelText>
<StyledOutline
maxHeight={maxHeight}
minHeight={minHeight}
focused={focused}
>
<React.Suspense
fallback={
<HelpText>
<Trans>Loading editor</Trans>
</HelpText>
}
render() {
const { label, minHeight, maxHeight, ui, ...rest } = this.props;
return (
<>
<LabelText>{label}</LabelText>
<StyledOutline
maxHeight={maxHeight}
minHeight={minHeight}
focused={this.focused}
>
<Editor
onBlur={handleBlur}
onFocus={handleFocus}
ui={ui}
grow
{...rest}
/>
</React.Suspense>
</StyledOutline>
</>
);
<React.Suspense fallback={<HelpText>Loading editor</HelpText>}>
<Editor
onBlur={this.handleBlur}
onFocus={this.handleFocus}
ui={ui}
grow
{...rest}
/>
</React.Suspense>
</StyledOutline>
</>
);
}
}
const StyledOutline = styled(Outline)`
@@ -67,4 +67,4 @@ const StyledOutline = styled(Outline)`
}
`;
export default observer(withTheme(InputRich));
export default inject("ui")(withTheme(InputRich));
+5 -6
View File
@@ -4,19 +4,18 @@ import * as React from "react";
import styled from "styled-components";
import Fade from "components/Fade";
import Flex from "components/Flex";
import PlaceholderText from "components/PlaceholderText";
import Mask from "components/Mask";
type Props = {
count?: number,
};
const ListPlaceHolder = ({ count }: Props) => {
const Placeholder = ({ count }: Props) => {
return (
<Fade>
{times(count || 2, (index) => (
<Item key={index} column auto>
<PlaceholderText header delay={0.2 * index} />
<PlaceholderText delay={0.2 * index} />
<Mask />
</Item>
))}
</Fade>
@@ -24,7 +23,7 @@ const ListPlaceHolder = ({ count }: Props) => {
};
const Item = styled(Flex)`
padding: 10px 0;
padding: 15px 0 16px;
`;
export default ListPlaceHolder;
export default Placeholder;
@@ -0,0 +1,30 @@
// @flow
import { times } from "lodash";
import * as React from "react";
import styled from "styled-components";
import Fade from "components/Fade";
import Flex from "components/Flex";
import Mask from "components/Mask";
type Props = {
count?: number,
};
const ListPlaceHolder = ({ count }: Props) => {
return (
<Fade>
{times(count || 2, (index) => (
<Item key={index} column auto>
<Mask header />
<Mask />
</Item>
))}
</Fade>
);
};
const Item = styled(Flex)`
padding: 10px 0;
`;
export default ListPlaceHolder;
@@ -4,19 +4,18 @@ import styled from "styled-components";
import DelayedMount from "components/DelayedMount";
import Fade from "components/Fade";
import Flex from "components/Flex";
import PlaceholderText from "components/PlaceholderText";
import Mask from "components/Mask";
export default function PlaceholderDocument(props: Object) {
export default function LoadingPlaceholder(props: Object) {
return (
<DelayedMount>
<Wrapper>
<Flex column auto {...props}>
<PlaceholderText height={34} maxWidth={70} />
<PlaceholderText delay={0.2} maxWidth={40} />
<Mask height={34} />
<br />
<PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} />
<PlaceholderText delay={0.6} />
<Mask />
<Mask />
<Mask />
</Flex>
</Wrapper>
</DelayedMount>
@@ -0,0 +1,6 @@
// @flow
import ListPlaceholder from "./ListPlaceholder";
import LoadingPlaceholder from "./LoadingPlaceholder";
export default LoadingPlaceholder;
export { ListPlaceholder };
-4
View File
@@ -3,11 +3,9 @@ import { format, formatDistanceToNow } from "date-fns";
import {
enUS,
de,
faIR,
fr,
es,
it,
ja,
ko,
ptBR,
pt,
@@ -23,10 +21,8 @@ const locales = {
en_US: enUS,
de_DE: de,
es_ES: es,
fa_IR: faIR,
fr_FR: fr,
it_IT: it,
ja_JP: ja,
ko_KR: ko,
pt_BR: ptBR,
pt_PT: pt,
@@ -2,48 +2,44 @@
import * as React from "react";
import styled from "styled-components";
import { randomInteger } from "shared/random";
import { pulsate } from "shared/styles/animations";
import Flex from "components/Flex";
import { pulsate } from "styles/animations";
type Props = {|
header?: boolean,
height?: number,
minWidth?: number,
maxWidth?: number,
delay?: number,
|};
class PlaceholderText extends React.Component<Props> {
width = randomInteger(this.props.minWidth || 75, this.props.maxWidth || 100);
class Mask extends React.Component<Props> {
width: number;
shouldComponentUpdate() {
return false;
}
constructor(props: Props) {
super();
this.width = randomInteger(props.minWidth || 75, props.maxWidth || 100);
}
render() {
return (
<Mask
width={this.width}
height={this.props.height}
delay={this.props.delay}
/>
);
return <Redacted width={this.width} height={this.props.height} />;
}
}
const Mask = styled(Flex)`
const Redacted = styled(Flex)`
width: ${(props) => (props.header ? props.width / 2 : props.width)}%;
height: ${(props) =>
props.height ? props.height : props.header ? 24 : 18}px;
margin-bottom: 6px;
border-radius: 6px;
background-color: ${(props) => props.theme.divider};
animation: ${pulsate} 2s infinite;
animation-delay: ${(props) => props.delay || 0}s;
animation: ${pulsate} 1.3s infinite;
&:last-child {
margin-bottom: 0;
}
`;
export default PlaceholderText;
export default Mask;
+1 -1
View File
@@ -7,12 +7,12 @@ import { useTranslation } from "react-i18next";
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { fadeAndScaleIn } from "shared/styles/animations";
import Flex from "components/Flex";
import NudeButton from "components/NudeButton";
import Scrollable from "components/Scrollable";
import usePrevious from "hooks/usePrevious";
import useUnmount from "hooks/useUnmount";
import { fadeAndScaleIn } from "styles/animations";
let openModals = 0;
+20 -21
View File
@@ -1,4 +1,5 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import Document from "models/Document";
import DocumentListItem from "components/DocumentListItem";
@@ -18,26 +19,24 @@ type Props = {|
showTemplate?: boolean,
|};
const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
empty,
heading,
documents,
fetch,
options,
...rest
}: Props) {
return (
<PaginatedList
items={documents}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item) => (
<DocumentListItem key={item.id} document={item} {...rest} />
)}
/>
);
});
@observer
class PaginatedDocumentList extends React.Component<Props> {
render() {
const { empty, heading, documents, fetch, options, ...rest } = this.props;
return (
<PaginatedList
items={documents}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item) => (
<DocumentListItem key={item.id} document={item} {...rest} />
)}
/>
);
}
}
export default PaginatedDocumentList;
+2 -2
View File
@@ -7,7 +7,7 @@ import * as React from "react";
import { Waypoint } from "react-waypoint";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DelayedMount from "components/DelayedMount";
import PlaceholderList from "components/List/Placeholder";
import { ListPlaceholder } from "components/LoadingPlaceholder";
type Props = {
fetch?: (options: ?Object) => Promise<void>,
@@ -128,7 +128,7 @@ class PaginatedList extends React.Component<Props> {
)}
{showLoading && (
<DelayedMount>
<PlaceholderList count={5} />
<ListPlaceholder count={5} />
</DelayedMount>
)}
</>
+1 -1
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { Popover as ReakitPopover } from "reakit/Popover";
import styled from "styled-components";
import { fadeAndScaleIn } from "styles/animations";
import { fadeAndScaleIn } from "shared/styles/animations";
type Props = {
children: React.Node,
+41 -26
View File
@@ -1,13 +1,15 @@
// @flow
import { observer } from "mobx-react";
import {
EditIcon,
ArchiveIcon,
HomeIcon,
PlusIcon,
EditIcon,
SearchIcon,
SettingsIcon,
ShapesIcon,
StarredIcon,
ShapesIcon,
TrashIcon,
PlusIcon,
SettingsIcon,
} from "outline-icons";
import * as React from "react";
import { DndProvider } from "react-dnd";
@@ -21,22 +23,16 @@ import Flex from "components/Flex";
import Modal from "components/Modal";
import Scrollable from "components/Scrollable";
import Sidebar from "./Sidebar";
import ArchiveLink from "./components/ArchiveLink";
import Collections from "./components/Collections";
import Section from "./components/Section";
import SidebarLink from "./components/SidebarLink";
import TeamButton from "./components/TeamButton";
import TrashLink from "./components/TrashLink";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import AccountMenu from "menus/AccountMenu";
function MainSidebar() {
const { t } = useTranslation();
const { policies, documents } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const { policies, auth, documents } = useStores();
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
const [
createCollectionModalOpen,
@@ -75,6 +71,9 @@ function MainSidebar() {
dndArea,
]);
const { user, team } = auth;
if (!user || !team) return null;
const can = policies.abilities(team.id);
return (
@@ -115,6 +114,17 @@ function MainSidebar() {
exact={false}
label={t("Starred")}
/>
{can.createDocument && (
<SidebarLink
to="/templates"
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
active={
documents.active ? documents.active.template : undefined
}
/>
)}
{can.createDocument && (
<SidebarLink
to="/drafts"
@@ -141,21 +151,26 @@ function MainSidebar() {
/>
</Section>
<Section>
{can.createDocument && (
<>
<SidebarLink
to="/templates"
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
active={
documents.active ? documents.active.template : undefined
}
/>
<ArchiveLink documents={documents} />
<TrashLink documents={documents} />
</>
)}
<SidebarLink
to="/archive"
icon={<ArchiveIcon color="currentColor" />}
exact={false}
label={t("Archive")}
active={
documents.active
? documents.active.isArchived && !documents.active.isDeleted
: undefined
}
/>
<SidebarLink
to="/trash"
icon={<TrashIcon color="currentColor" />}
exact={false}
label={t("Trash")}
active={
documents.active ? documents.active.isDeleted : undefined
}
/>
<SidebarLink
to="/settings"
icon={<SettingsIcon color="currentColor" />}
+1 -1
View File
@@ -6,13 +6,13 @@ import { Portal } from "react-portal";
import { useLocation } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { fadeIn } from "shared/styles/animations";
import Fade from "components/Fade";
import Flex from "components/Flex";
import ResizeBorder from "./components/ResizeBorder";
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
import usePrevious from "hooks/usePrevious";
import useStores from "hooks/useStores";
import { fadeIn } from "styles/animations";
let ANIMATION_MS = 250;
let isFirstRender = true;
@@ -1,43 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { ArchiveIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import useStores from "../../../hooks/useStores";
import SidebarLink from "./SidebarLink";
import useToasts from "hooks/useToasts";
function ArchiveLink({ documents }) {
const { policies } = useStores();
const { t } = useTranslation();
const { showToast } = useToasts();
const [{ isDocumentDropping }, dropToArchiveDocument] = useDrop({
accept: "document",
drop: async (item, monitor) => {
const document = documents.get(item.id);
await document.archive();
showToast(t("Document archived"), { type: "success" });
},
canDrop: (item, monitor) => policies.abilities(item.id).archive,
collect: (monitor) => ({
isDocumentDropping: monitor.isOver(),
}),
});
return (
<div ref={dropToArchiveDocument}>
<SidebarLink
to="/archive"
icon={<ArchiveIcon color="currentColor" open={isDocumentDropping} />}
exact={false}
label={t("Archive")}
active={documents.active?.isArchived && !documents.active?.isDeleted}
isActiveDrop={isDocumentDropping}
/>
</div>
);
}
export default observer(ArchiveLink);
@@ -12,7 +12,6 @@ import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useBoolean from "hooks/useBoolean";
import useStores from "hooks/useStores";
import CollectionMenu from "menus/CollectionMenu";
import CollectionSortMenu from "menus/CollectionSortMenu";
@@ -36,7 +35,7 @@ function CollectionLink({
isDraggingAnyCollection,
onChangeDragging,
}: Props) {
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [menuOpen, setMenuOpen] = React.useState(false);
const handleTitleChange = React.useCallback(
async (name: string) => {
@@ -164,14 +163,14 @@ function CollectionLink({
{can.update && (
<CollectionSortMenuWithMargin
collection={collection}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
)}
<CollectionMenu
collection={collection}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</>
}
@@ -9,13 +9,11 @@ import Fade from "components/Fade";
import Flex from "components/Flex";
import useStores from "../../../hooks/useStores";
import CollectionLink from "./CollectionLink";
import CollectionsLoading from "./CollectionsLoading";
import DropCursor from "./DropCursor";
import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import SidebarLink from "./SidebarLink";
import useCurrentTeam from "hooks/useCurrentTeam";
import useToasts from "hooks/useToasts";
type Props = {
onCreateCollection: () => void,
};
@@ -24,7 +22,6 @@ function Collections({ onCreateCollection }: Props) {
const [isFetching, setFetching] = React.useState(false);
const [fetchError, setFetchError] = React.useState();
const { ui, policies, documents, collections } = useStores();
const { showToast } = useToasts();
const isPreloaded: boolean = !!collections.orderedData.length;
const { t } = useTranslation();
const team = useCurrentTeam();
@@ -41,7 +38,7 @@ function Collections({ onCreateCollection }: Props) {
setFetching(true);
await collections.fetchPage({ limit: 100 });
} catch (error) {
showToast(
ui.showToast(
t("Collections could not be loaded, please reload the app"),
{
type: "error",
@@ -54,7 +51,7 @@ function Collections({ onCreateCollection }: Props) {
}
}
load();
}, [collections, isFetching, showToast, fetchError, t]);
}, [collections, isFetching, ui, fetchError, t]);
const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({
accept: "collection",
@@ -108,7 +105,7 @@ function Collections({ onCreateCollection }: Props) {
return (
<Flex column>
<Header>{t("Collections")}</Header>
<PlaceholderCollections />
<CollectionsLoading />
</Flex>
);
}
@@ -0,0 +1,21 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Mask from "components/Mask";
function CollectionsLoading() {
return (
<Wrapper>
<Mask />
<Mask />
<Mask />
</Wrapper>
);
}
const Wrapper = styled.div`
margin: 4px 16px;
width: 75%;
`;
export default CollectionsLoading;
@@ -12,7 +12,6 @@ import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useBoolean from "hooks/useBoolean";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import { type NavigationNode } from "types";
@@ -121,7 +120,7 @@ function DocumentLink(
[documents, document]
);
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [menuOpen, setMenuOpen] = React.useState(false);
const isMoving = documents.movingDocumentId === node.id;
const manualSort = collection?.sort.field === "index";
@@ -133,11 +132,7 @@ function DocumentLink(
isDragging: !!monitor.isDragging(),
}),
canDrag: (monitor) => {
return (
policies.abilities(node.id).move ||
policies.abilities(node.id).archive ||
policies.abilities(node.id).delete
);
return policies.abilities(node.id).move;
},
});
@@ -250,8 +245,8 @@ function DocumentLink(
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</Fade>
) : undefined
@@ -7,7 +7,6 @@ import styled, { css } from "styled-components";
import LoadingIndicator from "components/LoadingIndicator";
import useImportDocument from "hooks/useImportDocument";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
children: React.Node,
@@ -19,8 +18,7 @@ type Props = {|
function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const { t } = useTranslation();
const { documents, policies } = useStores();
const { showToast } = useToasts();
const { ui, documents, policies } = useStores();
const { handleFiles, isImporting } = useImportDocument(
collectionId,
documentId
@@ -29,11 +27,11 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const can = policies.abilities(collectionId);
const handleRejection = React.useCallback(() => {
showToast(
ui.showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"),
{ type: "error" }
);
}, [t, showToast]);
}, [t, ui]);
if (disabled || !can.update) {
return children;
@@ -1,7 +1,7 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import useToasts from "hooks/useToasts";
import useStores from "hooks/useStores";
type Props = {|
onSubmit: (title: string) => Promise<void>,
@@ -13,7 +13,8 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
const [isEditing, setIsEditing] = React.useState(false);
const [originalValue, setOriginalValue] = React.useState(title);
const [value, setValue] = React.useState(title);
const { showToast } = useToasts();
const { ui } = useStores();
React.useEffect(() => {
setValue(title);
}, [title]);
@@ -51,13 +52,13 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
setOriginalValue(value);
} catch (error) {
setValue(originalValue);
showToast(error.message, {
ui.showToast(error.message, {
type: "error",
});
throw error;
}
}
}, [originalValue, showToast, value, onSubmit]);
}, [ui, originalValue, value, onSubmit]);
return (
<>
@@ -1,21 +0,0 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import PlaceholderText from "components/PlaceholderText";
function PlaceholderCollections() {
return (
<Wrapper>
<PlaceholderText />
<PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} />
</Wrapper>
);
}
const Wrapper = styled.div`
margin: 4px 16px;
width: 75%;
`;
export default PlaceholderCollections;
@@ -1,62 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { TrashIcon } from "outline-icons";
import * as React from "react";
import { useState } from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import DocumentDelete from "scenes/DocumentDelete";
import Modal from "components/Modal";
import useStores from "../../../hooks/useStores";
import SidebarLink from "./SidebarLink";
function TrashLink({ documents }) {
const { policies } = useStores();
const { t } = useTranslation();
const [document, setDocument] = useState();
const [{ isDocumentDropping }, dropToTrashDocument] = useDrop({
accept: "document",
drop: (item, monitor) => {
const doc = documents.get(item.id);
// without setTimeout it was not working in firefox v89.0.2-ubuntu
// on dropping mouseup is considered as clicking outside the modal, and it immediately closes
setTimeout(() => setDocument(doc), 1);
},
canDrop: (item, monitor) => policies.abilities(item.id).delete,
collect: (monitor) => ({
isDocumentDropping: monitor.isOver(),
}),
});
return (
<>
<div ref={dropToTrashDocument}>
<SidebarLink
to="/trash"
icon={<TrashIcon color="currentColor" open={isDocumentDropping} />}
exact={false}
label={t("Trash")}
active={documents.active?.isDeleted}
isActiveDrop={isDocumentDropping}
/>
</div>
{document && (
<Modal
title={t("Delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setDocument(undefined)}
isOpen
>
<DocumentDelete
document={document}
onSubmit={() => setDocument(undefined)}
/>
</Modal>
)}
</>
);
}
export default observer(TrashLink);
+5 -5
View File
@@ -11,7 +11,7 @@ import DocumentsStore from "stores/DocumentsStore";
import GroupsStore from "stores/GroupsStore";
import MembershipsStore from "stores/MembershipsStore";
import PoliciesStore from "stores/PoliciesStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
import ViewsStore from "stores/ViewsStore";
import { getVisibilityListener, getPageVisible } from "utils/pageVisibility";
@@ -27,7 +27,7 @@ type Props = {
policies: PoliciesStore,
views: ViewsStore,
auth: AuthStore,
toasts: ToastsStore,
ui: UiStore,
};
@observer
@@ -72,7 +72,7 @@ class SocketProvider extends React.Component<Props> {
const {
auth,
toasts,
ui,
documents,
collections,
groups,
@@ -113,7 +113,7 @@ class SocketProvider extends React.Component<Props> {
this.socket.on("unauthorized", (err) => {
this.socket.authenticated = false;
toasts.showToast(err.message, {
ui.showToast(err.message, {
type: "error",
});
throw err;
@@ -338,7 +338,7 @@ class SocketProvider extends React.Component<Props> {
export default inject(
"auth",
"toasts",
"ui",
"documents",
"collections",
"groups",
+8 -49
View File
@@ -1,26 +1,14 @@
// @flow
import { m } from "framer-motion";
import * as React from "react";
import { NavLink, Route } from "react-router-dom";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { type Theme } from "types";
type Props = {
theme: Theme,
children: React.Node,
};
const NavLinkWithChildrenFunc = ({ to, exact = false, children, ...rest }) => (
<Route path={to} exact={exact}>
{({ match }) => (
<NavLink to={to} {...rest}>
{children(match)}
</NavLink>
)}
</Route>
);
const TabLink = styled(NavLinkWithChildrenFunc)`
const TabLink = styled(NavLink)`
position: relative;
display: inline-flex;
align-items: center;
@@ -32,48 +20,19 @@ const TabLink = styled(NavLinkWithChildrenFunc)`
&:hover {
color: ${(props) => props.theme.textSecondary};
border-bottom: 3px solid ${(props) => props.theme.divider};
padding-bottom: 5px;
}
`;
const Active = styled(m.div)`
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
width: 100%;
border-top-left-radius: 2px;
border-top-right-radius: 2px;
background: ${(props) => props.theme.textSecondary};
`;
const transition = {
type: "spring",
stiffness: 500,
damping: 30,
};
function Tab({ theme, children, ...rest }: Props) {
function Tab({ theme, ...rest }: Props) {
const activeStyle = {
paddingBottom: "5px",
borderBottom: `3px solid ${theme.textSecondary}`,
color: theme.textSecondary,
};
return (
<TabLink {...rest} activeStyle={activeStyle}>
{(match) => (
<>
{children}
{match && (
<Active
layoutId="underline"
initial={false}
transition={transition}
/>
)}
</>
)}
</TabLink>
);
return <TabLink {...rest} activeStyle={activeStyle} />;
}
export default withTheme(Tab);
+2 -2
View File
@@ -8,7 +8,7 @@ import styled from "styled-components";
import Button from "components/Button";
import Empty from "components/Empty";
import Flex from "components/Flex";
import PlaceholderText from "components/PlaceholderText";
import Mask from "components/Mask";
export type Props = {|
data: any[],
@@ -170,7 +170,7 @@ export const Placeholder = ({
<Row key={row}>
{new Array(columns).fill().map((_, col) => (
<Cell key={col}>
<PlaceholderText minWidth={25} maxWidth={75} />
<Mask minWidth={25} maxWidth={75} />
</Cell>
))}
</Row>
+5 -57
View File
@@ -1,40 +1,13 @@
// @flow
import { AnimateSharedLayout } from "framer-motion";
import { transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import useWindowSize from "hooks/useWindowSize";
const Nav = styled.nav`
border-bottom: 1px solid ${(props) => props.theme.divider};
margin: 12px 0;
overflow-y: auto;
white-space: nowrap;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
&:after {
content: "";
position: absolute;
top: 0;
right: 0;
width: 50px;
height: 100%;
pointer-events: none;
background: ${(props) =>
props.$shadowVisible
? `linear-gradient(
90deg,
${transparentize(1, props.theme.background)} 0%,
${props.theme.background} 100%
)`
: `transparent`};
}
transition: opacity 250ms ease-out;
`;
// When sticky we need extra background coverage around the sides otherwise
@@ -57,36 +30,11 @@ export const Separator = styled.span`
margin-top: 6px;
`;
const Tabs = ({ children }: {| children: React.Node |}) => {
const ref = React.useRef<?HTMLDivElement>();
const [shadowVisible, setShadow] = React.useState(false);
const { width } = useWindowSize();
const updateShadows = React.useCallback(() => {
const c = ref.current;
if (!c) return;
const scrollLeft = c.scrollLeft;
const wrapperWidth = c.scrollWidth - c.clientWidth;
const fade = !!(wrapperWidth - scrollLeft !== 0);
if (fade !== shadowVisible) {
setShadow(fade);
}
}, [shadowVisible]);
React.useEffect(() => {
updateShadows();
}, [width, updateShadows]);
const Tabs = (props: {}) => {
return (
<AnimateSharedLayout>
<Sticky>
<Nav ref={ref} onScroll={updateShadows} $shadowVisible={shadowVisible}>
{children}
</Nav>
</Sticky>
</AnimateSharedLayout>
<Sticky>
<Nav {...props}></Nav>
</Sticky>
);
};
+2 -2
View File
@@ -2,10 +2,10 @@
import { observer } from "mobx-react";
import * as React from "react";
import { ThemeProvider } from "styled-components";
import { dark, light, lightMobile, darkMobile } from "shared/theme";
import GlobalStyles from "shared/styles/globals";
import { dark, light, lightMobile, darkMobile } from "shared/styles/theme";
import useMediaQuery from "hooks/useMediaQuery";
import useStores from "hooks/useStores";
import GlobalStyles from "styles/globals";
const empty = {};
+1 -1
View File
@@ -3,7 +3,7 @@ import { CheckboxIcon, InfoIcon, WarningIcon } from "outline-icons";
import { darken } from "polished";
import * as React from "react";
import styled, { css } from "styled-components";
import { fadeAndScaleIn, pulse } from "styles/animations";
import { fadeAndScaleIn, pulse } from "shared/styles/animations";
import type { Toast as TToast } from "types";
type Props = {
+3 -3
View File
@@ -6,15 +6,15 @@ import Toast from "components/Toast";
import useStores from "hooks/useStores";
function Toasts() {
const { toasts } = useStores();
const { ui } = useStores();
return (
<List>
{toasts.orderedData.map((toast) => (
{ui.orderedToasts.map((toast) => (
<Toast
key={toast.id}
toast={toast}
onRequestClose={() => toasts.hideToast(toast.id)}
onRequestClose={() => ui.removeToast(toast.id)}
/>
))}
</List>
-39
View File
@@ -1,39 +0,0 @@
// @flow
import * as React from "react";
import Image from "components/Image";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp(
"^https?://datastudio.google.com/(embed|u/0)/reporting/(.*)/page/(.*)(/edit)?$"
);
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class GoogleDataStudio extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href.replace("u/0", "embed").replace("/edit", "")}
icon={
<Image
src="/images/google-datastudio.png"
alt="Google Data Studio Icon"
width={16}
height={16}
/>
}
canonicalUrl={this.props.attrs.href}
title="Google Data Studio"
border
/>
);
}
}
-19
View File
@@ -1,19 +0,0 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import GoogleDataStudio from "./GoogleDataStudio";
describe("GoogleDataStudio", () => {
const match = GoogleDataStudio.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://datastudio.google.com/embed/reporting/aab01789-f3a2-4ff3-9cba-c4c94c4a92e8/page/7zFD".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://datastudio.google.com/u/0/".match(match)).toBe(null);
expect("https://datastudio.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
});
});
-8
View File
@@ -11,7 +11,6 @@ import Descript from "./Descript";
import Figma from "./Figma";
import Framer from "./Framer";
import Gist from "./Gist";
import GoogleDataStudio from "./GoogleDataStudio";
import GoogleDocs from "./GoogleDocs";
import GoogleDrawings from "./GoogleDrawings";
import GoogleDrive from "./GoogleDrive";
@@ -149,13 +148,6 @@ export default [
component: GoogleSlides,
matcher: matcher(GoogleSlides),
},
{
title: "Google Data Studio",
keywords: "business intelligence",
icon: () => <Img src="/images/google-datastudio.png" />,
component: GoogleDataStudio,
matcher: matcher(GoogleDataStudio),
},
{
title: "InVision",
keywords: "design prototype",
-23
View File
@@ -1,23 +0,0 @@
// @flow
import * as React from "react";
type InitialState = boolean | (() => boolean);
/**
* React hook to manage booleans
*
* @param initialState the initial boolean state value
*/
export default function useBoolean(initialState: InitialState = false) {
const [value, setValue] = React.useState(initialState);
const setTrue = React.useCallback(() => {
setValue(true);
}, []);
const setFalse = React.useCallback(() => {
setValue(false);
}, []);
return [value, setTrue, setFalse];
}
+3 -5
View File
@@ -4,7 +4,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
let importingLock = false;
@@ -12,8 +11,7 @@ export default function useImportDocument(
collectionId: string,
documentId?: string
): {| handleFiles: (files: File[]) => Promise<void>, isImporting: boolean |} {
const { documents } = useStores();
const { showToast } = useToasts();
const { documents, ui } = useStores();
const [isImporting, setImporting] = React.useState(false);
const { t } = useTranslation();
const history = useHistory();
@@ -53,7 +51,7 @@ export default function useImportDocument(
}
}
} catch (err) {
showToast(`${t("Could not import file")}. ${err.message}`, {
ui.showToast(`${t("Could not import file")}. ${err.message}`, {
type: "error",
});
} finally {
@@ -61,7 +59,7 @@ export default function useImportDocument(
importingLock = false;
}
},
[t, documents, history, showToast, collectionId, documentId]
[t, ui, documents, history, collectionId, documentId]
);
return {
-8
View File
@@ -1,8 +0,0 @@
// @flow
import useStores from "./useStores";
export default function useToasts() {
const { toasts } = useStores();
return { showToast: toasts.showToast, hideToast: toasts.hideToast };
}
+9 -16
View File
@@ -1,6 +1,5 @@
// @flow
import "focus-visible";
import { LazyMotion } from "framer-motion";
import { createBrowserHistory } from "history";
import { Provider } from "mobx-react";
import * as React from "react";
@@ -50,10 +49,6 @@ if ("serviceWorker" in window.navigator) {
});
}
// Make sure to return the specific export containing the feature bundle.
const loadFeatures = () =>
import("./utils/motion.js").then((res) => res.default);
if (element) {
const App = () => (
<React.StrictMode>
@@ -61,17 +56,15 @@ if (element) {
<Analytics>
<Theme>
<ErrorBoundary>
<LazyMotion features={loadFeatures}>
<Router history={history}>
<>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</LazyMotion>
<Router history={history}>
<>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</ErrorBoundary>
</Theme>
</Analytics>
+5 -8
View File
@@ -19,7 +19,6 @@ import MenuItem, { MenuAnchor } from "components/ContextMenu/MenuItem";
import Separator from "components/ContextMenu/Separator";
import Flex from "components/Flex";
import Guide from "components/Guide";
import useBoolean from "hooks/useBoolean";
import usePrevious from "hooks/usePrevious";
import useStores from "hooks/useStores";
@@ -79,11 +78,9 @@ function AccountMenu(props: Props) {
const { auth, ui } = useStores();
const previousTheme = usePrevious(ui.theme);
const { t } = useTranslation();
const [
keyboardShortcutsOpen,
handleKeyboardShortcutsOpen,
handleKeyboardShortcutsClose,
] = useBoolean();
const [keyboardShortcutsOpen, setKeyboardShortcutsOpen] = React.useState(
false
);
React.useEffect(() => {
if (ui.theme !== previousTheme) {
@@ -95,7 +92,7 @@ function AccountMenu(props: Props) {
<>
<Guide
isOpen={keyboardShortcutsOpen}
onRequestClose={handleKeyboardShortcutsClose}
onRequestClose={() => setKeyboardShortcutsOpen(false)}
title={t("Keyboard shortcuts")}
>
<KeyboardShortcuts />
@@ -105,7 +102,7 @@ function AccountMenu(props: Props) {
<MenuItem {...menu} as={Link} to={settings()}>
{t("Settings")}
</MenuItem>
<MenuItem {...menu} onClick={handleKeyboardShortcutsOpen}>
<MenuItem {...menu} onClick={() => setKeyboardShortcutsOpen(true)}>
{t("Keyboard shortcuts")}
</MenuItem>
<MenuItem {...menu} href={developers()} target="_blank">
+3 -5
View File
@@ -15,7 +15,6 @@ import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template, { filterTemplateItems } from "components/ContextMenu/Template";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import getDataTransferFiles from "utils/getDataTransferFiles";
import { newDocumentUrl } from "utils/routeHelpers";
@@ -38,8 +37,7 @@ function CollectionMenu({
}: Props) {
const menu = useMenuState({ modal, placement });
const [renderModals, setRenderModals] = React.useState(false);
const { documents, policies } = useStores();
const { showToast } = useToasts();
const { ui, documents, policies } = useStores();
const { t } = useTranslation();
const history = useHistory();
@@ -101,14 +99,14 @@ function CollectionMenu({
});
history.push(document.url);
} catch (err) {
showToast(err.message, {
ui.showToast(err.message, {
type: "error",
});
throw err;
}
},
[history, showToast, collection.id, documents]
[history, ui, collection.id, documents]
);
const can = policies.abilities(collection.id);
+11 -13
View File
@@ -18,7 +18,6 @@ import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import getDataTransferFiles from "utils/getDataTransferFiles";
import {
documentHistoryUrl,
@@ -52,8 +51,7 @@ function DocumentMenu({
onOpen,
onClose,
}: Props) {
const { policies, collections, documents } = useStores();
const { showToast } = useToasts();
const { policies, collections, ui, documents } = useStores();
const menu = useMenuState({
modal,
unstable_preventOverflow: true,
@@ -85,33 +83,33 @@ function DocumentMenu({
// when duplicating, go straight to the duplicated document content
history.push(duped.url);
showToast(t("Document duplicated"), { type: "success" });
ui.showToast(t("Document duplicated"), { type: "success" });
},
[t, history, showToast, document]
[ui, t, history, document]
);
const handleArchive = React.useCallback(
async (ev: SyntheticEvent<>) => {
await document.archive();
showToast(t("Document archived"), { type: "success" });
ui.showToast(t("Document archived"), { type: "success" });
},
[showToast, t, document]
[ui, t, document]
);
const handleRestore = React.useCallback(
async (ev: SyntheticEvent<>, options?: { collectionId: string }) => {
await document.restore(options);
showToast(t("Document restored"), { type: "success" });
ui.showToast(t("Document restored"), { type: "success" });
},
[showToast, t, document]
[ui, t, document]
);
const handleUnpublish = React.useCallback(
async (ev: SyntheticEvent<>) => {
await document.unpublish();
showToast(t("Document unpublished"), { type: "success" });
ui.showToast(t("Document unpublished"), { type: "success" });
},
[showToast, t, document]
[ui, t, document]
);
const handlePrint = React.useCallback((ev: SyntheticEvent<>) => {
@@ -183,14 +181,14 @@ function DocumentMenu({
);
history.push(importedDocument.url);
} catch (err) {
showToast(err.message, {
ui.showToast(err.message, {
type: "error",
});
throw err;
}
},
[history, showToast, collection, documents, document.id]
[history, ui, collection, documents, document.id]
);
return (
+6 -6
View File
@@ -11,7 +11,7 @@ import MenuItem from "components/ContextMenu/MenuItem";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Separator from "components/ContextMenu/Separator";
import CopyToClipboard from "components/CopyToClipboard";
import useToasts from "hooks/useToasts";
import useStores from "hooks/useStores";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {|
@@ -22,7 +22,7 @@ type Props = {|
|};
function RevisionMenu({ document, revision, className, iconColor }: Props) {
const { showToast } = useToasts();
const { ui } = useStores();
const menu = useMenuState({ modal: true });
const { t } = useTranslation();
const history = useHistory();
@@ -31,15 +31,15 @@ function RevisionMenu({ document, revision, className, iconColor }: Props) {
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
await document.restore({ revisionId: revision.id });
showToast(t("Document restored"), { type: "success" });
ui.showToast(t("Document restored"), { type: "success" });
history.push(document.url);
},
[history, showToast, t, document, revision]
[history, ui, t, document, revision]
);
const handleCopy = React.useCallback(() => {
showToast(t("Link copied"), { type: "info" });
}, [showToast, t]);
ui.showToast(t("Link copied"), { type: "info" });
}, [ui, t]);
const url = `${window.location.origin}${documentHistoryUrl(
document,
+6 -8
View File
@@ -10,7 +10,6 @@ import MenuItem from "components/ContextMenu/MenuItem";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import CopyToClipboard from "components/CopyToClipboard";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {
share: Share,
@@ -18,8 +17,7 @@ type Props = {
function ShareMenu({ share }: Props) {
const menu = useMenuState({ modal: true });
const { shares, policies } = useStores();
const { showToast } = useToasts();
const { ui, shares, policies } = useStores();
const { t } = useTranslation();
const history = useHistory();
const can = policies.abilities(share.id);
@@ -38,17 +36,17 @@ function ShareMenu({ share }: Props) {
try {
await shares.revoke(share);
showToast(t("Share link revoked"), { type: "info" });
ui.showToast(t("Share link revoked"), { type: "info" });
} catch (err) {
showToast(err.message, { type: "error" });
ui.showToast(err.message, { type: "error" });
}
},
[t, shares, share, showToast]
[t, shares, share, ui]
);
const handleCopy = React.useCallback(() => {
showToast(t("Share link copied"), { type: "info" });
}, [t, showToast]);
ui.showToast(t("Share link copied"), { type: "info" });
}, [t, ui]);
return (
<>
-76
View File
@@ -1,76 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { TableOfContentsIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import Button from "components/Button";
import ContextMenu from "components/ContextMenu";
import Template from "components/ContextMenu/Template";
import { type MenuItem } from "types";
type Props = {|
headings: { title: string, level: number, id: string }[],
|};
function TableOfContentsMenu({ headings }: Props) {
const menu = useMenuState({
modal: true,
unstable_preventOverflow: true,
unstable_fixed: true,
unstable_flip: true,
});
const { t } = useTranslation();
const minHeading = headings.reduce(
(memo, heading) => (heading.level < memo ? heading.level : memo),
Infinity
);
const items: MenuItem[] = React.useMemo(() => {
let i = [
{
type: "heading",
visible: true,
title: t("Contents"),
},
...headings.map((heading) => ({
href: `#${heading.id}`,
title: t(heading.title),
level: heading.level - minHeading,
})),
];
if (i.length === 1) {
i.push({
href: "#",
title: t("Headings you add to the document will appear here"),
disabled: true,
});
}
return i;
}, [t, headings, minHeading]);
return (
<>
<MenuButton {...menu}>
{(props) => (
<Button
{...props}
icon={<TableOfContentsIcon />}
iconColor="currentColor"
borderOnHover
neutral
/>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Table of contents")}>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
}
export default observer(TableOfContentsMenu);
+4 -7
View File
@@ -40,13 +40,13 @@ function TemplatesMenu({ document }: Props) {
{...menu}
>
<DocumentIcon />
<TemplateItem>
<div>
<strong>{template.titleWithDefault}</strong>
<br />
<Author>
{t("By {{ author }}", { author: template.createdBy.name })}
</Author>
</TemplateItem>
</div>
</MenuItem>
);
@@ -70,12 +70,9 @@ function TemplatesMenu({ document }: Props) {
);
}
const TemplateItem = styled.div`
const Author = styled.div`
font-size: 13px;
text-align: left;
`;
const Author = styled.div`
font-size: 13px;
`;
export default observer(TemplatesMenu);
+6
View File
@@ -149,6 +149,12 @@ export default class Document extends BaseModel {
get isFromTemplate(): boolean {
return !!this.templateId;
}
@computed
get placeholder(): ?string {
return this.isTemplate ? "Start your template…" : "Start with a title…";
}
@action
share = async () => {
return this.store.rootStore.shares.create({ documentId: this.id });
+2 -2
View File
@@ -14,7 +14,7 @@ import Trash from "scenes/Trash";
import CenteredContent from "components/CenteredContent";
import Layout from "components/Layout";
import PlaceholderDocument from "components/PlaceholderDocument";
import LoadingPlaceholder from "components/LoadingPlaceholder";
import Route from "components/ProfiledRoute";
import SocketProvider from "components/SocketProvider";
import { matchDocumentSlug as slug } from "utils/routeHelpers";
@@ -43,7 +43,7 @@ export default function AuthenticatedRoutes() {
<React.Suspense
fallback={
<CenteredContent>
<PlaceholderDocument />
<LoadingPlaceholder />
</CenteredContent>
}
>
+4 -6
View File
@@ -6,7 +6,6 @@ import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Input from "components/Input";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
onSubmit: () => void,
@@ -15,8 +14,7 @@ type Props = {|
function APITokenNew({ onSubmit }: Props) {
const [name, setName] = React.useState("");
const [isSaving, setIsSaving] = React.useState(false);
const { apiKeys } = useStores();
const { showToast } = useToasts();
const { apiKeys, ui } = useStores();
const { t } = useTranslation();
const handleSubmit = React.useCallback(async () => {
@@ -24,14 +22,14 @@ function APITokenNew({ onSubmit }: Props) {
try {
await apiKeys.create({ name });
showToast(t("API token created", { type: "success" }));
ui.showToast(t("API token created", { type: "success" }));
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
ui.showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
}, [t, showToast, name, onSubmit, apiKeys]);
}, [t, ui, name, onSubmit, apiKeys]);
const handleNameChange = React.useCallback((event) => {
setName(event.target.value);
+15 -14
View File
@@ -27,11 +27,11 @@ import Flex from "components/Flex";
import Heading from "components/Heading";
import HelpText from "components/HelpText";
import InputSearchPage from "components/InputSearchPage";
import PlaceholderList from "components/List/Placeholder";
import LoadingIndicator from "components/LoadingIndicator";
import { ListPlaceholder } from "components/LoadingPlaceholder";
import Mask from "components/Mask";
import Modal from "components/Modal";
import PaginatedDocumentList from "components/PaginatedDocumentList";
import PlaceholderText from "components/PlaceholderText";
import Scene from "components/Scene";
import Subheading from "components/Subheading";
import Tab from "components/Tab";
@@ -39,11 +39,9 @@ import Tabs from "components/Tabs";
import Tooltip from "components/Tooltip";
import Collection from "../models/Collection";
import { updateCollectionUrl } from "../utils/routeHelpers";
import useBoolean from "hooks/useBoolean";
import useCurrentTeam from "hooks/useCurrentTeam";
import useImportDocument from "hooks/useImportDocument";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import CollectionMenu from "menus/CollectionMenu";
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
@@ -53,15 +51,10 @@ function CollectionScene() {
const match = useRouteMatch();
const { t } = useTranslation();
const { documents, policies, collections, ui } = useStores();
const { showToast } = useToasts();
const team = useCurrentTeam();
const [isFetching, setFetching] = React.useState();
const [error, setError] = React.useState();
const [
permissionsModalOpen,
handlePermissionsModalOpen,
handlePermissionsModalClose,
] = useBoolean();
const [permissionsModalOpen, setPermissionsModalOpen] = React.useState(false);
const id = params.id || "";
const collection: ?Collection =
@@ -109,12 +102,20 @@ function CollectionScene() {
load();
}, [collections, isFetching, collection, error, id, can]);
const handlePermissionsModalOpen = React.useCallback(() => {
setPermissionsModalOpen(true);
}, []);
const handlePermissionsModalClose = React.useCallback(() => {
setPermissionsModalOpen(false);
}, []);
const handleRejection = React.useCallback(() => {
showToast(
ui.showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"),
{ type: "error" }
);
}, [t, showToast]);
}, [t, ui]);
if (!collection && error) {
return <Search notFound />;
@@ -375,9 +376,9 @@ function CollectionScene() {
) : (
<CenteredContent>
<Heading>
<PlaceholderText height={35} />
<Mask height={35} />
</Heading>
<PlaceholderList count={5} />
<ListPlaceholder count={5} />
</CenteredContent>
);
}
+44 -42
View File
@@ -1,60 +1,62 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { withRouter, type RouterHistory } from "react-router-dom";
import CollectionsStore from "stores/CollectionsStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useToasts from "hooks/useToasts";
import { homeUrl } from "utils/routeHelpers";
type Props = {
history: RouterHistory,
collection: Collection,
collections: CollectionsStore,
ui: UiStore,
onSubmit: () => void,
};
function CollectionDelete({ collection, onSubmit }: Props) {
const [isDeleting, setIsDeleting] = React.useState();
const { showToast } = useToasts();
const history = useHistory();
const { t } = useTranslation();
@observer
class CollectionDelete extends React.Component<Props> {
@observable isDeleting: boolean;
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsDeleting(true);
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.isDeleting = true;
try {
await collection.delete();
history.push(homeUrl());
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsDeleting(false);
}
},
[showToast, onSubmit, collection, history]
);
try {
await this.props.collection.delete();
this.props.history.push(homeUrl());
this.props.onSubmit();
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isDeleting = false;
}
};
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans
defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash."
values={{ collectionName: collection.name }}
components={{ em: <strong /> }}
/>
</HelpText>
<Button type="submit" disabled={isDeleting} autoFocus danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure  Delete")}
</Button>
</form>
</Flex>
);
render() {
const { collection } = this.props;
return (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
Are you sure about that? Deleting the{" "}
<strong>{collection.name}</strong> collection is permanent and
cannot be restored, however documents within will be moved to the
trash.
</HelpText>
<Button type="submit" disabled={this.isDeleting} autoFocus danger>
{this.isDeleting ? "Deleting…" : "Im sure  Delete"}
</Button>
</form>
</Flex>
);
}
}
export default observer(CollectionDelete);
export default inject("collections", "ui")(withRouter(CollectionDelete));
+5 -5
View File
@@ -4,7 +4,7 @@ import { inject, observer } from "mobx-react";
import * as React from "react";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Button from "components/Button";
import Flex from "components/Flex";
@@ -16,7 +16,7 @@ import Switch from "components/Switch";
type Props = {
collection: Collection,
toasts: ToastsStore,
ui: UiStore,
auth: AuthStore,
onSubmit: () => void,
t: TFunction,
@@ -46,11 +46,11 @@ class CollectionEdit extends React.Component<Props> {
sort: this.sort,
});
this.props.onSubmit();
this.props.toasts.showToast(t("The collection was updated"), {
this.props.ui.showToast(t("The collection was updated"), {
type: "success",
});
} catch (err) {
this.props.toasts.showToast(err.message, { type: "error" });
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
@@ -148,5 +148,5 @@ class CollectionEdit extends React.Component<Props> {
}
export default withTranslation()<CollectionEdit>(
inject("toasts", "auth")(CollectionEdit)
inject("ui", "auth")(CollectionEdit)
);
+36 -32
View File
@@ -1,7 +1,9 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Button from "components/Button";
import Flex from "components/Flex";
@@ -9,41 +11,43 @@ import HelpText from "components/HelpText";
type Props = {
collection: Collection,
auth: AuthStore,
ui: UiStore,
onSubmit: () => void,
};
function CollectionExport({ collection, onSubmit }: Props) {
const [isLoading, setIsLoading] = React.useState();
const { t } = useTranslation();
@observer
class CollectionExport extends React.Component<Props> {
@observable isLoading: boolean = false;
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsLoading(true);
await collection.export();
setIsLoading(false);
onSubmit();
},
[collection, onSubmit]
);
this.isLoading = true;
await this.props.collection.export();
this.isLoading = false;
this.props.onSubmit();
};
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans
defaults="Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format."
values={{ collectionName: collection.name }}
components={{ em: <strong /> }}
/>
</HelpText>
<Button type="submit" disabled={isLoading} primary>
{isLoading ? `${t("Exporting")}` : t("Export Collection")}
</Button>
</form>
</Flex>
);
render() {
const { collection, auth } = this.props;
if (!auth.user) return null;
return (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
Exporting the collection <strong>{collection.name}</strong> may take
a few seconds. Your documents will be downloaded as a zip of folders
with files in Markdown format.
</HelpText>
<Button type="submit" disabled={this.isLoading} primary>
{this.isLoading ? "Exporting…" : "Export Collection"}
</Button>
</form>
</Flex>
);
}
}
export default observer(CollectionExport);
export default inject("ui", "auth")(CollectionExport);
+4 -4
View File
@@ -7,7 +7,7 @@ import { withTranslation, type TFunction, Trans } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import AuthStore from "stores/AuthStore";
import CollectionsStore from "stores/CollectionsStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Button from "components/Button";
import Flex from "components/Flex";
@@ -20,7 +20,7 @@ import Switch from "components/Switch";
type Props = {
history: RouterHistory,
auth: AuthStore,
toasts: ToastsStore,
ui: UiStore,
collections: CollectionsStore,
onSubmit: () => void,
t: TFunction,
@@ -55,7 +55,7 @@ class CollectionNew extends React.Component<Props> {
this.props.onSubmit();
this.props.history.push(collection.url);
} catch (err) {
this.props.toasts.showToast(err.message, { type: "error" });
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
@@ -169,5 +169,5 @@ class CollectionNew extends React.Component<Props> {
}
export default withTranslation()<CollectionNew>(
inject("collections", "toasts", "auth")(withRouter(CollectionNew))
inject("collections", "ui", "auth")(withRouter(CollectionNew))
);
@@ -8,7 +8,7 @@ import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import CollectionGroupMembershipsStore from "stores/CollectionGroupMembershipsStore";
import GroupsStore from "stores/GroupsStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Group from "models/Group";
import GroupNew from "scenes/GroupNew";
@@ -23,7 +23,7 @@ import Modal from "components/Modal";
import PaginatedList from "components/PaginatedList";
type Props = {
toasts: ToastsStore,
ui: UiStore,
auth: AuthStore,
collection: Collection,
collectionGroupMemberships: CollectionGroupMembershipsStore,
@@ -65,14 +65,14 @@ class AddGroupsToCollection extends React.Component<Props> {
groupId: group.id,
permission: "read_write",
});
this.props.toasts.showToast(
this.props.ui.showToast(
t("{{ groupName }} was added to the collection", {
groupName: group.name,
}),
{ type: "success" }
);
} catch (err) {
this.props.toasts.showToast(t("Could not add user"), { type: "error" });
this.props.ui.showToast(t("Could not add user"), { type: "error" });
console.error(err);
}
};
@@ -147,6 +147,6 @@ export default withTranslation()<AddGroupsToCollection>(
"auth",
"groups",
"collectionGroupMemberships",
"toasts"
"ui"
)(AddGroupsToCollection)
);
@@ -6,7 +6,7 @@ import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import MembershipsStore from "stores/MembershipsStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
import UsersStore from "stores/UsersStore";
import Collection from "models/Collection";
import User from "models/User";
@@ -21,7 +21,7 @@ import PaginatedList from "components/PaginatedList";
import MemberListItem from "./components/MemberListItem";
type Props = {
toasts: ToastsStore,
ui: UiStore,
auth: AuthStore,
collection: Collection,
memberships: MembershipsStore,
@@ -62,14 +62,14 @@ class AddPeopleToCollection extends React.Component<Props> {
userId: user.id,
permission: "read_write",
});
this.props.toasts.showToast(
this.props.ui.showToast(
t("{{ userName }} was added to the collection", {
userName: user.name,
}),
{ type: "success" }
);
} catch (err) {
this.props.toasts.showToast(t("Could not add user"), { type: "error" });
this.props.ui.showToast(t("Could not add user"), { type: "error" });
}
};
@@ -130,5 +130,5 @@ class AddPeopleToCollection extends React.Component<Props> {
}
export default withTranslation()<AddPeopleToCollection>(
inject("auth", "users", "memberships", "toasts")(AddPeopleToCollection)
inject("auth", "users", "memberships", "ui")(AddPeopleToCollection)
);
+24 -34
View File
@@ -17,10 +17,8 @@ import AddGroupsToCollection from "./AddGroupsToCollection";
import AddPeopleToCollection from "./AddPeopleToCollection";
import CollectionGroupMemberListItem from "./components/CollectionGroupMemberListItem";
import MemberListItem from "./components/MemberListItem";
import useBoolean from "hooks/useBoolean";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
collection: Collection,
@@ -30,22 +28,14 @@ function CollectionPermissions({ collection }: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const {
ui,
memberships,
collectionGroupMemberships,
users,
groups,
} = useStores();
const { showToast } = useToasts();
const [
addGroupModalOpen,
handleAddGroupModalOpen,
handleAddGroupModalClose,
] = useBoolean();
const [
addMemberModalOpen,
handleAddMemberModalOpen,
handleAddMemberModalClose,
] = useBoolean();
const [addGroupModalOpen, setAddGroupModalOpen] = React.useState(false);
const [addMemberModalOpen, setAddMemberModalOpen] = React.useState(false);
const handleRemoveUser = React.useCallback(
async (user) => {
@@ -54,7 +44,7 @@ function CollectionPermissions({ collection }: Props) {
collectionId: collection.id,
userId: user.id,
});
showToast(
ui.showToast(
t(`{{ userName }} was removed from the collection`, {
userName: user.name,
}),
@@ -63,10 +53,10 @@ function CollectionPermissions({ collection }: Props) {
}
);
} catch (err) {
showToast(t("Could not remove user"), { type: "error" });
ui.showToast(t("Could not remove user"), { type: "error" });
}
},
[memberships, showToast, collection, t]
[memberships, ui, collection, t]
);
const handleUpdateUser = React.useCallback(
@@ -77,17 +67,17 @@ function CollectionPermissions({ collection }: Props) {
userId: user.id,
permission,
});
showToast(
ui.showToast(
t(`{{ userName }} permissions were updated`, { userName: user.name }),
{
type: "success",
}
);
} catch (err) {
showToast(t("Could not update user"), { type: "error" });
ui.showToast(t("Could not update user"), { type: "error" });
}
},
[memberships, showToast, collection, t]
[memberships, ui, collection, t]
);
const handleRemoveGroup = React.useCallback(
@@ -97,7 +87,7 @@ function CollectionPermissions({ collection }: Props) {
collectionId: collection.id,
groupId: group.id,
});
showToast(
ui.showToast(
t(`The {{ groupName }} group was removed from the collection`, {
groupName: group.name,
}),
@@ -106,10 +96,10 @@ function CollectionPermissions({ collection }: Props) {
}
);
} catch (err) {
showToast(t("Could not remove group"), { type: "error" });
ui.showToast(t("Could not remove group"), { type: "error" });
}
},
[collectionGroupMemberships, showToast, collection, t]
[collectionGroupMemberships, ui, collection, t]
);
const handleUpdateGroup = React.useCallback(
@@ -120,7 +110,7 @@ function CollectionPermissions({ collection }: Props) {
groupId: group.id,
permission,
});
showToast(
ui.showToast(
t(`{{ groupName }} permissions were updated`, {
groupName: group.name,
}),
@@ -129,24 +119,24 @@ function CollectionPermissions({ collection }: Props) {
}
);
} catch (err) {
showToast(t("Could not update user"), { type: "error" });
ui.showToast(t("Could not update user"), { type: "error" });
}
},
[collectionGroupMemberships, showToast, collection, t]
[collectionGroupMemberships, ui, collection, t]
);
const handleChangePermission = React.useCallback(
async (ev) => {
try {
await collection.save({ permission: ev.target.value });
showToast(t("Default access permissions were updated"), {
ui.showToast(t("Default access permissions were updated"), {
type: "success",
});
} catch (err) {
showToast(t("Could not update permissions"), { type: "error" });
ui.showToast(t("Could not update permissions"), { type: "error" });
}
},
[collection, showToast, t]
[collection, ui, t]
);
const fetchOptions = React.useMemo(() => ({ id: collection.id }), [
@@ -193,7 +183,7 @@ function CollectionPermissions({ collection }: Props) {
<Actions>
<Button
type="button"
onClick={handleAddGroupModalOpen}
onClick={() => setAddGroupModalOpen(true)}
icon={<PlusIcon />}
neutral
>
@@ -201,7 +191,7 @@ function CollectionPermissions({ collection }: Props) {
</Button>{" "}
<Button
type="button"
onClick={handleAddMemberModalOpen}
onClick={() => setAddMemberModalOpen(true)}
icon={<PlusIcon />}
neutral
>
@@ -254,24 +244,24 @@ function CollectionPermissions({ collection }: Props) {
title={t(`Add groups to {{ collectionName }}`, {
collectionName: collection.name,
})}
onRequestClose={handleAddGroupModalClose}
onRequestClose={() => setAddGroupModalOpen(false)}
isOpen={addGroupModalOpen}
>
<AddGroupsToCollection
collection={collection}
onSubmit={handleAddGroupModalClose}
onSubmit={() => setAddGroupModalOpen(false)}
/>
</Modal>
<Modal
title={t(`Add people to {{ collectionName }}`, {
collectionName: collection.name,
})}
onRequestClose={handleAddMemberModalClose}
onRequestClose={() => setAddMemberModalOpen(false)}
isOpen={addMemberModalOpen}
>
<AddPeopleToCollection
collection={collection}
onSubmit={handleAddMemberModalClose}
onSubmit={() => setAddMemberModalOpen(false)}
/>
</Modal>
</Flex>
@@ -70,13 +70,11 @@ const Wrapper = styled("div")`
display: none;
position: sticky;
top: 80px;
max-height: calc(100vh - 80px);
box-shadow: 1px 0 0 ${(props) => props.theme.divider};
margin-top: 40px;
margin-right: 2em;
min-height: 40px;
overflow-y: auto;
${breakpoint("desktopLarge")`
margin-left: -16em;
+29 -57
View File
@@ -4,14 +4,12 @@ import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { InputIcon } from "outline-icons";
import * as React from "react";
import { type TFunction, Trans, withTranslation } from "react-i18next";
import keydown from "react-keydown";
import { Prompt, Route, withRouter } from "react-router-dom";
import type { RouterHistory, Match } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import AuthStore from "stores/AuthStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
import Document from "models/Document";
import Revision from "models/Revision";
@@ -20,10 +18,10 @@ import Branding from "components/Branding";
import ErrorBoundary from "components/ErrorBoundary";
import Flex from "components/Flex";
import LoadingIndicator from "components/LoadingIndicator";
import LoadingPlaceholder from "components/LoadingPlaceholder";
import Modal from "components/Modal";
import Notice from "components/Notice";
import PageTitle from "components/PageTitle";
import PlaceholderDocument from "components/PlaceholderDocument";
import Time from "components/Time";
import Container from "./Container";
import Contents from "./Contents";
@@ -46,6 +44,15 @@ import {
const AUTOSAVE_DELAY = 3000;
const IS_DIRTY_DELAY = 500;
const DISCARD_CHANGES = `
You have unsaved changes.
Are you sure you want to discard them?
`;
const UPLOADING_WARNING = `
Images are still uploading.
Are you sure you want to discard them?
`;
type Props = {
match: Match,
history: RouterHistory,
@@ -60,8 +67,6 @@ type Props = {
theme: Theme,
auth: AuthStore,
ui: UiStore,
toasts: ToastsStore,
t: TFunction,
};
@observer
@@ -76,12 +81,8 @@ class DocumentScene extends React.Component<Props> {
@observable title: string = this.props.document.title;
getEditorText: () => string = () => this.props.document.text;
componentDidMount() {
this.updateIsDirty();
}
componentDidUpdate(prevProps) {
const { auth, document, t } = this.props;
const { auth, document } = this.props;
if (prevProps.readOnly && !this.props.readOnly) {
this.updateIsDirty();
@@ -95,10 +96,8 @@ class DocumentScene extends React.Component<Props> {
}
} else if (prevProps.document.revision !== this.lastRevision) {
if (auth.user && document.updatedBy.id !== auth.user.id) {
this.props.toasts.showToast(
t(`Document updated by {{userName}}`, {
userName: document.updatedBy.name,
}),
this.props.ui.showToast(
`Document updated by ${document.updatedBy.name}`,
{
timeout: 30 * 1000,
type: "warning",
@@ -117,7 +116,6 @@ class DocumentScene extends React.Component<Props> {
document.injectTemplate = false;
this.title = document.title;
this.isDirty = true;
this.updateIsDirty();
}
}
@@ -239,7 +237,7 @@ class DocumentScene extends React.Component<Props> {
this.props.ui.setActiveDocument(savedDocument);
}
} catch (err) {
this.props.toasts.showToast(err.message, { type: "error" });
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
this.isPublishing = false;
@@ -304,7 +302,6 @@ class DocumentScene extends React.Component<Props> {
auth,
ui,
match,
t,
} = this.props;
const team = auth.team;
const { shareId } = match.params;
@@ -354,15 +351,11 @@ class DocumentScene extends React.Component<Props> {
<>
<Prompt
when={this.isDirty && !this.isUploading}
message={t(
`You have unsaved changes.\nAre you sure you want to discard them?`
)}
message={DISCARD_CHANGES}
/>
<Prompt
when={this.isUploading && !this.isDirty}
message={t(
`Images are still uploading.\nAre you sure you want to discard them?`
)}
message={UPLOADING_WARNING}
/>
</>
)}
@@ -381,7 +374,6 @@ class DocumentScene extends React.Component<Props> {
sharedTree={this.props.sharedTree}
goBack={this.goBack}
onSave={this.onSave}
headings={headings}
/>
<MaxWidth
archived={document.isArchived}
@@ -391,51 +383,33 @@ class DocumentScene extends React.Component<Props> {
>
{document.isTemplate && !readOnly && (
<Notice muted>
<Trans>
Youre editing a template. Highlight some text and use the{" "}
<PlaceholderIcon color="currentColor" /> control to add
placeholders that can be filled out when creating new
documents from this template.
</Trans>
Youre editing a template. Highlight some text and use the{" "}
<PlaceholderIcon color="currentColor" /> control to add
placeholders that can be filled out when creating new
documents from this template.
</Notice>
)}
{document.archivedAt && !document.deletedAt && (
<Notice muted>
{t("Archived by {{userName}}", {
userName: document.updatedBy.name,
})}{" "}
<Time dateTime={document.updatedAt} addSuffix />
Archived by {document.updatedBy.name}{" "}
<Time dateTime={document.archivedAt} /> ago
</Notice>
)}
{document.deletedAt && (
<Notice muted>
<strong>
{t("Deleted by {{userName}}", {
userName: document.updatedBy.name,
})}{" "}
<Time dateTime={document.deletedAt || ""} addSuffix />
</strong>
Deleted by {document.updatedBy.name}{" "}
<Time dateTime={document.deletedAt} /> ago
{document.permanentlyDeletedAt && (
<>
<br />
{document.template ? (
<Trans>
This template will be permanently deleted in{" "}
<Time dateTime={document.permanentlyDeletedAt} />{" "}
unless restored.
</Trans>
) : (
<Trans>
This document will be permanently deleted in{" "}
<Time dateTime={document.permanentlyDeletedAt} />{" "}
unless restored.
</Trans>
)}
This {document.noun} will be permanently deleted in{" "}
<Time dateTime={document.permanentlyDeletedAt} /> unless
restored.
</>
)}
</Notice>
)}
<React.Suspense fallback={<PlaceholderDocument />}>
<React.Suspense fallback={<LoadingPlaceholder />}>
<Flex auto={!readOnly}>
{showContents && <Contents headings={headings} />}
<Editor
@@ -533,7 +507,5 @@ const MaxWidth = styled(Flex)`
`;
export default withRouter(
withTranslation()<DocumentScene>(
inject("ui", "auth", "toasts")(DocumentScene)
)
inject("ui", "auth", "policies", "revisions")(DocumentScene)
);
+6 -21
View File
@@ -1,15 +1,13 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import Textarea from "react-autosize-textarea";
import { type TFunction, withTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { MAX_TITLE_LENGTH } from "shared/constants";
import { light } from "shared/theme";
import { light } from "shared/styles/theme";
import parseTitle from "shared/utils/parseTitle";
import PoliciesStore from "stores/PoliciesStore";
import Document from "models/Document";
import ClickablePadding from "components/ClickablePadding";
import DocumentMetaWithViews from "components/DocumentMetaWithViews";
@@ -30,8 +28,6 @@ type Props = {|
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
innerRef: { current: any },
children: React.Node,
policies: PoliciesStore,
t: TFunction,
|};
@observer
@@ -106,12 +102,9 @@ class DocumentEditor extends React.Component<Props> {
readOnly,
innerRef,
children,
policies,
t,
...rest
} = this.props;
const can = policies.abilities(document.id);
const { emoji } = parseTitle(title);
const startsWithEmojiAndSpace = !!(emoji && title.startsWith(`${emoji} `));
const normalizedTitle =
@@ -128,9 +121,7 @@ class DocumentEditor extends React.Component<Props> {
dir="auto"
>
<span>{normalizedTitle}</span>{" "}
{(can.star || can.unstar) && (
<StarButton document={document} size={32} />
)}
{!shareId && <StarButton document={document} size={32} />}
</Title>
) : (
<Title
@@ -138,11 +129,7 @@ class DocumentEditor extends React.Component<Props> {
ref={this.ref}
onChange={onChangeTitle}
onKeyDown={this.handleTitleKeyDown}
placeholder={
document.isTemplate
? t("Start your template…")
: t("Start with a title…")
}
placeholder={document.placeholder}
value={normalizedTitle}
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
autoFocus={!title}
@@ -165,7 +152,7 @@ class DocumentEditor extends React.Component<Props> {
<Editor
ref={innerRef}
autoFocus={!!title && !this.props.defaultValue}
placeholder={t("…the rest is up to you")}
placeholder="…the rest is up to you"
onHoverLink={this.handleLinkActive}
scrollTo={window.location.hash}
readOnly={readOnly}
@@ -237,6 +224,4 @@ const Title = styled(Textarea)`
}
`;
export default withTranslation()<DocumentEditor>(
inject("policies")(DocumentEditor)
);
export default DocumentEditor;
+1 -14
View File
@@ -24,7 +24,6 @@ import useMobile from "hooks/useMobile";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
import TableOfContentsMenu from "menus/TableOfContentsMenu";
import TemplatesMenu from "menus/TemplatesMenu";
import { type NavigationNode } from "types";
import { metaDisplay } from "utils/keyboard";
@@ -47,7 +46,6 @@ type Props = {|
publish?: boolean,
autosave?: boolean,
}) => void,
headings: { title: string, level: number, id: string }[],
|};
function DocumentHeader({
@@ -62,7 +60,6 @@ function DocumentHeader({
publishingIsDisabled,
sharedTree,
onSave,
headings,
}: Props) {
const { t } = useTranslation();
const { auth, ui, policies } = useStores();
@@ -76,7 +73,7 @@ function DocumentHeader({
onSave({ done: true, publish: true });
}, [onSave]);
const isNew = document.isNewDocument;
const isNew = document.isNew;
const isTemplate = document.isTemplate;
const can = policies.abilities(document.id);
const canShareDocument = auth.team && auth.team.sharing && can.share;
@@ -156,11 +153,6 @@ function DocumentHeader({
}
actions={
<>
{isMobile && (
<TocWrapper>
<TableOfContentsMenu headings={headings} />
</TocWrapper>
)}
{!isPublishing && isSaving && <Status>{t("Saving")}</Status>}
<Collaborators
document={document}
@@ -282,9 +274,4 @@ const Status = styled(Action)`
color: ${(props) => props.theme.slate};
`;
const TocWrapper = styled(Action)`
position: absolute;
left: 42px;
`;
export default observer(DocumentHeader);
@@ -8,15 +8,20 @@ import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import Guide from "components/Guide";
import NudeButton from "components/NudeButton";
import Tooltip from "components/Tooltip";
import useBoolean from "hooks/useBoolean";
function KeyboardShortcutsButton() {
const { t } = useTranslation();
const [
keyboardShortcutsOpen,
handleOpenKeyboardShortcuts,
handleCloseKeyboardShortcuts,
] = useBoolean();
const [keyboardShortcutsOpen, setKeyboardShortcutsOpen] = React.useState(
false
);
const handleCloseKeyboardShortcuts = React.useCallback(() => {
setKeyboardShortcutsOpen(false);
}, []);
const handleOpenKeyboardShortcuts = React.useCallback(() => {
setKeyboardShortcutsOpen(true);
}, []);
return (
<>
+2 -2
View File
@@ -2,8 +2,8 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import CenteredContent from "components/CenteredContent";
import LoadingPlaceholder from "components/LoadingPlaceholder";
import PageTitle from "components/PageTitle";
import PlaceholderDocument from "components/PlaceholderDocument";
import Container from "./Container";
import type { LocationWithState } from "types";
@@ -20,7 +20,7 @@ export default function Loading({ location }: Props) {
title={location.state ? location.state.title : t("Untitled")}
/>
<CenteredContent>
<PlaceholderDocument />
<LoadingPlaceholder />
</CenteredContent>
</Container>
);
@@ -16,7 +16,6 @@ import Input from "components/Input";
import Notice from "components/Notice";
import Switch from "components/Switch";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
document: Document,
@@ -27,8 +26,7 @@ type Props = {|
function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
const { t } = useTranslation();
const { policies, shares } = useStores();
const { showToast } = useToasts();
const { policies, shares, ui } = useStores();
const [isCopied, setIsCopied] = React.useState(false);
const timeout = React.useRef<?TimeoutID>();
const can = policies.abilities(share ? share.id : "");
@@ -48,10 +46,10 @@ function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
try {
await share.save({ published: event.currentTarget.checked });
} catch (err) {
showToast(err.message, { type: "error" });
ui.showToast(err.message, { type: "error" });
}
},
[document.id, shares, showToast]
[document.id, shares, ui]
);
const handleChildDocumentsChange = React.useCallback(
@@ -64,10 +62,10 @@ function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
includeChildDocuments: event.currentTarget.checked,
});
} catch (err) {
showToast(err.message, { type: "error" });
ui.showToast(err.message, { type: "error" });
}
},
[document.id, shares, showToast]
[document.id, shares, ui]
);
const handleCopied = React.useCallback(() => {
@@ -77,9 +75,9 @@ function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
setIsCopied(false);
onSubmit();
showToast(t("Share link copied"), { type: "info" });
ui.showToast(t("Share link copied"), { type: "info" });
}, 250);
}, [t, onSubmit, showToast]);
}, [t, onSubmit, ui]);
return (
<>
+1 -2
View File
@@ -8,7 +8,6 @@ import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import { collectionUrl, documentUrl } from "utils/routeHelpers";
type Props = {
@@ -22,7 +21,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
const history = useHistory();
const [isDeleting, setDeleting] = React.useState(false);
const [isArchiving, setArchiving] = React.useState(false);
const { showToast } = useToasts();
const { showToast } = ui;
const canArchive = !document.isDraft && !document.isArchived;
const collection = collections.get(document.collectionId);
+86 -75
View File
@@ -1,34 +1,37 @@
// @flow
import { Search } from "js-search";
import { last } from "lodash";
import { observer } from "mobx-react";
import { observable, computed } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import styled from "styled-components";
import { type DocumentPath } from "stores/CollectionsStore";
import CollectionsStore, { type DocumentPath } from "stores/CollectionsStore";
import DocumentsStore from "stores/DocumentsStore";
import UiStore from "stores/UiStore";
import Document from "models/Document";
import Flex from "components/Flex";
import { Outline } from "components/Input";
import Labeled from "components/Labeled";
import PathToDocument from "components/PathToDocument";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
document: Document,
documents: DocumentsStore,
collections: CollectionsStore,
ui: UiStore,
onRequestClose: () => void,
|};
function DocumentMove({ document, onRequestClose }: Props) {
const [searchTerm, setSearchTerm] = useState();
const { collections, documents } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
@observer
class DocumentMove extends React.Component<Props> {
@observable searchTerm: ?string;
@observable isSaving: boolean;
const searchIndex = useMemo(() => {
@computed
get searchIndex() {
const { collections, documents } = this.props;
const paths = collections.pathsToDocuments;
const index = new Search("id");
index.addIndex("title");
@@ -44,16 +47,19 @@ function DocumentMove({ document, onRequestClose }: Props) {
index.addDocuments(indexeableDocuments);
return index;
}, [documents, collections.pathsToDocuments]);
}
const results: DocumentPath[] = useMemo(() => {
@computed
get results(): DocumentPath[] {
const { document, collections } = this.props;
const onlyShowCollections = document.isTemplate;
let results = [];
if (collections.isLoaded) {
if (searchTerm) {
results = searchIndex.search(searchTerm);
if (this.searchTerm) {
results = this.searchIndex.search(this.searchTerm);
} else {
results = searchIndex._documents;
results = this.searchIndex._documents;
}
}
@@ -76,18 +82,19 @@ function DocumentMove({ document, onRequestClose }: Props) {
}
return results;
}, [document, collections, searchTerm, searchIndex]);
}
const handleSuccess = () => {
showToast(t("Document moved"), { type: "info" });
onRequestClose();
handleSuccess = () => {
this.props.ui.showToast("Document moved", { type: "info" });
this.props.onRequestClose();
};
const handleFilter = (ev: SyntheticInputEvent<*>) => {
setSearchTerm(ev.target.value);
handleFilter = (ev: SyntheticInputEvent<*>) => {
this.searchTerm = ev.target.value;
};
const renderPathToCurrentDocument = () => {
renderPathToCurrentDocument() {
const { collections, document } = this.props;
const result = collections.getPathForDocument(document.id);
if (result) {
@@ -98,71 +105,75 @@ function DocumentMove({ document, onRequestClose }: Props) {
/>
);
}
};
}
const row = ({ index, data, style }) => {
row = ({ index, data, style }) => {
const result = data[index];
const { document, collections } = this.props;
return (
<PathToDocument
result={result}
document={document}
collection={collections.get(result.collectionId)}
onSuccess={handleSuccess}
onSuccess={this.handleSuccess}
style={style}
/>
);
};
const data = results;
render() {
const { document, collections } = this.props;
const data = this.results;
if (!document || !collections.isLoaded) {
return null;
if (!document || !collections.isLoaded) {
return null;
}
return (
<Flex column>
<Section>
<Labeled label="Current location">
{this.renderPathToCurrentDocument()}
</Labeled>
</Section>
<Section column>
<Labeled label="Choose a new location" />
<NewLocation>
<InputWrapper>
<Input
type="search"
placeholder="Search collections & documents…"
onChange={this.handleFilter}
required
autoFocus
/>
</InputWrapper>
<Results>
<AutoSizer>
{({ width, height }) => (
<Flex role="listbox" column>
<List
key={data.length}
width={width}
height={height}
itemData={data}
itemCount={data.length}
itemSize={40}
itemKey={(index, data) => data[index].id}
>
{this.row}
</List>
</Flex>
)}
</AutoSizer>
</Results>
</NewLocation>
</Section>
</Flex>
);
}
return (
<Flex column>
<Section>
<Labeled label={t("Current location")}>
{renderPathToCurrentDocument()}
</Labeled>
</Section>
<Section column>
<Labeled label={t("Choose a new location")} />
<NewLocation>
<InputWrapper>
<Input
type="search"
placeholder={`${t("Search collections & documents")}`}
onChange={handleFilter}
required
autoFocus
/>
</InputWrapper>
<Results>
<AutoSizer>
{({ width, height }) => (
<Flex role="listbox" column>
<List
key={data.length}
width={width}
height={height}
itemData={data}
itemCount={data.length}
itemSize={40}
itemKey={(index, data) => data[index].id}
>
{row}
</List>
</Flex>
)}
</AutoSizer>
</Results>
</NewLocation>
</Section>
</Flex>
);
}
const InputWrapper = styled("div")`
@@ -199,4 +210,4 @@ const Section = styled(Flex)`
margin-bottom: 24px;
`;
export default observer(DocumentMove);
export default inject("documents", "collections", "ui")(DocumentMove);
+4 -6
View File
@@ -7,9 +7,8 @@ import { useTranslation } from "react-i18next";
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
import CenteredContent from "components/CenteredContent";
import Flex from "components/Flex";
import PlaceholderDocument from "components/PlaceholderDocument";
import LoadingPlaceholder from "components/LoadingPlaceholder";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import { editDocumentUrl } from "utils/routeHelpers";
function DocumentNew() {
@@ -17,8 +16,7 @@ function DocumentNew() {
const location = useLocation();
const match = useRouteMatch();
const { t } = useTranslation();
const { documents, collections } = useStores();
const { showToast } = useToasts();
const { documents, ui, collections } = useStores();
const id = match.params.id || "";
useEffect(() => {
@@ -38,7 +36,7 @@ function DocumentNew() {
history.replace(editDocumentUrl(document));
} catch (err) {
showToast(t("Couldnt create the document, try again?"), {
ui.showToast(t("Couldnt create the document, try again?"), {
type: "error",
});
history.goBack();
@@ -50,7 +48,7 @@ function DocumentNew() {
return (
<Flex column auto>
<CenteredContent>
<PlaceholderDocument />
<LoadingPlaceholder />
</CenteredContent>
</Flex>
);
+2 -3
View File
@@ -8,7 +8,6 @@ import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
document: Document,
@@ -18,8 +17,8 @@ type Props = {|
function DocumentPermanentDelete({ document, onSubmit }: Props) {
const [isDeleting, setIsDeleting] = React.useState(false);
const { t } = useTranslation();
const { documents } = useStores();
const { showToast } = useToasts();
const { ui, documents } = useStores();
const { showToast } = ui;
const history = useHistory();
const handleSubmit = React.useCallback(
+45 -46
View File
@@ -1,64 +1,63 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { withRouter, type RouterHistory } from "react-router-dom";
import UiStore from "stores/UiStore";
import Document from "models/Document";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useToasts from "hooks/useToasts";
import { documentUrl } from "utils/routeHelpers";
type Props = {
ui: UiStore,
document: Document,
history: RouterHistory,
onSubmit: () => void,
};
function DocumentTemplatize({ document, onSubmit }: Props) {
const [isSaving, setIsSaving] = useState();
const history = useHistory();
const { showToast } = useToasts();
const { t } = useTranslation();
@observer
class DocumentTemplatize extends React.Component<Props> {
@observable isSaving: boolean;
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsSaving(true);
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.isSaving = true;
try {
const template = await document.templatize();
history.push(documentUrl(template));
showToast(t("Template created, go ahead and customize it"), {
type: "info",
});
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
},
[document, showToast, history, onSubmit, t]
);
try {
const template = await this.props.document.templatize();
this.props.history.push(documentUrl(template));
this.props.ui.showToast("Template created, go ahead and customize it", {
type: "info",
});
this.props.onSubmit();
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
};
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans
defaults="Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents."
values={{ titleWithDefault: document.titleWithDefault }}
components={{ em: <strong /> }}
/>
</HelpText>
<Button type="submit">
{isSaving ? `${t("Creating")}` : t("Create template")}
</Button>
</form>
</Flex>
);
render() {
const { document } = this.props;
return (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
Creating a template from{" "}
<strong>{document.titleWithDefault}</strong> is a non-destructive
action we'll make a copy of the document and turn it into a
template that can be used as a starting point for new documents.
</HelpText>
<Button type="submit">
{this.isSaving ? "Creating…" : "Create template"}
</Button>
</form>
</Flex>
);
}
}
export default observer(DocumentTemplatize);
export default inject("ui")(withRouter(DocumentTemplatize));
+37 -35
View File
@@ -1,57 +1,59 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { withRouter, type RouterHistory } from "react-router-dom";
import { groupSettings } from "shared/utils/routeHelpers";
import UiStore from "stores/UiStore";
import Group from "models/Group";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useToasts from "hooks/useToasts";
type Props = {|
type Props = {
history: RouterHistory,
group: Group,
ui: UiStore,
onSubmit: () => void,
|};
};
function GroupDelete({ group, onSubmit }: Props) {
const { t } = useTranslation();
const { showToast } = useToasts();
const history = useHistory();
const [isDeleting, setIsDeleting] = React.useState();
@observer
class GroupDelete extends React.Component<Props> {
@observable isDeleting: boolean;
const handleSubmit = async (ev: SyntheticEvent<>) => {
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsDeleting(true);
this.isDeleting = true;
try {
await group.delete();
history.push(groupSettings());
onSubmit();
await this.props.group.delete();
this.props.history.push(groupSettings());
this.props.onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
this.props.ui.showToast(err.message, { type: "error" });
} finally {
setIsDeleting(false);
this.isDeleting = false;
}
};
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans
defaults="Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with."
values={{ groupName: group.name }}
components={{ em: <strong /> }}
/>
</HelpText>
<Button type="submit" danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure  Delete")}
</Button>
</form>
</Flex>
);
render() {
const { group } = this.props;
return (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
Are you sure about that? Deleting the <strong>{group.name}</strong>{" "}
group will cause its members to lose access to collections and
documents that it is associated with.
</HelpText>
<Button type="submit" danger>
{this.isDeleting ? "Deleting…" : "Im sure  Delete"}
</Button>
</form>
</Flex>
);
}
}
export default observer(GroupDelete);
export default inject("ui")(withRouter(GroupDelete));
+48 -49
View File
@@ -1,71 +1,70 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import UiStore from "stores/UiStore";
import Group from "models/Group";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Input from "components/Input";
import useToasts from "hooks/useToasts";
type Props = {
history: RouterHistory,
ui: UiStore,
group: Group,
onSubmit: () => void,
};
function GroupEdit({ group, onSubmit }: Props) {
const { showToast } = useToasts();
const { t } = useTranslation();
const [name, setName] = React.useState(group.name);
const [isSaving, setIsSaving] = React.useState();
@observer
class GroupEdit extends React.Component<Props> {
@observable name: string = this.props.group.name;
@observable isSaving: boolean;
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsSaving(true);
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.isSaving = true;
try {
await group.save({ name: name });
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
},
[group, onSubmit, showToast, name]
);
try {
await this.props.group.save({ name: this.name });
this.props.onSubmit();
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
};
const handleNameChange = React.useCallback((ev: SyntheticInputEvent<*>) => {
setName(ev.target.value);
}, []);
handleNameChange = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
};
return (
<form onSubmit={handleSubmit}>
<HelpText>
<Trans>
render() {
return (
<form onSubmit={this.handleSubmit}>
<HelpText>
You can edit the name of this group at any time, however doing so too
often might confuse your team mates.
</Trans>
</HelpText>
<Flex>
<Input
type="text"
label={t("Name")}
onChange={handleNameChange}
value={name}
required
autoFocus
flex
/>
</Flex>
</HelpText>
<Flex>
<Input
type="text"
label="Name"
onChange={this.handleNameChange}
value={this.name}
required
autoFocus
flex
/>
</Flex>
<Button type="submit" disabled={isSaving || !name}>
{isSaving ? `${t("Saving")}` : t("Save")}
</Button>
</form>
);
<Button type="submit" disabled={this.isSaving || !this.name}>
{this.isSaving ? "Saving" : "Save"}
</Button>
</form>
);
}
}
export default observer(GroupEdit);
export default inject("ui")(withRouter(GroupEdit));
+5 -5
View File
@@ -6,7 +6,7 @@ import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import GroupMembershipsStore from "stores/GroupMembershipsStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
import UsersStore from "stores/UsersStore";
import Group from "models/Group";
import User from "models/User";
@@ -21,7 +21,7 @@ import PaginatedList from "components/PaginatedList";
import GroupMemberListItem from "./components/GroupMemberListItem";
type Props = {
toasts: ToastsStore,
ui: UiStore,
auth: AuthStore,
group: Group,
groupMemberships: GroupMembershipsStore,
@@ -62,12 +62,12 @@ class AddPeopleToGroup extends React.Component<Props> {
groupId: this.props.group.id,
userId: user.id,
});
this.props.toasts.showToast(
this.props.ui.showToast(
t(`{{userName}} was added to the group`, { userName: user.name }),
{ type: "success" }
);
} catch (err) {
this.props.toasts.showToast(t("Could not add user"), { type: "error" });
this.props.ui.showToast(t("Could not add user"), { type: "error" });
}
};
@@ -128,5 +128,5 @@ class AddPeopleToGroup extends React.Component<Props> {
}
export default withTranslation()<AddPeopleToGroup>(
inject("auth", "users", "groupMemberships", "toasts")(AddPeopleToGroup)
inject("auth", "users", "groupMemberships", "ui")(AddPeopleToGroup)
);
+95 -78
View File
@@ -1,8 +1,14 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { withTranslation, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import GroupMembershipsStore from "stores/GroupMembershipsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import UsersStore from "stores/UsersStore";
import Group from "models/Group";
import User from "models/User";
import Button from "components/Button";
@@ -14,101 +20,112 @@ import PaginatedList from "components/PaginatedList";
import Subheading from "components/Subheading";
import AddPeopleToGroup from "./AddPeopleToGroup";
import GroupMemberListItem from "./components/GroupMemberListItem";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {
ui: UiStore,
auth: AuthStore,
group: Group,
users: UsersStore,
policies: PoliciesStore,
groupMemberships: GroupMembershipsStore,
t: TFunction,
};
function GroupMembers({ group }: Props) {
const [addModalOpen, setAddModalOpen] = React.useState();
const { users, groupMemberships, policies } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const can = policies.abilities(group.id);
@observer
class GroupMembers extends React.Component<Props> {
@observable addModalOpen: boolean = false;
const handleAddModal = (state) => {
setAddModalOpen(state);
handleAddModalOpen = () => {
this.addModalOpen = true;
};
const handleRemoveUser = async (user: User) => {
handleAddModalClose = () => {
this.addModalOpen = false;
};
handleRemoveUser = async (user: User) => {
const { t } = this.props;
try {
await groupMemberships.delete({
groupId: group.id,
await this.props.groupMemberships.delete({
groupId: this.props.group.id,
userId: user.id,
});
showToast(
this.props.ui.showToast(
t(`{{userName}} was removed from the group`, { userName: user.name }),
{ type: "success" }
);
} catch (err) {
showToast(t("Could not remove user"), { type: "error" });
this.props.ui.showToast(t("Could not remove user"), { type: "error" });
}
};
return (
<Flex column>
{can.update ? (
<>
<HelpText>
<Trans
defaults="Add and remove team members in the <em>{{groupName}}</em> group. Adding people to the group will give them access to any collections this group has been added to."
values={{ groupName: group.name }}
components={{ em: <strong /> }}
/>
</HelpText>
<span>
<Button
type="button"
onClick={() => handleAddModal(true)}
icon={<PlusIcon />}
neutral
>
{t("Add people")}
</Button>
</span>
</>
) : (
<HelpText>
<Trans
defaults="Listing team members in the <em>{{groupName}}</em> group."
values={{ groupName: group.name }}
components={{ em: <strong /> }}
/>
</HelpText>
)}
render() {
const { group, users, groupMemberships, policies, t, auth } = this.props;
const { user } = auth;
if (!user) return null;
<Subheading>
<Trans>Members</Trans>
</Subheading>
<PaginatedList
items={users.inGroup(group.id)}
fetch={groupMemberships.fetchPage}
options={{ id: group.id }}
empty={<Empty>{t("This group has no members.")}</Empty>}
renderItem={(item) => (
<GroupMemberListItem
key={item.id}
user={item}
onRemove={can.update ? () => handleRemoveUser(item) : undefined}
/>
const can = policies.abilities(group.id);
return (
<Flex column>
{can.update ? (
<>
<HelpText>
Add and remove team members in the <strong>{group.name}</strong>{" "}
group. Adding people to the group will give them access to any
collections this group has been added to.
</HelpText>
<span>
<Button
type="button"
onClick={this.handleAddModalOpen}
icon={<PlusIcon />}
neutral
>
{t("Add people")}
</Button>
</span>
</>
) : (
<HelpText>
Listing team members in the <strong>{group.name}</strong> group.
</HelpText>
)}
/>
{can.update && (
<Modal
title={t(`Add people to {{groupName}}`, { groupName: group.name })}
onRequestClose={() => handleAddModal(false)}
isOpen={addModalOpen}
>
<AddPeopleToGroup
group={group}
onSubmit={() => handleAddModal(false)}
/>
</Modal>
)}
</Flex>
);
<Subheading>Members</Subheading>
<PaginatedList
items={users.inGroup(group.id)}
fetch={groupMemberships.fetchPage}
options={{ id: group.id }}
empty={<Empty>{t("This group has no members.")}</Empty>}
renderItem={(item) => (
<GroupMemberListItem
key={item.id}
user={item}
onRemove={
can.update ? () => this.handleRemoveUser(item) : undefined
}
/>
)}
/>
{can.update && (
<Modal
title={`Add people to ${group.name}`}
onRequestClose={this.handleAddModalClose}
isOpen={this.addModalOpen}
>
<AddPeopleToGroup
group={group}
onSubmit={this.handleAddModalClose}
/>
</Modal>
)}
</Flex>
);
}
}
export default observer(GroupMembers);
export default withTranslation()<GroupMembers>(
inject("auth", "users", "policies", "groupMemberships", "ui")(GroupMembers)
);
+55 -55
View File
@@ -1,7 +1,10 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import GroupsStore from "stores/GroupsStore";
import UiStore from "stores/UiStore";
import Group from "models/Group";
import GroupMembers from "scenes/GroupMembers";
import Button from "components/Button";
@@ -9,82 +12,79 @@ import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Input from "components/Input";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {
history: RouterHistory,
ui: UiStore,
groups: GroupsStore,
onSubmit: () => void,
};
function GroupNew({ onSubmit }: Props) {
const { groups } = useStores();
const { t } = useTranslation();
const { showToast } = useToasts();
const [name, setName] = React.useState();
const [isSaving, setIsSaving] = React.useState();
const [group, setGroup] = React.useState();
@observer
class GroupNew extends React.Component<Props> {
@observable name: string = "";
@observable isSaving: boolean;
@observable group: Group;
const handleSubmit = async (ev: SyntheticEvent<>) => {
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsSaving(true);
this.isSaving = true;
const group = new Group(
{
name: name,
name: this.name,
},
groups
this.props.groups
);
try {
setGroup(await group.save());
this.group = await group.save();
} catch (err) {
showToast(err.message, { type: "error" });
this.props.ui.showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
this.isSaving = false;
}
};
const handleNameChange = (ev: SyntheticInputEvent<*>) => {
setName(ev.target.value);
handleNameChange = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
};
return (
<>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans>
render() {
return (
<>
<form onSubmit={this.handleSubmit}>
<HelpText>
Groups are for organizing your team. They work best when centered
around a function or a responsibility Support or Engineering for
example.
</Trans>
</HelpText>
<Flex>
<Input
type="text"
label="Name"
onChange={handleNameChange}
value={name}
required
autoFocus
flex
/>
</Flex>
<HelpText>
<Trans>Youll be able to add people to the group next.</Trans>
</HelpText>
</HelpText>
<Flex>
<Input
type="text"
label="Name"
onChange={this.handleNameChange}
value={this.name}
required
autoFocus
flex
/>
</Flex>
<HelpText>Youll be able to add people to the group next.</HelpText>
<Button type="submit" disabled={isSaving || !name}>
{isSaving ? `${t("Creating")}` : t("Continue")}
</Button>
</form>
<Modal
title={t("Group members")}
onRequestClose={onSubmit}
isOpen={!!group}
>
<GroupMembers group={group} />
</Modal>
</>
);
<Button type="submit" disabled={this.isSaving || !this.name}>
{this.isSaving ? "Creating" : "Continue"}
</Button>
</form>
<Modal
title="Group members"
onRequestClose={this.props.onSubmit}
isOpen={!!this.group}
>
<GroupMembers group={this.group} />
</Modal>
</>
);
}
}
export default observer(GroupNew);
export default inject("groups", "ui")(withRouter(GroupNew));
+165 -175
View File
@@ -1,10 +1,14 @@
// @flow
import { observer } from "mobx-react";
import { observable, action } from "mobx";
import { inject, observer } from "mobx-react";
import { LinkIcon, CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
import { Link, withRouter, type RouterHistory } from "react-router-dom";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import UsersStore from "stores/UsersStore";
import Button from "components/Button";
import CopyToClipboard from "components/CopyToClipboard";
import Flex from "components/Flex";
@@ -12,212 +16,198 @@ import HelpText from "components/HelpText";
import Input from "components/Input";
import NudeButton from "components/NudeButton";
import Tooltip from "components/Tooltip";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
const MAX_INVITES = 20;
type Props = {|
type Props = {
auth: AuthStore,
users: UsersStore,
history: RouterHistory,
policies: PoliciesStore,
ui: UiStore,
onSubmit: () => void,
|};
};
type InviteRequest = {
email: string,
name: string,
};
function Invite({ onSubmit }: Props) {
const [isSaving, setIsSaving] = React.useState();
const [linkCopied, setLinkCopied] = React.useState<boolean>(false);
const [invites, setInvites] = React.useState<InviteRequest[]>([
@observer
class Invite extends React.Component<Props> {
@observable isSaving: boolean;
@observable linkCopied: boolean = false;
@observable
invites: InviteRequest[] = [
{ email: "", name: "" },
{ email: "", name: "" },
{ email: "", name: "" },
]);
];
const { users, policies } = useStores();
const { showToast } = useToasts();
const user = useCurrentUser();
const team = useCurrentTeam();
const { t } = useTranslation();
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.isSaving = true;
const predictedDomain = user.email.split("@")[1];
const can = policies.abilities(team.id);
try {
await this.props.users.invite(this.invites);
this.props.onSubmit();
this.props.ui.showToast("We sent out your invites!", { type: "success" });
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
};
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsSaving(true);
@action
handleChange = (ev, index) => {
this.invites[index][ev.target.name] = ev.target.value;
};
try {
await users.invite(invites);
onSubmit();
showToast(t("We sent out your invites!"), { type: "success" });
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
},
[onSubmit, showToast, invites, t, users]
);
@action
handleGuestChange = (ev, index) => {
this.invites[index][ev.target.name] = ev.target.checked;
};
const handleChange = React.useCallback((ev, index) => {
setInvites((prevInvites) => {
const newInvites = [...prevInvites];
newInvites[index][ev.target.name] = ev.target.value;
return newInvites;
});
}, []);
const handleAdd = React.useCallback(() => {
if (invites.length >= MAX_INVITES) {
showToast(
t("Sorry, you can only send {{MAX_INVITES}} invites at a time", {
MAX_INVITES,
}),
@action
handleAdd = () => {
if (this.invites.length >= MAX_INVITES) {
this.props.ui.showToast(
`Sorry, you can only send ${MAX_INVITES} invites at a time`,
{ type: "warning" }
);
}
setInvites((prevInvites) => {
const newInvites = [...prevInvites];
newInvites.push({ email: "", name: "" });
return newInvites;
});
}, [showToast, invites, t]);
this.invites.push({ email: "", name: "" });
};
const handleRemove = React.useCallback(
(ev: SyntheticEvent<>, index: number) => {
ev.preventDefault();
@action
handleRemove = (ev: SyntheticEvent<>, index: number) => {
ev.preventDefault();
this.invites.splice(index, 1);
};
setInvites((prevInvites) => {
const newInvites = [...prevInvites];
newInvites.splice(index, 1);
return newInvites;
});
},
[]
);
const handleCopy = React.useCallback(() => {
setLinkCopied(true);
showToast(t("Share link copied"), {
handleCopy = () => {
this.linkCopied = true;
this.props.ui.showToast("Share link copied", {
type: "success",
});
}, [showToast, t]);
};
return (
<form onSubmit={handleSubmit}>
{team.guestSignin ? (
<HelpText>
<Trans
defaults="Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address."
values={{ signinMethods: team.signinMethods }}
/>
</HelpText>
) : (
<HelpText>
<Trans
defaults="Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}."
values={{ signinMethods: team.signinMethods }}
/>{" "}
{can.update && (
<Trans>
As an admin you can also{" "}
<Link to="/settings/security">enable email sign-in</Link>.
</Trans>
)}
</HelpText>
)}
{team.subdomain && (
<CopyBlock>
<Flex align="flex-end">
render() {
const { team, user } = this.props.auth;
if (!team || !user) return null;
const predictedDomain = user.email.split("@")[1];
const can = this.props.policies.abilities(team.id);
return (
<form onSubmit={this.handleSubmit}>
{team.guestSignin ? (
<HelpText>
Invite team members or guests to join your knowledge base. Team
members can sign in with {team.signinMethods} or use their email
address.
</HelpText>
) : (
<HelpText>
Invite team members to join your knowledge base. They will need to
sign in with {team.signinMethods}.{" "}
{can.update && (
<>
As an admin you can also{" "}
<Link to="/settings/security">enable email sign-in</Link>.
</>
)}
</HelpText>
)}
{team.subdomain && (
<CopyBlock>
<Flex align="flex-end">
<Input
type="text"
value={team.url}
label="Want a link to share directly with your team?"
readOnly
flex
/>
&nbsp;&nbsp;
<CopyToClipboard text={team.url} onCopy={this.handleCopy}>
<Button
type="button"
icon={<LinkIcon />}
style={{ marginBottom: "16px" }}
neutral
>
{this.linkCopied ? "Link copied" : "Copy link"}
</Button>
</CopyToClipboard>
</Flex>
<p>
<hr />
</p>
</CopyBlock>
)}
{this.invites.map((invite, index) => (
<Flex key={index}>
<Input
type="text"
value={team.url}
label={t("Want a link to share directly with your team?")}
readOnly
type="email"
name="email"
label="Email"
labelHidden={index !== 0}
onChange={(ev) => this.handleChange(ev, index)}
placeholder={`example@${predictedDomain}`}
value={invite.email}
required={index === 0}
autoFocus={index === 0}
flex
/>
&nbsp;&nbsp;
<CopyToClipboard text={team.url} onCopy={handleCopy}>
<Button
type="button"
icon={<LinkIcon />}
style={{ marginBottom: "16px" }}
neutral
>
{linkCopied ? t("Link copied") : t("Copy link")}
</Button>
</CopyToClipboard>
<Input
type="text"
name="name"
label="Full name"
labelHidden={index !== 0}
onChange={(ev) => this.handleChange(ev, index)}
value={invite.name}
required={!!invite.email}
flex
/>
{index !== 0 && (
<Remove>
<Tooltip tooltip="Remove invite" placement="top">
<NudeButton onClick={(ev) => this.handleRemove(ev, index)}>
<CloseIcon />
</NudeButton>
</Tooltip>
</Remove>
)}
</Flex>
<p>
<hr />
</p>
</CopyBlock>
)}
{invites.map((invite, index) => (
<Flex key={index}>
<Input
type="email"
name="email"
label={t("Email")}
labelHidden={index !== 0}
onChange={(ev) => handleChange(ev, index)}
placeholder={`example@${predictedDomain}`}
value={invite.email}
required={index === 0}
autoFocus={index === 0}
flex
/>
&nbsp;&nbsp;
<Input
type="text"
name="name"
label={t("Full name")}
labelHidden={index !== 0}
onChange={(ev) => handleChange(ev, index)}
value={invite.name}
required={!!invite.email}
flex
/>
{index !== 0 && (
<Remove>
<Tooltip tooltip={t("Remove invite")} placement="top">
<NudeButton onClick={(ev) => handleRemove(ev, index)}>
<CloseIcon />
</NudeButton>
</Tooltip>
</Remove>
))}
<Flex justify="space-between">
{this.invites.length <= MAX_INVITES ? (
<Button type="button" onClick={this.handleAdd} neutral>
Add another
</Button>
) : (
<span />
)}
</Flex>
))}
<Flex justify="space-between">
{invites.length <= MAX_INVITES ? (
<Button type="button" onClick={handleAdd} neutral>
<Trans>Add another</Trans>
<Button
type="submit"
disabled={this.isSaving}
data-on="click"
data-event-category="invite"
data-event-action="sendInvites"
>
{this.isSaving ? "Inviting…" : "Send Invites"}
</Button>
) : (
<span />
)}
<Button
type="submit"
disabled={isSaving}
data-on="click"
data-event-category="invite"
data-event-action="sendInvites"
>
{isSaving ? `${t("Inviting")}` : t("Send Invites")}
</Button>
</Flex>
<br />
</form>
);
</Flex>
<br />
</form>
);
}
}
const CopyBlock = styled("div")`
@@ -231,4 +221,4 @@ const Remove = styled("div")`
right: -32px;
`;
export default observer(Invite);
export default inject("auth", "users", "policies", "ui")(withRouter(Invite));
+124 -116
View File
@@ -1,10 +1,11 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { TeamIcon } from "outline-icons";
import * as React from "react";
import { useRef, useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
import Button from "components/Button";
import Flex from "components/Flex";
import Heading from "components/Heading";
@@ -13,130 +14,137 @@ import Input, { LabelText } from "components/Input";
import Scene from "components/Scene";
import ImageUpload from "./components/ImageUpload";
import env from "env";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
function Details() {
const { auth } = useStores();
const { showToast } = useToasts();
const team = useCurrentTeam();
const { t } = useTranslation();
const form = useRef<?HTMLFormElement>();
const [name, setName] = useState(team.name);
const [subdomain, setSubdomain] = useState(team.subdomain);
const [avatarUrl, setAvatarUrl] = useState();
type Props = {
auth: AuthStore,
ui: UiStore,
};
const handleSubmit = React.useCallback(
async (event: ?SyntheticEvent<>) => {
if (event) {
event.preventDefault();
}
@observer
class Details extends React.Component<Props> {
timeout: TimeoutID;
form: ?HTMLFormElement;
try {
await auth.updateTeam({
name,
avatarUrl,
subdomain,
});
showToast(t("Settings saved"), { type: "success" });
} catch (err) {
showToast(err.message, { type: "error" });
}
},
[auth, showToast, name, avatarUrl, subdomain, t]
);
@observable name: string;
@observable subdomain: ?string;
@observable avatarUrl: ?string;
const handleNameChange = React.useCallback((ev: SyntheticInputEvent<*>) => {
setName(ev.target.value);
}, []);
componentDidMount() {
const { team } = this.props.auth;
if (team) {
this.name = team.name;
this.subdomain = team.subdomain;
}
}
const handleSubdomainChange = React.useCallback(
(ev: SyntheticInputEvent<*>) => {
setSubdomain(ev.target.value.toLowerCase());
},
[]
);
componentWillUnmount() {
clearTimeout(this.timeout);
}
const handleAvatarUpload = React.useCallback(
(avatarUrl: string) => {
setAvatarUrl(avatarUrl);
handleSubmit();
},
[handleSubmit]
);
handleSubmit = async (event: ?SyntheticEvent<>) => {
if (event) {
event.preventDefault();
}
const handleAvatarError = React.useCallback(
(error: ?string) => {
showToast(error || t("Unable to upload new logo"));
},
[showToast, t]
);
try {
await this.props.auth.updateTeam({
name: this.name,
avatarUrl: this.avatarUrl,
subdomain: this.subdomain,
});
this.props.ui.showToast("Settings saved", { type: "success" });
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
}
};
const isValid = form.current && form.current.checkValidity();
handleNameChange = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
};
return (
<Scene title={t("Details")} icon={<TeamIcon color="currentColor" />}>
<Heading>{t("Details")}</Heading>
<HelpText>
<Trans>
handleSubdomainChange = (ev: SyntheticInputEvent<*>) => {
this.subdomain = ev.target.value.toLowerCase();
};
handleAvatarUpload = (avatarUrl: string) => {
this.avatarUrl = avatarUrl;
this.handleSubmit();
};
handleAvatarError = (error: ?string) => {
this.props.ui.showToast(error || "Unable to upload new logo");
};
get isValid() {
return this.form && this.form.checkValidity();
}
render() {
const { team, isSaving } = this.props.auth;
if (!team) return null;
const avatarUrl = this.avatarUrl || team.avatarUrl;
return (
<Scene title="Details" icon={<TeamIcon color="currentColor" />}>
<Heading>Details</Heading>
<HelpText>
These details affect the way that your Outline appears to everyone on
the team.
</Trans>
</HelpText>
</HelpText>
<ProfilePicture column>
<LabelText>{t("Logo")}</LabelText>
<AvatarContainer>
<ImageUpload
onSuccess={handleAvatarUpload}
onError={handleAvatarError}
submitText={t("Crop logo")}
borderRadius={0}
>
<Avatar src={avatarUrl} />
<Flex auto align="center" justify="center">
<Trans>Upload</Trans>
</Flex>
</ImageUpload>
</AvatarContainer>
</ProfilePicture>
<form onSubmit={handleSubmit} ref={form}>
<Input
label={t("Name")}
name="name"
autoComplete="organization"
value={name}
onChange={handleNameChange}
required
short
/>
{env.SUBDOMAINS_ENABLED && (
<>
<Input
label={t("Subdomain")}
name="subdomain"
value={subdomain || ""}
onChange={handleSubdomainChange}
autoComplete="off"
minLength={4}
maxLength={32}
short
/>
{subdomain && (
<HelpText small>
<Trans>Your knowledge base will be accessible at</Trans>{" "}
<strong>{subdomain}.getoutline.com</strong>
</HelpText>
)}
</>
)}
<Button type="submit" disabled={auth.isSaving || !isValid}>
{auth.isSaving ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
);
<ProfilePicture column>
<LabelText>Logo</LabelText>
<AvatarContainer>
<ImageUpload
onSuccess={this.handleAvatarUpload}
onError={this.handleAvatarError}
submitText="Crop logo"
borderRadius={0}
>
<Avatar src={avatarUrl} />
<Flex auto align="center" justify="center">
Upload
</Flex>
</ImageUpload>
</AvatarContainer>
</ProfilePicture>
<form onSubmit={this.handleSubmit} ref={(ref) => (this.form = ref)}>
<Input
label="Name"
name="name"
autoComplete="organization"
value={this.name}
onChange={this.handleNameChange}
required
short
/>
{env.SUBDOMAINS_ENABLED && (
<>
<Input
label="Subdomain"
name="subdomain"
value={this.subdomain || ""}
onChange={this.handleSubdomainChange}
autoComplete="off"
minLength={4}
maxLength={32}
short
/>
{this.subdomain && (
<HelpText small>
Your knowledge base will be accessible at{" "}
<strong>{this.subdomain}.getoutline.com</strong>
</HelpText>
)}
</>
)}
<Button type="submit" disabled={isSaving || !this.isValid}>
{isSaving ? "Saving" : "Save"}
</Button>
</form>
</Scene>
);
}
}
const ProfilePicture = styled(Flex)`
@@ -178,4 +186,4 @@ const Avatar = styled.img`
${avatarStyles};
`;
export default observer(Details);
export default inject("auth", "ui")(Details);
+9 -6
View File
@@ -14,7 +14,6 @@ import Modal from "components/Modal";
import PaginatedList from "components/PaginatedList";
import Scene from "components/Scene";
import Subheading from "components/Subheading";
import useBoolean from "hooks/useBoolean";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import GroupMenu from "menus/GroupMenu";
@@ -24,11 +23,15 @@ function Groups() {
const { policies, groups } = useStores();
const team = useCurrentTeam();
const can = policies.abilities(team.id);
const [
newGroupModalOpen,
handleNewGroupModalOpen,
handleNewGroupModalClose,
] = useBoolean();
const [newGroupModalOpen, setNewGroupModalOpen] = React.useState(false);
const handleNewGroupModalOpen = React.useCallback(() => {
setNewGroupModalOpen(true);
}, []);
const handleNewGroupModalClose = React.useCallback(() => {
setNewGroupModalOpen(false);
}, []);
return (
<Scene
+2 -3
View File
@@ -14,7 +14,6 @@ import Notice from "components/Notice";
import Scene from "components/Scene";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import getDataTransferFiles from "utils/getDataTransferFiles";
import { uploadFile } from "utils/uploadFile";
@@ -22,8 +21,8 @@ function ImportExport() {
const { t } = useTranslation();
const user = useCurrentUser();
const fileRef = React.useRef();
const { collections } = useStores();
const { showToast } = useToasts();
const { ui, collections } = useStores();
const { showToast } = ui;
const [isLoading, setLoading] = React.useState(false);
const [isImporting, setImporting] = React.useState(false);
const [isImported, setImported] = React.useState(false);
+105 -110
View File
@@ -1,142 +1,137 @@
// @flow
import { debounce } from "lodash";
import { observer } from "mobx-react";
import { observer, inject } from "mobx-react";
import { EmailIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import NotificationSettingsStore from "stores/NotificationSettingsStore";
import UiStore from "stores/UiStore";
import Heading from "components/Heading";
import HelpText from "components/HelpText";
import Input from "components/Input";
import Notice from "components/Notice";
import Scene from "components/Scene";
import Subheading from "components/Subheading";
import NotificationListItem from "./components/NotificationListItem";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
function Notifications() {
const { notificationSettings } = useStores();
const { showToast } = useToasts();
const user = useCurrentUser();
const { t } = useTranslation();
type Props = {
ui: UiStore,
auth: AuthStore,
notificationSettings: NotificationSettingsStore,
};
const options = [
{
event: "documents.publish",
title: t("Document published"),
description: t(
"Receive a notification whenever a new document is published"
),
},
{
event: "documents.update",
title: t("Document updated"),
description: t(
"Receive a notification when a document you created is edited"
),
},
{
event: "collections.create",
title: t("Collection created"),
description: t(
"Receive a notification whenever a new collection is created"
),
},
{
separator: true,
},
{
event: "emails.onboarding",
title: t("Getting started"),
description: t(
"Tips on getting started with Outline`s features and functionality"
),
},
{
event: "emails.features",
title: t("New features"),
description: t("Receive an email when new features of note are added"),
},
];
const options = [
{
event: "documents.publish",
title: "Document published",
description: "Receive a notification whenever a new document is published",
},
{
event: "documents.update",
title: "Document updated",
description: "Receive a notification when a document you created is edited",
},
{
event: "collections.create",
title: "Collection created",
description: "Receive a notification whenever a new collection is created",
},
{
separator: true,
},
{
event: "emails.onboarding",
title: "Getting started",
description:
"Tips on getting started with Outline`s features and functionality",
},
{
event: "emails.features",
title: "New features",
description: "Receive an email when new features of note are added",
},
];
React.useEffect(() => {
notificationSettings.fetchPage();
}, [notificationSettings]);
@observer
class Notifications extends React.Component<Props> {
componentDidMount() {
this.props.notificationSettings.fetchPage();
}
const showSuccessMessage = debounce(() => {
showToast(t("Notifications saved"), { type: "success" });
handleChange = async (ev: SyntheticInputEvent<>) => {
const { notificationSettings } = this.props;
const setting = notificationSettings.getByEvent(ev.target.name);
if (ev.target.checked) {
await notificationSettings.save({
event: ev.target.name,
});
} else if (setting) {
await notificationSettings.delete(setting);
}
this.showSuccessMessage();
};
showSuccessMessage = debounce(() => {
this.props.ui.showToast("Notifications saved", { type: "success" });
}, 500);
const handleChange = React.useCallback(
async (ev: SyntheticInputEvent<>) => {
const setting = notificationSettings.getByEvent(ev.target.name);
render() {
const { notificationSettings, auth } = this.props;
const showSuccessNotice = window.location.search === "?success";
const { user, team } = auth;
if (!team || !user) return null;
if (ev.target.checked) {
await notificationSettings.save({
event: ev.target.name,
});
} else if (setting) {
await notificationSettings.delete(setting);
}
showSuccessMessage();
},
[notificationSettings, showSuccessMessage]
);
const showSuccessNotice = window.location.search === "?success";
return (
<Scene title={t("Notifications")} icon={<EmailIcon color="currentColor" />}>
{showSuccessNotice && (
<Notice>
<Trans>
return (
<Scene title="Notifications" icon={<EmailIcon color="currentColor" />}>
{showSuccessNotice && (
<Notice>
Unsubscription successful. Your notification settings were updated
</Trans>
</Notice>
)}
<Heading>{t("Notifications")}</Heading>
<HelpText>
<Trans>
</Notice>
)}
<Heading>Notifications</Heading>
<HelpText>
Manage when and where you receive email notifications from Outline.
Your email address can be updated in your SSO provider.
</Trans>
</HelpText>
<Input
type="email"
value={user.email}
label={t("Email address")}
readOnly
short
/>
</HelpText>
<Subheading>{t("Notifications")}</Subheading>
<Input
type="email"
value={user.email}
label="Email address"
readOnly
short
/>
{options.map((option, index) => {
if (option.separator) return <Separator key={`separator-${index}`} />;
<Subheading>Notifications</Subheading>
const setting = notificationSettings.getByEvent(option.event);
{options.map((option, index) => {
if (option.separator) return <Separator key={`separator-${index}`} />;
return (
<NotificationListItem
key={option.event}
onChange={handleChange}
setting={setting}
disabled={
(setting && setting.isSaving) || notificationSettings.isFetching
}
{...option}
/>
);
})}
</Scene>
);
const setting = notificationSettings.getByEvent(option.event);
return (
<NotificationListItem
key={option.event}
onChange={this.handleChange}
setting={setting}
disabled={
(setting && setting.isSaving) || notificationSettings.isFetching
}
{...option}
/>
);
})}
</Scene>
);
}
}
const Separator = styled.hr`
padding-bottom: 12px;
`;
export default observer(Notifications);
export default inject("notificationSettings", "auth", "ui")(Notifications);
+9 -6
View File
@@ -18,7 +18,6 @@ import Modal from "components/Modal";
import Scene from "components/Scene";
import PeopleTable from "./components/PeopleTable";
import UserStatusFilter from "./components/UserStatusFilter";
import useBoolean from "hooks/useBoolean";
import useCurrentTeam from "hooks/useCurrentTeam";
import useQuery from "hooks/useQuery";
import useStores from "hooks/useStores";
@@ -27,11 +26,7 @@ function People(props) {
const topRef = React.useRef();
const location = useLocation();
const history = useHistory();
const [
inviteModalOpen,
handleInviteModalOpen,
handleInviteModalClose,
] = useBoolean();
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
const team = useCurrentTeam();
const { users, policies } = useStores();
const { t } = useTranslation();
@@ -101,6 +96,14 @@ function People(props) {
userIds,
]);
const handleInviteModalOpen = React.useCallback(() => {
setInviteModalOpen(true);
}, []);
const handleInviteModalClose = React.useCallback(() => {
setInviteModalOpen(false);
}, []);
const handleFilter = React.useCallback(
(filter) => {
if (filter) {
+6 -8
View File
@@ -7,7 +7,7 @@ import { Trans, withTranslation, type TFunction } from "react-i18next";
import styled from "styled-components";
import { languageOptions } from "shared/i18n";
import AuthStore from "stores/AuthStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
import UserDelete from "scenes/UserDelete";
import Button from "components/Button";
import Flex from "components/Flex";
@@ -20,7 +20,7 @@ import ImageUpload from "./components/ImageUpload";
type Props = {
auth: AuthStore,
toasts: ToastsStore,
ui: UiStore,
t: TFunction,
};
@@ -55,7 +55,7 @@ class Profile extends React.Component<Props> {
language: this.language,
});
this.props.toasts.showToast(t("Profile saved"), { type: "success" });
this.props.ui.showToast(t("Profile saved"), { type: "success" });
};
handleNameChange = (ev: SyntheticInputEvent<*>) => {
@@ -69,14 +69,12 @@ class Profile extends React.Component<Props> {
await this.props.auth.updateUser({
avatarUrl: this.avatarUrl,
});
this.props.toasts.showToast(t("Profile picture updated"), {
type: "success",
});
this.props.ui.showToast(t("Profile picture updated"), { type: "success" });
};
handleAvatarError = (error: ?string) => {
const { t } = this.props;
this.props.toasts.showToast(
this.props.ui.showToast(
error || t("Unable to upload new profile picture"),
{ type: "error" }
);
@@ -215,4 +213,4 @@ const Avatar = styled.img`
${avatarStyles};
`;
export default withTranslation()<Profile>(inject("auth", "toasts")(Profile));
export default withTranslation()<Profile>(inject("auth", "ui")(Profile));
+80 -79
View File
@@ -1,96 +1,97 @@
// @flow
import { debounce } from "lodash";
import { observer } from "mobx-react";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { PadlockIcon } from "outline-icons";
import * as React from "react";
import { useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
import Checkbox from "components/Checkbox";
import Heading from "components/Heading";
import HelpText from "components/HelpText";
import Scene from "components/Scene";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
function Security() {
const { auth } = useStores();
const team = useCurrentTeam();
const { t } = useTranslation();
const { showToast } = useToasts();
const [sharing, setSharing] = useState(team.documentEmbeds);
const [documentEmbeds, setDocumentEmbeds] = useState(team.guestSignin);
const [guestSignin, setGuestSignin] = useState(team.sharing);
type Props = {
auth: AuthStore,
ui: UiStore,
};
const showSuccessMessage = debounce(() => {
showToast(t("Settings saved"), { type: "success" });
@observer
class Security extends React.Component<Props> {
form: ?HTMLFormElement;
@observable sharing: boolean;
@observable documentEmbeds: boolean;
@observable guestSignin: boolean;
componentDidMount() {
const { auth } = this.props;
if (auth.team) {
this.documentEmbeds = auth.team.documentEmbeds;
this.guestSignin = auth.team.guestSignin;
this.sharing = auth.team.sharing;
}
}
handleChange = async (ev: SyntheticInputEvent<*>) => {
switch (ev.target.name) {
case "sharing":
this.sharing = ev.target.checked;
break;
case "documentEmbeds":
this.documentEmbeds = ev.target.checked;
break;
case "guestSignin":
this.guestSignin = ev.target.checked;
break;
default:
}
await this.props.auth.updateTeam({
sharing: this.sharing,
documentEmbeds: this.documentEmbeds,
guestSignin: this.guestSignin,
});
this.showSuccessMessage();
};
showSuccessMessage = debounce(() => {
this.props.ui.showToast("Settings saved", { type: "success" });
}, 500);
const handleChange = React.useCallback(
async (ev: SyntheticInputEvent<*>) => {
switch (ev.target.name) {
case "sharing":
setSharing(ev.target.checked);
break;
case "documentEmbeds":
setDocumentEmbeds(ev.target.checked);
break;
case "guestSignin":
setGuestSignin(ev.target.checked);
break;
default:
}
await auth.updateTeam({
sharing,
documentEmbeds,
guestSignin,
});
showSuccessMessage();
},
[auth, sharing, documentEmbeds, guestSignin, showSuccessMessage]
);
return (
<Scene title={t("Security")} icon={<PadlockIcon color="currentColor" />}>
<Heading>
<Trans>Security</Trans>
</Heading>
<HelpText>
<Trans>
render() {
return (
<Scene title="Security" icon={<PadlockIcon color="currentColor" />}>
<Heading>Security</Heading>
<HelpText>
Settings that impact the access, security, and content of your
knowledge base.
</Trans>
</HelpText>
</HelpText>
<Checkbox
label={t("Allow email authentication")}
name="guestSignin"
checked={guestSignin}
onChange={handleChange}
note={t("When enabled, users can sign-in using their email address")}
/>
<Checkbox
label={t("Public document sharing")}
name="sharing"
checked={sharing}
onChange={handleChange}
note={t(
"When enabled, documents can be shared publicly on the internet by any team member"
)}
/>
<Checkbox
label={t("Rich service embeds")}
name="documentEmbeds"
checked={documentEmbeds}
onChange={handleChange}
note={t(
"Links to supported services are shown as rich embeds within your documents"
)}
/>
</Scene>
);
<Checkbox
label="Allow email authentication"
name="guestSignin"
checked={this.guestSignin}
onChange={this.handleChange}
note="When enabled, users can sign-in using their email address"
/>
<Checkbox
label="Public document sharing"
name="sharing"
checked={this.sharing}
onChange={this.handleChange}
note="When enabled, documents can be shared publicly on the internet by any team member"
/>
<Checkbox
label="Rich service embeds"
name="documentEmbeds"
checked={this.documentEmbeds}
onChange={this.handleChange}
note="Links to supported services are shown as rich embeds within your documents"
/>
</Scene>
);
}
}
export default observer(Security);
export default inject("auth", "ui")(Security);
+9 -2
View File
@@ -13,7 +13,6 @@ import PaginatedList from "components/PaginatedList";
import Scene from "components/Scene";
import Subheading from "components/Subheading";
import TokenListItem from "./components/TokenListItem";
import useBoolean from "hooks/useBoolean";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
@@ -21,9 +20,17 @@ function Tokens() {
const team = useCurrentTeam();
const { t } = useTranslation();
const { apiKeys, policies } = useStores();
const [newModalOpen, handleNewModalOpen, handleNewModalClose] = useBoolean();
const [newModalOpen, setNewModalOpen] = React.useState(false);
const can = policies.abilities(team.id);
const handleNewModalOpen = React.useCallback(() => {
setNewModalOpen(true);
}, []);
const handleNewModalClose = React.useCallback(() => {
setNewModalOpen(false);
}, []);
return (
<Scene
title={t("API Tokens")}
+6 -10
View File
@@ -1,6 +1,5 @@
// @flow
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import Button from "components/Button";
import Heading from "components/Heading";
import HelpText from "components/HelpText";
@@ -8,16 +7,13 @@ import Scene from "components/Scene";
import ZapierIcon from "components/ZapierIcon";
function Zapier() {
const { t } = useTranslation();
return (
<Scene title={t("Zapier")} icon={<ZapierIcon color="currentColor" />}>
<Heading>{t("Zapier")}</Heading>
<Scene title="Zapier" icon={<ZapierIcon color="currentColor" />}>
<Heading>Zapier</Heading>
<HelpText>
<Trans>
Zapier is a platform that allows Outline to easily integrate with
thousands of other business tools. Head over to Zapier to setup a
"Zap" and start programmatically interacting with Outline.'
</Trans>
Zapier is a platform that allows Outline to easily integrate with
thousands of other business tools. Head over to Zapier to setup a "Zap"
and start programmatically interacting with Outline.
</HelpText>
<p>
<Button
@@ -25,7 +21,7 @@ function Zapier() {
(window.location.href = "https://zapier.com/apps/outline")
}
>
{t("Open Zapier")}
Open Zapier
</Button>
</p>
</Scene>
+45 -48
View File
@@ -1,66 +1,63 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
type Props = {
auth: AuthStore,
ui: UiStore,
onRequestClose: () => void,
|};
};
function UserDelete({ onRequestClose }: Props) {
const [isDeleting, setIsDeleting] = React.useState();
const { auth } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
@observer
class UserDelete extends React.Component<Props> {
@observable isDeleting: boolean;
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsDeleting(true);
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.isDeleting = true;
try {
await auth.deleteUser();
auth.logout();
} catch (error) {
showToast(error.message, { type: "error" });
} finally {
setIsDeleting(false);
}
},
[auth, showToast]
);
try {
await this.props.auth.deleteUser();
this.props.auth.logout();
} catch (error) {
this.props.ui.showToast(error.message, { type: "error" });
} finally {
this.isDeleting = false;
}
};
return (
<Modal isOpen title={t("Delete Account")} onRequestClose={onRequestClose}>
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans>
render() {
const { onRequestClose } = this.props;
return (
<Modal isOpen title="Delete Account" onRequestClose={onRequestClose}>
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
Are you sure? Deleting your account will destroy identifying data
associated with your user and cannot be undone. You will be
immediately logged out of Outline and all your API tokens will be
revoked.
</Trans>
</HelpText>
<HelpText>
<Trans
defaults="<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned."
components={{ em: <strong /> }}
/>
</HelpText>
<Button type="submit" danger>
{isDeleting ? `${t("Deleting")}` : t("Delete My Account")}
</Button>
</form>
</Flex>
</Modal>
);
</HelpText>
<HelpText>
<strong>Note:</strong> Signing back in will cause a new account to
be automatically reprovisioned.
</HelpText>
<Button type="submit" danger>
{this.isDeleting ? "Deleting…" : "Delete My Account"}
</Button>
</form>
</Flex>
</Modal>
);
}
}
export default observer(UserDelete);
export default inject("auth", "ui")(UserDelete);
+11 -27
View File
@@ -14,12 +14,6 @@ import { getCookieDomain } from "utils/domains";
const AUTH_STORE = "AUTH_STORE";
const NO_REDIRECT_PATHS = ["/", "/create", "/home"];
type PersistedData = {
user?: User,
team?: Team,
policies?: Policy[],
};
type Provider = {|
id: string,
name: string,
@@ -36,7 +30,6 @@ export default class AuthStore {
@observable user: ?User;
@observable team: ?Team;
@observable token: ?string;
@observable policies: Policy[] = [];
@observable lastSignedIn: ?string;
@observable isSaving: boolean = false;
@observable isSuspended: boolean = false;
@@ -48,7 +41,7 @@ export default class AuthStore {
this.rootStore = rootStore;
// attempt to load the previous state of this store from localstorage
let data: PersistedData = {};
let data = {};
try {
data = JSON.parse(localStorage.getItem(AUTH_STORE) || "{}");
} catch (_) {
@@ -70,18 +63,14 @@ export default class AuthStore {
// signin/signout events in other tabs and follow suite.
window.addEventListener("storage", (event) => {
if (event.key === AUTH_STORE) {
const data: ?PersistedData = JSON.parse(event.newValue);
const data = JSON.parse(event.newValue);
// data may be null if key is deleted in localStorage
if (!data) return;
// If we're not signed in then hydrate from the received data, otherwise if
// we are signed in and the received data contains no user then sign out
if (this.authenticated) {
if (data.user === null) {
this.logout();
}
} else {
// if there is no user on the new data then we know the other tab
// signed out and we should do the same. Otherwise, if we're not
// signed in then hydrate from the received data
if (this.token && data.user === null) {
this.logout();
} else if (!this.token) {
this.rehydrate(data);
}
}
@@ -89,25 +78,22 @@ export default class AuthStore {
}
@action
rehydrate(data: PersistedData) {
rehydrate(data: { user: User, team: Team }) {
this.user = new User(data.user);
this.team = new Team(data.team);
this.token = getCookie("accessToken");
this.lastSignedIn = getCookie("lastSignedIn");
this.addPolicies(data.policies);
if (this.token) {
setImmediate(() => this.fetch());
}
}
addPolicies(policies?: Policy[]) {
addPolicies = (policies: Policy[]) => {
if (policies) {
// cache policies in this store so that they are persisted between sessions
this.policies = policies;
policies.forEach((policy) => this.rootStore.policies.add(policy));
}
}
};
@computed
get authenticated(): boolean {
@@ -119,7 +105,6 @@ export default class AuthStore {
return JSON.stringify({
user: this.user,
team: this.team,
policies: this.policies,
});
}
@@ -225,7 +210,6 @@ export default class AuthStore {
JSON.stringify({
user: null,
team: null,
policies: [],
})
);
+1 -5
View File
@@ -13,7 +13,6 @@ import NotificationSettingsStore from "./NotificationSettingsStore";
import PoliciesStore from "./PoliciesStore";
import RevisionsStore from "./RevisionsStore";
import SharesStore from "./SharesStore";
import ToastsStore from "./ToastsStore";
import UiStore from "./UiStore";
import UsersStore from "./UsersStore";
import ViewsStore from "./ViewsStore";
@@ -36,11 +35,8 @@ export default class RootStore {
ui: UiStore;
users: UsersStore;
views: ViewsStore;
toasts: ToastsStore;
constructor() {
// PoliciesStore must be initialized before AuthStore
this.policies = new PoliciesStore(this);
this.apiKeys = new ApiKeysStore(this);
this.auth = new AuthStore(this);
this.collections = new CollectionsStore(this);
@@ -52,12 +48,12 @@ export default class RootStore {
this.memberships = new MembershipsStore(this);
this.notificationSettings = new NotificationSettingsStore(this);
this.presence = new DocumentPresenceStore();
this.policies = new PoliciesStore(this);
this.revisions = new RevisionsStore(this);
this.shares = new SharesStore(this);
this.ui = new UiStore();
this.users = new UsersStore(this);
this.views = new ViewsStore(this);
this.toasts = new ToastsStore();
}
logout() {
-52
View File
@@ -1,52 +0,0 @@
// @flow
import { orderBy } from "lodash";
import { observable, action, computed } from "mobx";
import { v4 as uuidv4 } from "uuid";
import type { Toast, ToastOptions } from "types";
export default class ToastsStore {
@observable toasts: Map<string, Toast> = new Map();
lastToastId: string;
@action
showToast = (
message: string,
options: ToastOptions = {
type: "info",
}
) => {
if (!message) return;
const lastToast = this.toasts.get(this.lastToastId);
if (lastToast && lastToast.message === message) {
this.toasts.set(this.lastToastId, {
...lastToast,
reoccurring: lastToast.reoccurring ? ++lastToast.reoccurring : 1,
});
return this.lastToastId;
}
const id = uuidv4();
const createdAt = new Date().toISOString();
this.toasts.set(id, {
id,
message,
createdAt,
type: options.type,
timeout: options.timeout,
action: options.action,
});
this.lastToastId = id;
return id;
};
@action
hideToast = (id: string) => {
this.toasts.delete(id);
};
@computed
get orderedData(): Toast[] {
return orderBy(Array.from(this.toasts.values()), "createdAt", "desc");
}
}

Some files were not shown because too many files have changed in this diff Show More