mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4c594423f | |||
| 2bf237d54b | |||
| 3565e68725 | |||
| 61039e9d0d | |||
| 6d09122d56 | |||
| 5fb6097153 | |||
| ec17874568 | |||
| 40c3e9e85f | |||
| 9f739f3788 | |||
| f6837b4742 | |||
| 1560e3c9f7 | |||
| ca74908dc5 | |||
| de7ec1119b | |||
| 2093b4297f | |||
| 3df82c500b |
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
{updatedByMe ? t("You") : updatedBy.name}
|
||||
{to ? <Link to={to}>{content}</Link> : content}
|
||||
{showCollection && collection && (
|
||||
<span>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
+6
-7
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,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 = {};
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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("I’m 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…" : "I’m sure – Delete"}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default observer(CollectionDelete);
|
||||
export default inject("collections", "ui")(withRouter(CollectionDelete));
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
You’re 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>
|
||||
You’re 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)
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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 (
|
||||
<>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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("Couldn’t create the document, try again?"), {
|
||||
ui.showToast(t("Couldn’t create the document, try again?"), {
|
||||
type: "error",
|
||||
});
|
||||
history.goBack();
|
||||
@@ -50,7 +48,7 @@ function DocumentNew() {
|
||||
return (
|
||||
<Flex column auto>
|
||||
<CenteredContent>
|
||||
<PlaceholderDocument />
|
||||
<LoadingPlaceholder />
|
||||
</CenteredContent>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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("I’m 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…" : "I’m sure – Delete"}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default observer(GroupDelete);
|
||||
export default inject("ui")(withRouter(GroupDelete));
|
||||
|
||||
+48
-49
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>You’ll 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>You’ll 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
@@ -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
|
||||
/>
|
||||
|
||||
<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
|
||||
/>
|
||||
|
||||
<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
|
||||
/>
|
||||
|
||||
<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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
@@ -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
@@ -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: [],
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user