Compare commits

..

2 Commits

Author SHA1 Message Date
Saumya Pandey 37fa62d10b Merge branch 'main' of https://github.com/outline/outline into fix/2277 2021-07-06 10:27:44 +05:30
Saumya Pandey c76e75c017 Pass the textarea element to getComputedStyle 2021-07-06 10:20:24 +05:30
187 changed files with 2700 additions and 5874 deletions
-4
View File
@@ -94,10 +94,6 @@ FORCE_HTTPS=true
# the maintainers
ENABLE_UPDATES=true
# How many processes should be spawned. As a reasonable rule divide your servers
# available memory by 512 for a rough estimate
WEB_CONCURRENCY=1
# Override the maxium size of document imports, could be required if you have
# especially large Word documents with embedded imagery
MAXIMUM_IMPORT_SIZE=5120000
+5 -2
View File
@@ -6,7 +6,6 @@ import { Redirect } from "react-router-dom";
import { isCustomSubdomain } from "shared/utils/domains";
import LoadingIndicator from "components/LoadingIndicator";
import useStores from "../hooks/useStores";
import { changeLanguage } from "../utils/language";
import env from "env";
type Props = {
@@ -21,7 +20,11 @@ const Authenticated = ({ children }: Props) => {
// Watching for language changes here as this is the earliest point we have
// the user available and means we can start loading translations faster
React.useEffect(() => {
changeLanguage(language, i18n);
if (language && i18n.language !== language) {
// Languages are stored in en_US format in the database, however the
// frontend translation framework (i18next) expects en-US
i18n.changeLanguage(language.replace("_", "-"));
}
}, [i18n, language]);
if (auth.authenticated) {
+1 -1
View File
@@ -1,7 +1,7 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import { bounceIn } from "styles/animations";
import { bounceIn } from "shared/styles/animations";
type Props = {|
count: number,
-78
View File
@@ -1,78 +0,0 @@
// @flow
import React from "react";
import styled, { useTheme } from "styled-components";
const cleanPercentage = (percentage) => {
const tooLow = !Number.isFinite(+percentage) || percentage < 0;
const tooHigh = percentage > 100;
return tooLow ? 0 : tooHigh ? 100 : +percentage;
};
const Circle = ({
color,
percentage,
offset,
}: {
color: string,
percentage?: number,
offset: number,
}) => {
const radius = offset * 0.7;
const circumference = 2 * Math.PI * radius;
let strokePercentage;
if (percentage) {
// because the circle is so small, anything greater than 85% appears like 100%
percentage = percentage > 85 && percentage < 100 ? 85 : percentage;
strokePercentage = percentage
? ((100 - percentage) * circumference) / 100
: 0;
}
return (
<circle
r={radius}
cx={offset}
cy={offset}
fill="none"
stroke={strokePercentage !== circumference ? color : ""}
strokeWidth={2.5}
strokeDasharray={circumference}
strokeDashoffset={percentage ? strokePercentage : 0}
strokeLinecap="round"
style={{ transition: "stroke-dashoffset 0.6s ease 0s" }}
></circle>
);
};
const CircularProgressBar = ({
percentage,
size = 16,
}: {
percentage: number,
size?: number,
}) => {
const theme = useTheme();
percentage = cleanPercentage(percentage);
const offset = Math.floor(size / 2);
return (
<SVG width={size} height={size}>
<g transform={`rotate(-90 ${offset} ${offset})`}>
<Circle color={theme.progressBarBackground} offset={offset} />
{percentage > 0 && (
<Circle
color={theme.primary}
percentage={percentage}
offset={offset}
/>
)}
</g>
</SVG>
);
};
const SVG = styled.svg`
flex-shrink: 0;
`;
export default CircularProgressBar;
+2 -4
View File
@@ -12,15 +12,13 @@ import LoadingIndicator from "components/LoadingIndicator";
import NudeButton from "components/NudeButton";
import useDebouncedCallback from "hooks/useDebouncedCallback";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
collection: Collection,
|};
function CollectionDescription({ collection }: Props) {
const { collections, policies } = useStores();
const { showToast } = useToasts();
const { collections, ui, policies } = useStores();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
@@ -55,7 +53,7 @@ function CollectionDescription({ collection }: Props) {
});
setDirty(false);
} catch (err) {
showToast(
ui.showToast(
t("Sorry, an error occurred saving the collection", {
type: "error",
})
-2
View File
@@ -15,7 +15,6 @@ type Props = {|
target?: "_blank",
as?: string | React.ComponentType<*>,
hide?: () => void,
level?: number,
|};
const MenuItem = ({
@@ -89,7 +88,6 @@ export const MenuAnchor = styled.a`
margin: 0;
border: 0;
padding: 12px;
padding-left: ${(props) => 12 + props.level * 10}px;
width: 100%;
min-height: 32px;
background: none;
+43 -15
View File
@@ -9,11 +9,49 @@ import {
MenuItem as BaseMenuItem,
} from "reakit/Menu";
import styled from "styled-components";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import Separator from "./Separator";
import ContextMenu from ".";
import { type MenuItem as TMenuItem } from "types";
type TMenuItem =
| {|
title: React.Node,
to: string,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
href: string,
visible?: boolean,
selected?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
visible?: boolean,
disabled?: boolean,
style?: Object,
hover?: boolean,
items: TMenuItem[],
|}
| {|
type: "separator",
visible?: boolean,
|}
| {|
type: "heading",
visible?: boolean,
title: React.Node,
|};
type Props = {|
items: TMenuItem[],
@@ -45,7 +83,7 @@ const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
);
});
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
function Template({ items, ...menu }: Props): React.Node {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators
@@ -63,11 +101,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return [...acc, item];
}, []);
return filtered;
}
function Template({ items, ...menu }: Props): React.Node {
return filterTemplateItems(items).map((item, index) => {
return filtered.map((item, index) => {
if (item.to) {
return (
<MenuItem
@@ -90,8 +124,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 +163,6 @@ function Template({ items, ...menu }: Props): React.Node {
return <Separator key={index} />;
}
if (item.type === "heading") {
return <Header>{item.title}</Header>;
}
console.warn("Unrecognized menu item", item);
return null;
});
}
+14 -29
View File
@@ -4,18 +4,16 @@ import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import usePrevious from "hooks/usePrevious";
import {
fadeIn,
fadeAndSlideUp,
fadeAndSlideDown,
mobileContextMenu,
} from "styles/animations";
fadeAndScaleIn,
fadeAndSlideIn,
} from "shared/styles/animations";
import usePrevious from "hooks/usePrevious";
type Props = {|
"aria-label": string,
visible?: boolean,
placement?: string,
animating?: boolean,
children: React.Node,
onOpen?: () => void,
@@ -46,25 +44,13 @@ export default function ContextMenu({
return (
<>
<Menu hideOnClickOutside preventBodyScroll {...rest}>
{(props) => {
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
// positioning.
const topAnchor = props.style.top === "0";
const rightAnchor = props.placement === "bottom-end";
return (
<Position {...props}>
<Background
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
);
}}
{(props) => (
<Position {...props}>
<Background dir="auto">
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
)}
</Menu>
{(rest.visible || rest.animating) && (
<Portal>
@@ -105,7 +91,7 @@ const Position = styled.div`
`;
const Background = styled.div`
animation: ${mobileContextMenu} 200ms ease;
animation: ${fadeAndSlideIn} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
background: ${(props) => props.theme.menuBackground};
@@ -123,10 +109,9 @@ const Background = styled.div`
}
${breakpoint("tablet")`
animation: ${(props) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: ${(props) =>
props.rightAnchor === "bottom-end" ? "75%" : "25%"} 0;
props.left !== undefined ? "25%" : "75%"} 0;
max-width: 276px;
background: ${(props) => props.theme.menuBackground};
box-shadow: ${(props) => props.theme.menuShadow};
@@ -15,7 +15,7 @@ import RevisionsStore from "stores/RevisionsStore";
import Button from "components/Button";
import Flex from "components/Flex";
import PlaceholderList from "components/List/Placeholder";
import { ListPlaceholder } from "components/LoadingPlaceholder";
import Revision from "./components/Revision";
import { documentHistoryUrl, documentUrl } from "utils/routeHelpers";
@@ -120,7 +120,7 @@ class DocumentHistory extends React.Component<Props> {
</Header>
{showLoading ? (
<Loading>
<PlaceholderList count={5} />
<ListPlaceholder count={5} />
</Loading>
) : (
<ArrowKeyNavigation
+3 -4
View File
@@ -15,7 +15,6 @@ import Flex from "components/Flex";
import Highlight from "components/Highlight";
import StarButton, { AnimatedStar } from "components/Star";
import Tooltip from "components/Tooltip";
import useBoolean from "hooks/useBoolean";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
@@ -47,7 +46,7 @@ function DocumentListItem(props: Props, ref) {
const { policies } = useStores();
const currentUser = useCurrentUser();
const currentTeam = useCurrentTeam();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [menuOpen, setMenuOpen] = React.useState(false);
const {
document,
showNestedDocuments,
@@ -144,8 +143,8 @@ function DocumentListItem(props: Props, ref) {
<DocumentMenu
document={document}
showPin={showPin}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
modal={false}
/>
</Actions>
+10 -22
View File
@@ -6,10 +6,8 @@ import { Link } from "react-router-dom";
import styled from "styled-components";
import Document from "models/Document";
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
import DocumentTasks from "components/DocumentTasks";
import Flex from "components/Flex";
import Time from "components/Time";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
const Container = styled(Flex)`
@@ -52,9 +50,7 @@ function DocumentMeta({
...rest
}: Props) {
const { t } = useTranslation();
const { collections } = useStores();
const user = useCurrentUser();
const { collections, auth } = useStores();
const {
modifiedSinceViewed,
updatedAt,
@@ -65,8 +61,6 @@ function DocumentMeta({
deletedAt,
isDraft,
lastViewedAt,
isTasks,
isTemplate,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
@@ -75,8 +69,6 @@ function DocumentMeta({
return null;
}
const collection = collections.get(document.collectionId);
const lastUpdatedByCurrentUser = user.id === updatedBy.id;
let content;
if (deletedAt) {
@@ -111,16 +103,14 @@ function DocumentMeta({
);
} else {
content = (
<Modified highlight={modifiedSinceViewed && !lastUpdatedByCurrentUser}>
<Modified highlight={modifiedSinceViewed}>
{t("updated")} <Time dateTime={updatedAt} addSuffix />
</Modified>
);
}
const nestedDocumentsCount = collection
? collection.getDocumentChildren(document.id).length
: 0;
const canShowProgressBar = isTasks && !isTemplate;
const collection = collections.get(document.collectionId);
const updatedByMe = auth.user && auth.user.id === updatedBy.id;
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
@@ -141,9 +131,13 @@ function DocumentMeta({
);
};
const nestedDocumentsCount = collection
? collection.getDocumentChildren(document.id).length
: 0;
return (
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{lastUpdatedByCurrentUser ? t("You") : updatedBy.name}&nbsp;
{updatedByMe ? t("You") : updatedBy.name}&nbsp;
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
<span>
@@ -155,17 +149,11 @@ function DocumentMeta({
)}
{showNestedDocuments && nestedDocumentsCount > 0 && (
<span>
&nbsp; {nestedDocumentsCount}{" "}
&nbsp;&middot; {nestedDocumentsCount}{" "}
{t("nested document", { count: nestedDocumentsCount })}
</span>
)}
&nbsp;{timeSinceNow()}
{canShowProgressBar && (
<>
&nbsp;&nbsp;
<DocumentTasks document={document} />
</>
)}
{children}
</Container>
);
+1 -1
View File
@@ -42,7 +42,7 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
<PopoverDisclosure {...popover}>
{(props) => (
<>
&nbsp;&nbsp;
&nbsp;&middot;&nbsp;
<a {...props}>
{t("Viewed by")}{" "}
{onlyYou
-59
View File
@@ -1,59 +0,0 @@
// @flow
import { DoneIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import CircularProgressBar from "components/CircularProgressBar";
import usePrevious from "../hooks/usePrevious";
import Document from "../models/Document";
import { bounceIn } from "styles/animations";
type Props = {|
document: Document,
|};
function getMessage(t, total, completed) {
if (completed === 0) {
return t(`{{ total }} task`, { total, count: total });
} else if (completed === total) {
return t(`{{ completed }} task done`, { completed, count: completed });
} else {
return t(`{{ completed }} of {{ total }} tasks`, {
total,
completed,
});
}
}
function DocumentTasks({ document }: Props) {
const { tasks, tasksPercentage } = document;
const { t } = useTranslation();
const theme = useTheme();
const { completed, total } = tasks;
const done = completed === total;
const previousDone = usePrevious(done);
const message = getMessage(t, total, completed);
return (
<>
{completed === total ? (
<Done
color={theme.primary}
size={20}
$animated={done && previousDone === false}
/>
) : (
<CircularProgressBar percentage={tasksPercentage} />
)}
&nbsp;{message}
</>
);
}
const Done = styled(DoneIcon)`
margin: -1px;
animation: ${(props) => (props.$animated ? bounceIn : "none")} 600ms;
transform-origin: center center;
`;
export default DocumentTasks;
+6 -7
View File
@@ -4,13 +4,12 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { light } from "shared/theme";
import { light } from "shared/styles/theme";
import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import embeds from "../embeds";
import useMediaQuery from "hooks/useMediaQuery";
import useToasts from "hooks/useToasts";
import { type Theme } from "types";
import { isModKey } from "utils/keyboard";
import { uploadFile } from "utils/uploadFile";
@@ -59,9 +58,8 @@ type PropsWithRef = Props & {
};
function Editor(props: PropsWithRef) {
const { id, shareId, history } = props;
const { id, ui, shareId, history } = props;
const { t } = useTranslation();
const { showToast } = useToasts();
const isPrinting = useMediaQuery("print");
const onUploadImage = React.useCallback(
@@ -108,9 +106,11 @@ function Editor(props: PropsWithRef) {
const onShowToast = React.useCallback(
(message: string) => {
showToast(message);
if (ui) {
ui.showToast(message);
}
},
[showToast]
[ui]
);
const dictionary = React.useMemo(() => {
@@ -148,7 +148,6 @@ function Editor(props: PropsWithRef) {
hr: t("Divider"),
image: t("Image"),
imageUploadError: t("Sorry, an error occurred uploading the image"),
imageCaptionPlaceholder: t("Write a caption"),
info: t("Info"),
infoNotice: t("Info notice"),
link: t("Link"),
+17 -36
View File
@@ -3,7 +3,6 @@ import * as Sentry from "@sentry/react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction, Trans } from "react-i18next";
import styled from "styled-components";
import Button from "components/Button";
import CenteredContent from "components/CenteredContent";
@@ -12,11 +11,10 @@ import PageTitle from "components/PageTitle";
import { githubIssuesUrl } from "../../shared/utils/routeHelpers";
import env from "env";
type Props = {|
type Props = {
children: React.Node,
reloadOnChunkMissing?: boolean,
t: TFunction,
|};
};
@observer
class ErrorBoundary extends React.Component<Props> {
@@ -57,8 +55,6 @@ class ErrorBoundary extends React.Component<Props> {
};
render() {
const { t } = this.props;
if (this.error) {
const error = this.error;
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
@@ -67,21 +63,15 @@ class ErrorBoundary extends React.Component<Props> {
if (isChunkError) {
return (
<CenteredContent>
<PageTitle title={t("Module failed to load")} />
<h1>
<Trans>Loading Failed</Trans>
</h1>
<PageTitle title="Module failed to load" />
<h1>Loading Failed</h1>
<HelpText>
<Trans>
Sorry, part of the application failed to load. This may be
because it was updated since you opened the tab or because of a
failed network request. Please try reloading.
</Trans>
Sorry, part of the application failed to load. This may be because
it was updated since you opened the tab or because of a failed
network request. Please try reloading.
</HelpText>
<p>
<Button onClick={this.handleReload}>
<Trans>Reload</Trans>
</Button>
<Button onClick={this.handleReload}>Reload</Button>
</p>
</CenteredContent>
);
@@ -89,32 +79,23 @@ class ErrorBoundary extends React.Component<Props> {
return (
<CenteredContent>
<PageTitle title={t("Something Unexpected Happened")} />
<h1>
<Trans>Something Unexpected Happened</Trans>
</h1>
<PageTitle title="Something Unexpected Happened" />
<h1>Something Unexpected Happened</h1>
<HelpText>
<Trans
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
values={{
notified: isReported
? ` ${t("our engineers have been notified")}`
: undefined,
}}
/>
Sorry, an unrecoverable error occurred
{isReported && " our engineers have been notified"}. Please try
reloading the page, it may have been a temporary glitch.
</HelpText>
{this.showDetails && <Pre>{error.toString()}</Pre>}
<p>
<Button onClick={this.handleReload}>
<Trans>Reload</Trans>
</Button>{" "}
<Button onClick={this.handleReload}>Reload</Button>{" "}
{this.showDetails ? (
<Button onClick={this.handleReportBug} neutral>
<Trans>Report a Bug</Trans>
Report a Bug
</Button>
) : (
<Button onClick={this.handleShowDetails} neutral>
<Trans>Show Detail</Trans>
Show Details
</Button>
)}
</p>
@@ -133,4 +114,4 @@ const Pre = styled.pre`
white-space: pre-wrap;
`;
export default withTranslation()<ErrorBoundary>(ErrorBoundary);
export default ErrorBoundary;
+1 -1
View File
@@ -1,6 +1,6 @@
// @flow
import styled from "styled-components";
import { fadeIn } from "styles/animations";
import { fadeIn } from "shared/styles/animations";
const Fade = styled.span`
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
+1 -1
View File
@@ -45,7 +45,7 @@ const Container = styled.div`
align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify};
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
gap: ${({ gap }) => (gap ? `${gap}px` : "initial")};
gap: ${({ gap }) => `${gap}px` || "initial"};
min-height: 0;
min-width: 0;
`;
+1 -5
View File
@@ -72,10 +72,6 @@ const Actions = styled(Flex)`
flex-basis: 0;
min-width: auto;
padding-left: 8px;
${breakpoint("tablet")`
position: unset;
`};
`;
const Wrapper = styled(Flex)`
@@ -88,12 +84,12 @@ const Wrapper = styled(Flex)`
transform: translate3d(0, 0, 0);
backdrop-filter: blur(20px);
min-height: 56px;
justify-content: flex-start;
@media print {
display: none;
}
justify-content: flex-start;
${breakpoint("tablet")`
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
justify-content: "center";
+2 -2
View File
@@ -4,10 +4,10 @@ import { transparentize } from "polished";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import { fadeAndSlideIn } from "shared/styles/animations";
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore";
import HoverPreviewDocument from "components/HoverPreviewDocument";
import { fadeAndSlideDown } from "styles/animations";
import { isInternalUrl } from "utils/urls";
const DELAY_OPEN = 300;
@@ -136,7 +136,7 @@ function HoverPreview({ node, ...rest }: Props) {
}
const Animate = styled.div`
animation: ${fadeAndSlideDown} 150ms ease;
animation: ${fadeAndSlideIn} 150ms ease;
@media print {
display: none;
-4
View File
@@ -29,10 +29,6 @@ const RealInput = styled.input`
background: none;
color: ${(props) => props.theme.text};
height: 30px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:disabled,
&::placeholder {
+38 -38
View File
@@ -1,58 +1,58 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
import { Trans } from "react-i18next";
import styled, { withTheme } from "styled-components";
import UiStore from "stores/UiStore";
import Editor from "components/Editor";
import HelpText from "components/HelpText";
import { LabelText, Outline } from "components/Input";
import useStores from "hooks/useStores";
type Props = {|
label: string,
minHeight?: number,
maxHeight?: number,
readOnly?: boolean,
ui: UiStore,
|};
function InputRich({ label, minHeight, maxHeight, ...rest }: Props) {
const [focused, setFocused] = React.useState<boolean>(false);
const { ui } = useStores();
@observer
class InputRich extends React.Component<Props> {
@observable editorComponent: React.ComponentType<any>;
@observable focused: boolean = false;
const handleBlur = React.useCallback(() => {
setFocused(false);
}, []);
handleBlur = () => {
this.focused = false;
};
const handleFocus = React.useCallback(() => {
setFocused(true);
}, []);
handleFocus = () => {
this.focused = true;
};
return (
<>
<LabelText>{label}</LabelText>
<StyledOutline
maxHeight={maxHeight}
minHeight={minHeight}
focused={focused}
>
<React.Suspense
fallback={
<HelpText>
<Trans>Loading editor</Trans>
</HelpText>
}
render() {
const { label, minHeight, maxHeight, ui, ...rest } = this.props;
return (
<>
<LabelText>{label}</LabelText>
<StyledOutline
maxHeight={maxHeight}
minHeight={minHeight}
focused={this.focused}
>
<Editor
onBlur={handleBlur}
onFocus={handleFocus}
ui={ui}
grow
{...rest}
/>
</React.Suspense>
</StyledOutline>
</>
);
<React.Suspense fallback={<HelpText>Loading editor</HelpText>}>
<Editor
onBlur={this.handleBlur}
onFocus={this.handleFocus}
ui={ui}
grow
{...rest}
/>
</React.Suspense>
</StyledOutline>
</>
);
}
}
const StyledOutline = styled(Outline)`
@@ -67,4 +67,4 @@ const StyledOutline = styled(Outline)`
}
`;
export default observer(withTheme(InputRich));
export default inject("ui")(withTheme(InputRich));
+5 -6
View File
@@ -4,19 +4,18 @@ import * as React from "react";
import styled from "styled-components";
import Fade from "components/Fade";
import Flex from "components/Flex";
import PlaceholderText from "components/PlaceholderText";
import Mask from "components/Mask";
type Props = {
count?: number,
};
const ListPlaceHolder = ({ count }: Props) => {
const Placeholder = ({ count }: Props) => {
return (
<Fade>
{times(count || 2, (index) => (
<Item key={index} column auto>
<PlaceholderText header delay={0.2 * index} />
<PlaceholderText delay={0.2 * index} />
<Mask />
</Item>
))}
</Fade>
@@ -24,7 +23,7 @@ const ListPlaceHolder = ({ count }: Props) => {
};
const Item = styled(Flex)`
padding: 10px 0;
padding: 15px 0 16px;
`;
export default ListPlaceHolder;
export default Placeholder;
@@ -11,14 +11,16 @@ const LoadingIndicatorBar = () => {
};
const loadingFrame = keyframes`
from { margin-left: -100%; }
to { margin-left: 100%; }
from {margin-left: -100%; z-index:100;}
to {margin-left: 100%; }
`;
const Container = styled.div`
position: fixed;
top: 0;
z-index: ${(props) => props.theme.depths.loadingIndicatorBar};
background-color: #03a9f4;
width: 100%;
animation: ${loadingFrame} 4s ease-in-out infinite;
animation-delay: 250ms;
@@ -28,7 +30,7 @@ const Container = styled.div`
const Loader = styled.div`
width: 100%;
height: 2px;
background-color: ${(props) => props.theme.primary};
background-color: #03a9f4;
`;
export default LoadingIndicatorBar;
@@ -0,0 +1,30 @@
// @flow
import { times } from "lodash";
import * as React from "react";
import styled from "styled-components";
import Fade from "components/Fade";
import Flex from "components/Flex";
import Mask from "components/Mask";
type Props = {
count?: number,
};
const ListPlaceHolder = ({ count }: Props) => {
return (
<Fade>
{times(count || 2, (index) => (
<Item key={index} column auto>
<Mask header />
<Mask />
</Item>
))}
</Fade>
);
};
const Item = styled(Flex)`
padding: 10px 0;
`;
export default ListPlaceHolder;
@@ -4,19 +4,18 @@ import styled from "styled-components";
import DelayedMount from "components/DelayedMount";
import Fade from "components/Fade";
import Flex from "components/Flex";
import PlaceholderText from "components/PlaceholderText";
import Mask from "components/Mask";
export default function PlaceholderDocument(props: Object) {
export default function LoadingPlaceholder(props: Object) {
return (
<DelayedMount>
<Wrapper>
<Flex column auto {...props}>
<PlaceholderText height={34} maxWidth={70} />
<PlaceholderText delay={0.2} maxWidth={40} />
<Mask height={34} />
<br />
<PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} />
<PlaceholderText delay={0.6} />
<Mask />
<Mask />
<Mask />
</Flex>
</Wrapper>
</DelayedMount>
@@ -0,0 +1,6 @@
// @flow
import ListPlaceholder from "./ListPlaceholder";
import LoadingPlaceholder from "./LoadingPlaceholder";
export default LoadingPlaceholder;
export { ListPlaceholder };
-4
View File
@@ -3,11 +3,9 @@ import { format, formatDistanceToNow } from "date-fns";
import {
enUS,
de,
faIR,
fr,
es,
it,
ja,
ko,
ptBR,
pt,
@@ -23,10 +21,8 @@ const locales = {
en_US: enUS,
de_DE: de,
es_ES: es,
fa_IR: faIR,
fr_FR: fr,
it_IT: it,
ja_JP: ja,
ko_KR: ko,
pt_BR: ptBR,
pt_PT: pt,
@@ -2,48 +2,44 @@
import * as React from "react";
import styled from "styled-components";
import { randomInteger } from "shared/random";
import { pulsate } from "shared/styles/animations";
import Flex from "components/Flex";
import { pulsate } from "styles/animations";
type Props = {|
header?: boolean,
height?: number,
minWidth?: number,
maxWidth?: number,
delay?: number,
|};
class PlaceholderText extends React.Component<Props> {
width = randomInteger(this.props.minWidth || 75, this.props.maxWidth || 100);
class Mask extends React.Component<Props> {
width: number;
shouldComponentUpdate() {
return false;
}
constructor(props: Props) {
super();
this.width = randomInteger(props.minWidth || 75, props.maxWidth || 100);
}
render() {
return (
<Mask
width={this.width}
height={this.props.height}
delay={this.props.delay}
/>
);
return <Redacted width={this.width} height={this.props.height} />;
}
}
const Mask = styled(Flex)`
const Redacted = styled(Flex)`
width: ${(props) => (props.header ? props.width / 2 : props.width)}%;
height: ${(props) =>
props.height ? props.height : props.header ? 24 : 18}px;
margin-bottom: 6px;
border-radius: 6px;
background-color: ${(props) => props.theme.divider};
animation: ${pulsate} 2s infinite;
animation-delay: ${(props) => props.delay || 0}s;
animation: ${pulsate} 1.3s infinite;
&:last-child {
margin-bottom: 0;
}
`;
export default PlaceholderText;
export default Mask;
+1 -1
View File
@@ -7,12 +7,12 @@ import { useTranslation } from "react-i18next";
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { fadeAndScaleIn } from "shared/styles/animations";
import Flex from "components/Flex";
import NudeButton from "components/NudeButton";
import Scrollable from "components/Scrollable";
import usePrevious from "hooks/usePrevious";
import useUnmount from "hooks/useUnmount";
import { fadeAndScaleIn } from "styles/animations";
let openModals = 0;
+20 -21
View File
@@ -1,4 +1,5 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import Document from "models/Document";
import DocumentListItem from "components/DocumentListItem";
@@ -18,26 +19,24 @@ type Props = {|
showTemplate?: boolean,
|};
const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
empty,
heading,
documents,
fetch,
options,
...rest
}: Props) {
return (
<PaginatedList
items={documents}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item) => (
<DocumentListItem key={item.id} document={item} {...rest} />
)}
/>
);
});
@observer
class PaginatedDocumentList extends React.Component<Props> {
render() {
const { empty, heading, documents, fetch, options, ...rest } = this.props;
return (
<PaginatedList
items={documents}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item) => (
<DocumentListItem key={item.id} document={item} {...rest} />
)}
/>
);
}
}
export default PaginatedDocumentList;
+2 -2
View File
@@ -7,7 +7,7 @@ import * as React from "react";
import { Waypoint } from "react-waypoint";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DelayedMount from "components/DelayedMount";
import PlaceholderList from "components/List/Placeholder";
import { ListPlaceholder } from "components/LoadingPlaceholder";
type Props = {
fetch?: (options: ?Object) => Promise<void>,
@@ -128,7 +128,7 @@ class PaginatedList extends React.Component<Props> {
)}
{showLoading && (
<DelayedMount>
<PlaceholderList count={5} />
<ListPlaceholder count={5} />
</DelayedMount>
)}
</>
+1 -1
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { Popover as ReakitPopover } from "reakit/Popover";
import styled from "styled-components";
import { fadeAndScaleIn } from "styles/animations";
import { fadeAndScaleIn } from "shared/styles/animations";
type Props = {
children: React.Node,
+41 -26
View File
@@ -1,13 +1,15 @@
// @flow
import { observer } from "mobx-react";
import {
EditIcon,
ArchiveIcon,
HomeIcon,
PlusIcon,
EditIcon,
SearchIcon,
SettingsIcon,
ShapesIcon,
StarredIcon,
ShapesIcon,
TrashIcon,
PlusIcon,
SettingsIcon,
} from "outline-icons";
import * as React from "react";
import { DndProvider } from "react-dnd";
@@ -21,22 +23,16 @@ import Flex from "components/Flex";
import Modal from "components/Modal";
import Scrollable from "components/Scrollable";
import Sidebar from "./Sidebar";
import ArchiveLink from "./components/ArchiveLink";
import Collections from "./components/Collections";
import Section from "./components/Section";
import SidebarLink from "./components/SidebarLink";
import TeamButton from "./components/TeamButton";
import TrashLink from "./components/TrashLink";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import AccountMenu from "menus/AccountMenu";
function MainSidebar() {
const { t } = useTranslation();
const { policies, documents } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const { policies, auth, documents } = useStores();
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
const [
createCollectionModalOpen,
@@ -75,6 +71,9 @@ function MainSidebar() {
dndArea,
]);
const { user, team } = auth;
if (!user || !team) return null;
const can = policies.abilities(team.id);
return (
@@ -115,6 +114,17 @@ function MainSidebar() {
exact={false}
label={t("Starred")}
/>
{can.createDocument && (
<SidebarLink
to="/templates"
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
active={
documents.active ? documents.active.template : undefined
}
/>
)}
{can.createDocument && (
<SidebarLink
to="/drafts"
@@ -141,21 +151,26 @@ function MainSidebar() {
/>
</Section>
<Section>
{can.createDocument && (
<>
<SidebarLink
to="/templates"
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
active={
documents.active ? documents.active.template : undefined
}
/>
<ArchiveLink documents={documents} />
<TrashLink documents={documents} />
</>
)}
<SidebarLink
to="/archive"
icon={<ArchiveIcon color="currentColor" />}
exact={false}
label={t("Archive")}
active={
documents.active
? documents.active.isArchived && !documents.active.isDeleted
: undefined
}
/>
<SidebarLink
to="/trash"
icon={<TrashIcon color="currentColor" />}
exact={false}
label={t("Trash")}
active={
documents.active ? documents.active.isDeleted : undefined
}
/>
<SidebarLink
to="/settings"
icon={<SettingsIcon color="currentColor" />}
+1 -1
View File
@@ -6,13 +6,13 @@ import { Portal } from "react-portal";
import { useLocation } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { fadeIn } from "shared/styles/animations";
import Fade from "components/Fade";
import Flex from "components/Flex";
import ResizeBorder from "./components/ResizeBorder";
import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
import usePrevious from "hooks/usePrevious";
import useStores from "hooks/useStores";
import { fadeIn } from "styles/animations";
let ANIMATION_MS = 250;
let isFirstRender = true;
@@ -1,43 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { ArchiveIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import useStores from "../../../hooks/useStores";
import SidebarLink from "./SidebarLink";
import useToasts from "hooks/useToasts";
function ArchiveLink({ documents }) {
const { policies } = useStores();
const { t } = useTranslation();
const { showToast } = useToasts();
const [{ isDocumentDropping }, dropToArchiveDocument] = useDrop({
accept: "document",
drop: async (item, monitor) => {
const document = documents.get(item.id);
await document.archive();
showToast(t("Document archived"), { type: "success" });
},
canDrop: (item, monitor) => policies.abilities(item.id).archive,
collect: (monitor) => ({
isDocumentDropping: monitor.isOver(),
}),
});
return (
<div ref={dropToArchiveDocument}>
<SidebarLink
to="/archive"
icon={<ArchiveIcon color="currentColor" open={isDocumentDropping} />}
exact={false}
label={t("Archive")}
active={documents.active?.isArchived && !documents.active?.isDeleted}
isActiveDrop={isDocumentDropping}
/>
</div>
);
}
export default observer(ArchiveLink);
@@ -12,7 +12,6 @@ import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useBoolean from "hooks/useBoolean";
import useStores from "hooks/useStores";
import CollectionMenu from "menus/CollectionMenu";
import CollectionSortMenu from "menus/CollectionSortMenu";
@@ -36,7 +35,7 @@ function CollectionLink({
isDraggingAnyCollection,
onChangeDragging,
}: Props) {
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [menuOpen, setMenuOpen] = React.useState(false);
const handleTitleChange = React.useCallback(
async (name: string) => {
@@ -164,14 +163,14 @@ function CollectionLink({
{can.update && (
<CollectionSortMenuWithMargin
collection={collection}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
)}
<CollectionMenu
collection={collection}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</>
}
@@ -9,13 +9,11 @@ import Fade from "components/Fade";
import Flex from "components/Flex";
import useStores from "../../../hooks/useStores";
import CollectionLink from "./CollectionLink";
import CollectionsLoading from "./CollectionsLoading";
import DropCursor from "./DropCursor";
import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import SidebarLink from "./SidebarLink";
import useCurrentTeam from "hooks/useCurrentTeam";
import useToasts from "hooks/useToasts";
type Props = {
onCreateCollection: () => void,
};
@@ -24,7 +22,6 @@ function Collections({ onCreateCollection }: Props) {
const [isFetching, setFetching] = React.useState(false);
const [fetchError, setFetchError] = React.useState();
const { ui, policies, documents, collections } = useStores();
const { showToast } = useToasts();
const isPreloaded: boolean = !!collections.orderedData.length;
const { t } = useTranslation();
const team = useCurrentTeam();
@@ -41,7 +38,7 @@ function Collections({ onCreateCollection }: Props) {
setFetching(true);
await collections.fetchPage({ limit: 100 });
} catch (error) {
showToast(
ui.showToast(
t("Collections could not be loaded, please reload the app"),
{
type: "error",
@@ -54,7 +51,7 @@ function Collections({ onCreateCollection }: Props) {
}
}
load();
}, [collections, isFetching, showToast, fetchError, t]);
}, [collections, isFetching, ui, fetchError, t]);
const [{ isCollectionDropping }, dropToReorderCollection] = useDrop({
accept: "collection",
@@ -108,7 +105,7 @@ function Collections({ onCreateCollection }: Props) {
return (
<Flex column>
<Header>{t("Collections")}</Header>
<PlaceholderCollections />
<CollectionsLoading />
</Flex>
);
}
@@ -0,0 +1,21 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Mask from "components/Mask";
function CollectionsLoading() {
return (
<Wrapper>
<Mask />
<Mask />
<Mask />
</Wrapper>
);
}
const Wrapper = styled.div`
margin: 4px 16px;
width: 75%;
`;
export default CollectionsLoading;
@@ -12,7 +12,6 @@ import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useBoolean from "hooks/useBoolean";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import { type NavigationNode } from "types";
@@ -121,7 +120,7 @@ function DocumentLink(
[documents, document]
);
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [menuOpen, setMenuOpen] = React.useState(false);
const isMoving = documents.movingDocumentId === node.id;
const manualSort = collection?.sort.field === "index";
@@ -133,11 +132,7 @@ function DocumentLink(
isDragging: !!monitor.isDragging(),
}),
canDrag: (monitor) => {
return (
policies.abilities(node.id).move ||
policies.abilities(node.id).archive ||
policies.abilities(node.id).delete
);
return policies.abilities(node.id).move;
},
});
@@ -250,8 +245,8 @@ function DocumentLink(
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</Fade>
) : undefined
@@ -7,7 +7,6 @@ import styled, { css } from "styled-components";
import LoadingIndicator from "components/LoadingIndicator";
import useImportDocument from "hooks/useImportDocument";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
children: React.Node,
@@ -19,8 +18,7 @@ type Props = {|
function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const { t } = useTranslation();
const { documents, policies } = useStores();
const { showToast } = useToasts();
const { ui, documents, policies } = useStores();
const { handleFiles, isImporting } = useImportDocument(
collectionId,
documentId
@@ -29,11 +27,11 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const can = policies.abilities(collectionId);
const handleRejection = React.useCallback(() => {
showToast(
ui.showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"),
{ type: "error" }
);
}, [t, showToast]);
}, [t, ui]);
if (disabled || !can.update) {
return children;
@@ -1,7 +1,7 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import useToasts from "hooks/useToasts";
import useStores from "hooks/useStores";
type Props = {|
onSubmit: (title: string) => Promise<void>,
@@ -13,7 +13,7 @@ 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);
@@ -39,33 +39,26 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
[originalValue]
);
const handleSave = React.useCallback(
async (ev) => {
ev.preventDefault();
const handleSave = React.useCallback(async () => {
setIsEditing(false);
setIsEditing(false);
if (value === originalValue) {
return;
}
const trimmedValue = value.trim();
if (trimmedValue === originalValue || trimmedValue.length === 0) {
if (document) {
try {
await onSubmit(value);
setOriginalValue(value);
} catch (error) {
setValue(originalValue);
return;
ui.showToast(error.message, {
type: "error",
});
throw error;
}
if (document) {
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
} catch (error) {
setValue(originalValue);
showToast(error.message, {
type: "error",
});
throw error;
}
}
},
[originalValue, showToast, value, onSubmit]
);
}
}, [ui, originalValue, value, onSubmit]);
return (
<>
@@ -1,21 +0,0 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import PlaceholderText from "components/PlaceholderText";
function PlaceholderCollections() {
return (
<Wrapper>
<PlaceholderText />
<PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} />
</Wrapper>
);
}
const Wrapper = styled.div`
margin: 4px 16px;
width: 75%;
`;
export default PlaceholderCollections;
@@ -1,62 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { TrashIcon } from "outline-icons";
import * as React from "react";
import { useState } from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import DocumentDelete from "scenes/DocumentDelete";
import Modal from "components/Modal";
import useStores from "../../../hooks/useStores";
import SidebarLink from "./SidebarLink";
function TrashLink({ documents }) {
const { policies } = useStores();
const { t } = useTranslation();
const [document, setDocument] = useState();
const [{ isDocumentDropping }, dropToTrashDocument] = useDrop({
accept: "document",
drop: (item, monitor) => {
const doc = documents.get(item.id);
// without setTimeout it was not working in firefox v89.0.2-ubuntu
// on dropping mouseup is considered as clicking outside the modal, and it immediately closes
setTimeout(() => setDocument(doc), 1);
},
canDrop: (item, monitor) => policies.abilities(item.id).delete,
collect: (monitor) => ({
isDocumentDropping: monitor.isOver(),
}),
});
return (
<>
<div ref={dropToTrashDocument}>
<SidebarLink
to="/trash"
icon={<TrashIcon color="currentColor" open={isDocumentDropping} />}
exact={false}
label={t("Trash")}
active={documents.active?.isDeleted}
isActiveDrop={isDocumentDropping}
/>
</div>
{document && (
<Modal
title={t("Delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setDocument(undefined)}
isOpen
>
<DocumentDelete
document={document}
onSubmit={() => setDocument(undefined)}
/>
</Modal>
)}
</>
);
}
export default observer(TrashLink);
+5 -5
View File
@@ -11,7 +11,7 @@ import DocumentsStore from "stores/DocumentsStore";
import GroupsStore from "stores/GroupsStore";
import MembershipsStore from "stores/MembershipsStore";
import PoliciesStore from "stores/PoliciesStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
import ViewsStore from "stores/ViewsStore";
import { getVisibilityListener, getPageVisible } from "utils/pageVisibility";
@@ -27,7 +27,7 @@ type Props = {
policies: PoliciesStore,
views: ViewsStore,
auth: AuthStore,
toasts: ToastsStore,
ui: UiStore,
};
@observer
@@ -72,7 +72,7 @@ class SocketProvider extends React.Component<Props> {
const {
auth,
toasts,
ui,
documents,
collections,
groups,
@@ -113,7 +113,7 @@ class SocketProvider extends React.Component<Props> {
this.socket.on("unauthorized", (err) => {
this.socket.authenticated = false;
toasts.showToast(err.message, {
ui.showToast(err.message, {
type: "error",
});
throw err;
@@ -338,7 +338,7 @@ class SocketProvider extends React.Component<Props> {
export default inject(
"auth",
"toasts",
"ui",
"documents",
"collections",
"groups",
+8 -49
View File
@@ -1,26 +1,14 @@
// @flow
import { m } from "framer-motion";
import * as React from "react";
import { NavLink, Route } from "react-router-dom";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { type Theme } from "types";
type Props = {
theme: Theme,
children: React.Node,
};
const NavLinkWithChildrenFunc = ({ to, exact = false, children, ...rest }) => (
<Route path={to} exact={exact}>
{({ match }) => (
<NavLink to={to} exact={exact} {...rest}>
{children(match)}
</NavLink>
)}
</Route>
);
const TabLink = styled(NavLinkWithChildrenFunc)`
const TabLink = styled(NavLink)`
position: relative;
display: inline-flex;
align-items: center;
@@ -32,48 +20,19 @@ const TabLink = styled(NavLinkWithChildrenFunc)`
&:hover {
color: ${(props) => props.theme.textSecondary};
border-bottom: 3px solid ${(props) => props.theme.divider};
padding-bottom: 5px;
}
`;
const Active = styled(m.div)`
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
width: 100%;
border-top-left-radius: 2px;
border-top-right-radius: 2px;
background: ${(props) => props.theme.textSecondary};
`;
const transition = {
type: "spring",
stiffness: 500,
damping: 30,
};
function Tab({ theme, children, ...rest }: Props) {
function Tab({ theme, ...rest }: Props) {
const activeStyle = {
paddingBottom: "5px",
borderBottom: `3px solid ${theme.textSecondary}`,
color: theme.textSecondary,
};
return (
<TabLink {...rest} activeStyle={activeStyle}>
{(match) => (
<>
{children}
{match && (
<Active
layoutId="underline"
initial={false}
transition={transition}
/>
)}
</>
)}
</TabLink>
);
return <TabLink {...rest} activeStyle={activeStyle} />;
}
export default withTheme(Tab);
+2 -2
View File
@@ -8,7 +8,7 @@ import styled from "styled-components";
import Button from "components/Button";
import Empty from "components/Empty";
import Flex from "components/Flex";
import PlaceholderText from "components/PlaceholderText";
import Mask from "components/Mask";
export type Props = {|
data: any[],
@@ -170,7 +170,7 @@ export const Placeholder = ({
<Row key={row}>
{new Array(columns).fill().map((_, col) => (
<Cell key={col}>
<PlaceholderText minWidth={25} maxWidth={75} />
<Mask minWidth={25} maxWidth={75} />
</Cell>
))}
</Row>
+5 -57
View File
@@ -1,40 +1,13 @@
// @flow
import { AnimateSharedLayout } from "framer-motion";
import { transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import useWindowSize from "hooks/useWindowSize";
const Nav = styled.nav`
border-bottom: 1px solid ${(props) => props.theme.divider};
margin: 12px 0;
overflow-y: auto;
white-space: nowrap;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
&:after {
content: "";
position: absolute;
top: 0;
right: 0;
width: 50px;
height: 100%;
pointer-events: none;
background: ${(props) =>
props.$shadowVisible
? `linear-gradient(
90deg,
${transparentize(1, props.theme.background)} 0%,
${props.theme.background} 100%
)`
: `transparent`};
}
transition: opacity 250ms ease-out;
`;
// When sticky we need extra background coverage around the sides otherwise
@@ -57,36 +30,11 @@ export const Separator = styled.span`
margin-top: 6px;
`;
const Tabs = ({ children }: {| children: React.Node |}) => {
const ref = React.useRef<?HTMLDivElement>();
const [shadowVisible, setShadow] = React.useState(false);
const { width } = useWindowSize();
const updateShadows = React.useCallback(() => {
const c = ref.current;
if (!c) return;
const scrollLeft = c.scrollLeft;
const wrapperWidth = c.scrollWidth - c.clientWidth;
const fade = !!(wrapperWidth - scrollLeft !== 0);
if (fade !== shadowVisible) {
setShadow(fade);
}
}, [shadowVisible]);
React.useEffect(() => {
updateShadows();
}, [width, updateShadows]);
const Tabs = (props: {}) => {
return (
<AnimateSharedLayout>
<Sticky>
<Nav ref={ref} onScroll={updateShadows} $shadowVisible={shadowVisible}>
{children}
</Nav>
</Sticky>
</AnimateSharedLayout>
<Sticky>
<Nav {...props}></Nav>
</Sticky>
);
};
+2 -2
View File
@@ -2,10 +2,10 @@
import { observer } from "mobx-react";
import * as React from "react";
import { ThemeProvider } from "styled-components";
import { dark, light, lightMobile, darkMobile } from "shared/theme";
import GlobalStyles from "shared/styles/globals";
import { dark, light, lightMobile, darkMobile } from "shared/styles/theme";
import useMediaQuery from "hooks/useMediaQuery";
import useStores from "hooks/useStores";
import GlobalStyles from "styles/globals";
const empty = {};
+1 -1
View File
@@ -32,7 +32,7 @@ function Time(props: Props) {
<time dateTime={props.dateTime}>{props.children || content}</time>
}
>
<LocaleTime tooltipDelay={250} {...props} />
<LocaleTime {...props} />
</React.Suspense>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { CheckboxIcon, InfoIcon, WarningIcon } from "outline-icons";
import { darken } from "polished";
import * as React from "react";
import styled, { css } from "styled-components";
import { fadeAndScaleIn, pulse } from "styles/animations";
import { fadeAndScaleIn, pulse } from "shared/styles/animations";
import type { Toast as TToast } from "types";
type Props = {
+3 -3
View File
@@ -6,15 +6,15 @@ import Toast from "components/Toast";
import useStores from "hooks/useStores";
function Toasts() {
const { toasts } = useStores();
const { ui } = useStores();
return (
<List>
{toasts.orderedData.map((toast) => (
{ui.orderedToasts.map((toast) => (
<Toast
key={toast.id}
toast={toast}
onRequestClose={() => toasts.hideToast(toast.id)}
onRequestClose={() => ui.removeToast(toast.id)}
/>
))}
</List>
-39
View File
@@ -1,39 +0,0 @@
// @flow
import * as React from "react";
import Image from "components/Image";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp(
"^https?://datastudio.google.com/(embed|u/0)/reporting/(.*)/page/(.*)(/edit)?$"
);
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class GoogleDataStudio extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href.replace("u/0", "embed").replace("/edit", "")}
icon={
<Image
src="/images/google-datastudio.png"
alt="Google Data Studio Icon"
width={16}
height={16}
/>
}
canonicalUrl={this.props.attrs.href}
title="Google Data Studio"
border
/>
);
}
}
-19
View File
@@ -1,19 +0,0 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import GoogleDataStudio from "./GoogleDataStudio";
describe("GoogleDataStudio", () => {
const match = GoogleDataStudio.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://datastudio.google.com/embed/reporting/aab01789-f3a2-4ff3-9cba-c4c94c4a92e8/page/7zFD".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://datastudio.google.com/u/0/".match(match)).toBe(null);
expect("https://datastudio.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
});
});
-8
View File
@@ -11,7 +11,6 @@ import Descript from "./Descript";
import Figma from "./Figma";
import Framer from "./Framer";
import Gist from "./Gist";
import GoogleDataStudio from "./GoogleDataStudio";
import GoogleDocs from "./GoogleDocs";
import GoogleDrawings from "./GoogleDrawings";
import GoogleDrive from "./GoogleDrive";
@@ -149,13 +148,6 @@ export default [
component: GoogleSlides,
matcher: matcher(GoogleSlides),
},
{
title: "Google Data Studio",
keywords: "business intelligence",
icon: () => <Img src="/images/google-datastudio.png" />,
component: GoogleDataStudio,
matcher: matcher(GoogleDataStudio),
},
{
title: "InVision",
keywords: "design prototype",
-23
View File
@@ -1,23 +0,0 @@
// @flow
import * as React from "react";
type InitialState = boolean | (() => boolean);
/**
* React hook to manage booleans
*
* @param initialState the initial boolean state value
*/
export default function useBoolean(initialState: InitialState = false) {
const [value, setValue] = React.useState(initialState);
const setTrue = React.useCallback(() => {
setValue(true);
}, []);
const setFalse = React.useCallback(() => {
setValue(false);
}, []);
return [value, setTrue, setFalse];
}
+3 -5
View File
@@ -4,7 +4,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
let importingLock = false;
@@ -12,8 +11,7 @@ export default function useImportDocument(
collectionId: string,
documentId?: string
): {| handleFiles: (files: File[]) => Promise<void>, isImporting: boolean |} {
const { documents } = useStores();
const { showToast } = useToasts();
const { documents, ui } = useStores();
const [isImporting, setImporting] = React.useState(false);
const { t } = useTranslation();
const history = useHistory();
@@ -53,7 +51,7 @@ export default function useImportDocument(
}
}
} catch (err) {
showToast(`${t("Could not import file")}. ${err.message}`, {
ui.showToast(`${t("Could not import file")}. ${err.message}`, {
type: "error",
});
} finally {
@@ -61,7 +59,7 @@ export default function useImportDocument(
importingLock = false;
}
},
[t, documents, history, showToast, collectionId, documentId]
[t, ui, documents, history, collectionId, documentId]
);
return {
-8
View File
@@ -1,8 +0,0 @@
// @flow
import useStores from "./useStores";
export default function useToasts() {
const { toasts } = useStores();
return { showToast: toasts.showToast, hideToast: toasts.hideToast };
}
+9 -16
View File
@@ -1,6 +1,5 @@
// @flow
import "focus-visible";
import { LazyMotion } from "framer-motion";
import { createBrowserHistory } from "history";
import { Provider } from "mobx-react";
import * as React from "react";
@@ -50,10 +49,6 @@ if ("serviceWorker" in window.navigator) {
});
}
// Make sure to return the specific export containing the feature bundle.
const loadFeatures = () =>
import("./utils/motion.js").then((res) => res.default);
if (element) {
const App = () => (
<React.StrictMode>
@@ -61,17 +56,15 @@ if (element) {
<Analytics>
<Theme>
<ErrorBoundary>
<LazyMotion features={loadFeatures}>
<Router history={history}>
<>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</LazyMotion>
<Router history={history}>
<>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</ErrorBoundary>
</Theme>
</Analytics>
+5 -9
View File
@@ -19,7 +19,6 @@ import MenuItem, { MenuAnchor } from "components/ContextMenu/MenuItem";
import Separator from "components/ContextMenu/Separator";
import Flex from "components/Flex";
import Guide from "components/Guide";
import useBoolean from "hooks/useBoolean";
import usePrevious from "hooks/usePrevious";
import useStores from "hooks/useStores";
@@ -73,18 +72,15 @@ const AppearanceMenu = React.forwardRef((props, ref) => {
function AccountMenu(props: Props) {
const menu = useMenuState({
unstable_offset: [8, 0],
placement: "bottom-start",
modal: true,
});
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) {
@@ -96,7 +92,7 @@ function AccountMenu(props: Props) {
<>
<Guide
isOpen={keyboardShortcutsOpen}
onRequestClose={handleKeyboardShortcutsClose}
onRequestClose={() => setKeyboardShortcutsOpen(false)}
title={t("Keyboard shortcuts")}
>
<KeyboardShortcuts />
@@ -106,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">
+45 -53
View File
@@ -12,10 +12,9 @@ import CollectionExport from "scenes/CollectionExport";
import CollectionPermissions from "scenes/CollectionPermissions";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template, { filterTemplateItems } from "components/ContextMenu/Template";
import Template 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,63 +99,17 @@ 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);
const items = React.useMemo(
() =>
filterTemplateItems([
{
title: t("New document"),
visible: can.update,
onClick: handleNewDocument,
},
{
title: t("Import document"),
visible: can.update,
onClick: handleImportDocument,
},
{
type: "separator",
},
{
title: `${t("Edit")}`,
visible: can.update,
onClick: () => setShowCollectionEdit(true),
},
{
title: `${t("Permissions")}`,
visible: can.update,
onClick: () => setShowCollectionPermissions(true),
},
{
title: `${t("Export")}`,
visible: !!(collection && can.export),
onClick: () => setShowCollectionExport(true),
},
{
type: "separator",
},
{
title: `${t("Delete")}`,
visible: !!(collection && can.delete),
onClick: () => setShowCollectionDelete(true),
},
]),
[can, collection, handleNewDocument, handleImportDocument, t]
);
if (!items.length) {
return null;
}
return (
<>
@@ -182,7 +134,47 @@ function CollectionMenu({
onClose={onClose}
aria-label={t("Collection")}
>
<Template {...menu} items={items} />
<Template
{...menu}
items={[
{
title: t("New document"),
visible: can.update,
onClick: handleNewDocument,
},
{
title: t("Import document"),
visible: can.update,
onClick: handleImportDocument,
},
{
type: "separator",
},
{
title: `${t("Edit")}`,
visible: can.update,
onClick: () => setShowCollectionEdit(true),
},
{
title: `${t("Permissions")}`,
visible: can.update,
onClick: () => setShowCollectionPermissions(true),
},
{
title: `${t("Export")}`,
visible: !!(collection && can.export),
onClick: () => setShowCollectionExport(true),
},
{
type: "separator",
},
{
title: `${t("Delete")}`,
visible: !!(collection && can.delete),
onClick: () => setShowCollectionDelete(true),
},
]}
/>
</ContextMenu>
{renderModals && (
<>
+11 -13
View File
@@ -18,7 +18,6 @@ import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import getDataTransferFiles from "utils/getDataTransferFiles";
import {
documentHistoryUrl,
@@ -52,8 +51,7 @@ function DocumentMenu({
onOpen,
onClose,
}: Props) {
const { policies, collections, documents } = useStores();
const { showToast } = useToasts();
const { policies, collections, ui, documents } = useStores();
const menu = useMenuState({
modal,
unstable_preventOverflow: true,
@@ -85,33 +83,33 @@ function DocumentMenu({
// when duplicating, go straight to the duplicated document content
history.push(duped.url);
showToast(t("Document duplicated"), { type: "success" });
ui.showToast(t("Document duplicated"), { type: "success" });
},
[t, history, showToast, document]
[ui, t, history, document]
);
const handleArchive = React.useCallback(
async (ev: SyntheticEvent<>) => {
await document.archive();
showToast(t("Document archived"), { type: "success" });
ui.showToast(t("Document archived"), { type: "success" });
},
[showToast, t, document]
[ui, t, document]
);
const handleRestore = React.useCallback(
async (ev: SyntheticEvent<>, options?: { collectionId: string }) => {
await document.restore(options);
showToast(t("Document restored"), { type: "success" });
ui.showToast(t("Document restored"), { type: "success" });
},
[showToast, t, document]
[ui, t, document]
);
const handleUnpublish = React.useCallback(
async (ev: SyntheticEvent<>) => {
await document.unpublish();
showToast(t("Document unpublished"), { type: "success" });
ui.showToast(t("Document unpublished"), { type: "success" });
},
[showToast, t, document]
[ui, t, document]
);
const handlePrint = React.useCallback((ev: SyntheticEvent<>) => {
@@ -183,14 +181,14 @@ function DocumentMenu({
);
history.push(importedDocument.url);
} catch (err) {
showToast(err.message, {
ui.showToast(err.message, {
type: "error",
});
throw err;
}
},
[history, showToast, collection, documents, document.id]
[history, ui, collection, documents, document.id]
);
return (
+6 -6
View File
@@ -11,7 +11,7 @@ import MenuItem from "components/ContextMenu/MenuItem";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Separator from "components/ContextMenu/Separator";
import CopyToClipboard from "components/CopyToClipboard";
import useToasts from "hooks/useToasts";
import useStores from "hooks/useStores";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {|
@@ -22,7 +22,7 @@ type Props = {|
|};
function RevisionMenu({ document, revision, className, iconColor }: Props) {
const { showToast } = useToasts();
const { ui } = useStores();
const menu = useMenuState({ modal: true });
const { t } = useTranslation();
const history = useHistory();
@@ -31,15 +31,15 @@ function RevisionMenu({ document, revision, className, iconColor }: Props) {
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
await document.restore({ revisionId: revision.id });
showToast(t("Document restored"), { type: "success" });
ui.showToast(t("Document restored"), { type: "success" });
history.push(document.url);
},
[history, showToast, t, document, revision]
[history, ui, t, document, revision]
);
const handleCopy = React.useCallback(() => {
showToast(t("Link copied"), { type: "info" });
}, [showToast, t]);
ui.showToast(t("Link copied"), { type: "info" });
}, [ui, t]);
const url = `${window.location.origin}${documentHistoryUrl(
document,
+6 -8
View File
@@ -10,7 +10,6 @@ import MenuItem from "components/ContextMenu/MenuItem";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import CopyToClipboard from "components/CopyToClipboard";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {
share: Share,
@@ -18,8 +17,7 @@ type Props = {
function ShareMenu({ share }: Props) {
const menu = useMenuState({ modal: true });
const { shares, policies } = useStores();
const { showToast } = useToasts();
const { ui, shares, policies } = useStores();
const { t } = useTranslation();
const history = useHistory();
const can = policies.abilities(share.id);
@@ -38,17 +36,17 @@ function ShareMenu({ share }: Props) {
try {
await shares.revoke(share);
showToast(t("Share link revoked"), { type: "info" });
ui.showToast(t("Share link revoked"), { type: "info" });
} catch (err) {
showToast(err.message, { type: "error" });
ui.showToast(err.message, { type: "error" });
}
},
[t, shares, share, showToast]
[t, shares, share, ui]
);
const handleCopy = React.useCallback(() => {
showToast(t("Share link copied"), { type: "info" });
}, [t, showToast]);
ui.showToast(t("Share link copied"), { type: "info" });
}, [t, ui]);
return (
<>
-76
View File
@@ -1,76 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { TableOfContentsIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import Button from "components/Button";
import ContextMenu from "components/ContextMenu";
import Template from "components/ContextMenu/Template";
import { type MenuItem } from "types";
type Props = {|
headings: { title: string, level: number, id: string }[],
|};
function TableOfContentsMenu({ headings }: Props) {
const menu = useMenuState({
modal: true,
unstable_preventOverflow: true,
unstable_fixed: true,
unstable_flip: true,
});
const { t } = useTranslation();
const minHeading = headings.reduce(
(memo, heading) => (heading.level < memo ? heading.level : memo),
Infinity
);
const items: MenuItem[] = React.useMemo(() => {
let i = [
{
type: "heading",
visible: true,
title: t("Contents"),
},
...headings.map((heading) => ({
href: `#${heading.id}`,
title: t(heading.title),
level: heading.level - minHeading,
})),
];
if (i.length === 1) {
i.push({
href: "#",
title: t("Headings you add to the document will appear here"),
disabled: true,
});
}
return i;
}, [t, headings, minHeading]);
return (
<>
<MenuButton {...menu}>
{(props) => (
<Button
{...props}
icon={<TableOfContentsIcon />}
iconColor="currentColor"
borderOnHover
neutral
/>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Table of contents")}>
<Template {...menu} items={items} />
</ContextMenu>
</>
);
}
export default observer(TableOfContentsMenu);
+4 -7
View File
@@ -40,13 +40,13 @@ function TemplatesMenu({ document }: Props) {
{...menu}
>
<DocumentIcon />
<TemplateItem>
<div>
<strong>{template.titleWithDefault}</strong>
<br />
<Author>
{t("By {{ author }}", { author: template.createdBy.name })}
</Author>
</TemplateItem>
</div>
</MenuItem>
);
@@ -70,12 +70,9 @@ function TemplatesMenu({ document }: Props) {
);
}
const TemplateItem = styled.div`
const Author = styled.div`
font-size: 13px;
text-align: left;
`;
const Author = styled.div`
font-size: 13px;
`;
export default observer(TemplatesMenu);
+2 -12
View File
@@ -1,7 +1,6 @@
// @flow
import { addDays, differenceInDays } from "date-fns";
import invariant from "invariant";
import { floor } from "lodash";
import { action, computed, observable, set } from "mobx";
import parseTitle from "shared/utils/parseTitle";
import unescape from "shared/utils/unescape";
@@ -44,7 +43,6 @@ export default class Document extends BaseModel {
deletedAt: ?string;
url: string;
urlId: string;
tasks: { completed: number, total: number };
revision: number;
constructor(fields: Object, store: DocumentsStore) {
@@ -153,16 +151,8 @@ export default class Document extends BaseModel {
}
@computed
get isTasks(): boolean {
return !!this.tasks.total;
}
@computed
get tasksPercentage(): number {
if (!this.isTasks) {
return 0;
}
return floor((this.tasks.completed / this.tasks.total) * 100);
get placeholder(): ?string {
return this.isTemplate ? "Start your template…" : "Start with a title…";
}
@action
+2 -2
View File
@@ -14,7 +14,7 @@ import Trash from "scenes/Trash";
import CenteredContent from "components/CenteredContent";
import Layout from "components/Layout";
import PlaceholderDocument from "components/PlaceholderDocument";
import LoadingPlaceholder from "components/LoadingPlaceholder";
import Route from "components/ProfiledRoute";
import SocketProvider from "components/SocketProvider";
import { matchDocumentSlug as slug } from "utils/routeHelpers";
@@ -43,7 +43,7 @@ export default function AuthenticatedRoutes() {
<React.Suspense
fallback={
<CenteredContent>
<PlaceholderDocument />
<LoadingPlaceholder />
</CenteredContent>
}
>
+4 -6
View File
@@ -6,7 +6,6 @@ import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Input from "components/Input";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
onSubmit: () => void,
@@ -15,8 +14,7 @@ type Props = {|
function APITokenNew({ onSubmit }: Props) {
const [name, setName] = React.useState("");
const [isSaving, setIsSaving] = React.useState(false);
const { apiKeys } = useStores();
const { showToast } = useToasts();
const { apiKeys, ui } = useStores();
const { t } = useTranslation();
const handleSubmit = React.useCallback(async () => {
@@ -24,14 +22,14 @@ function APITokenNew({ onSubmit }: Props) {
try {
await apiKeys.create({ name });
showToast(t("API token created", { type: "success" }));
ui.showToast(t("API token created", { type: "success" }));
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
ui.showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
}, [t, showToast, name, onSubmit, apiKeys]);
}, [t, ui, name, onSubmit, apiKeys]);
const handleNameChange = React.useCallback((event) => {
setName(event.target.value);
+32 -33
View File
@@ -27,11 +27,11 @@ import Flex from "components/Flex";
import Heading from "components/Heading";
import HelpText from "components/HelpText";
import InputSearchPage from "components/InputSearchPage";
import PlaceholderList from "components/List/Placeholder";
import LoadingIndicator from "components/LoadingIndicator";
import { ListPlaceholder } from "components/LoadingPlaceholder";
import Mask from "components/Mask";
import Modal from "components/Modal";
import PaginatedDocumentList from "components/PaginatedDocumentList";
import PlaceholderText from "components/PlaceholderText";
import Scene from "components/Scene";
import Subheading from "components/Subheading";
import Tab from "components/Tab";
@@ -39,11 +39,9 @@ import Tabs from "components/Tabs";
import Tooltip from "components/Tooltip";
import Collection from "../models/Collection";
import { updateCollectionUrl } from "../utils/routeHelpers";
import useBoolean from "hooks/useBoolean";
import useCurrentTeam from "hooks/useCurrentTeam";
import useImportDocument from "hooks/useImportDocument";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import CollectionMenu from "menus/CollectionMenu";
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
@@ -53,15 +51,10 @@ function CollectionScene() {
const match = useRouteMatch();
const { t } = useTranslation();
const { documents, policies, collections, ui } = useStores();
const { showToast } = useToasts();
const team = useCurrentTeam();
const [isFetching, setFetching] = React.useState();
const [error, setError] = React.useState();
const [
permissionsModalOpen,
handlePermissionsModalOpen,
handlePermissionsModalClose,
] = useBoolean();
const [permissionsModalOpen, setPermissionsModalOpen] = React.useState(false);
const id = params.id || "";
const collection: ?Collection =
@@ -109,12 +102,20 @@ function CollectionScene() {
load();
}, [collections, isFetching, collection, error, id, can]);
const handlePermissionsModalOpen = React.useCallback(() => {
setPermissionsModalOpen(true);
}, []);
const handlePermissionsModalClose = React.useCallback(() => {
setPermissionsModalOpen(false);
}, []);
const handleRejection = React.useCallback(() => {
showToast(
ui.showToast(
t("Document not supported try Markdown, Plain text, HTML, or Word"),
{ type: "error" }
);
}, [t, showToast]);
}, [t, ui]);
if (!collection && error) {
return <Search notFound />;
@@ -148,27 +149,25 @@ function CollectionScene() {
/>
</Action>
{can.update && (
<>
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button
as={Link}
to={collection ? newDocumentUrl(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
>
<Button
as={Link}
to={collection ? newDocumentUrl(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
>
{t("New doc")}
</Button>
</Tooltip>
</Action>
<Separator />
</>
{t("New doc")}
</Button>
</Tooltip>
</Action>
)}
<Separator />
<Action>
<CollectionMenu
collection={collection}
@@ -375,9 +374,9 @@ function CollectionScene() {
) : (
<CenteredContent>
<Heading>
<PlaceholderText height={35} />
<Mask height={35} />
</Heading>
<PlaceholderList count={5} />
<ListPlaceholder count={5} />
</CenteredContent>
);
}
+44 -42
View File
@@ -1,60 +1,62 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { withRouter, type RouterHistory } from "react-router-dom";
import CollectionsStore from "stores/CollectionsStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useToasts from "hooks/useToasts";
import { homeUrl } from "utils/routeHelpers";
type Props = {
history: RouterHistory,
collection: Collection,
collections: CollectionsStore,
ui: UiStore,
onSubmit: () => void,
};
function CollectionDelete({ collection, onSubmit }: Props) {
const [isDeleting, setIsDeleting] = React.useState();
const { showToast } = useToasts();
const history = useHistory();
const { t } = useTranslation();
@observer
class CollectionDelete extends React.Component<Props> {
@observable isDeleting: boolean;
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsDeleting(true);
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.isDeleting = true;
try {
await collection.delete();
history.push(homeUrl());
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsDeleting(false);
}
},
[showToast, onSubmit, collection, history]
);
try {
await this.props.collection.delete();
this.props.history.push(homeUrl());
this.props.onSubmit();
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isDeleting = false;
}
};
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans
defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash."
values={{ collectionName: collection.name }}
components={{ em: <strong /> }}
/>
</HelpText>
<Button type="submit" disabled={isDeleting} autoFocus danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure  Delete")}
</Button>
</form>
</Flex>
);
render() {
const { collection } = this.props;
return (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
Are you sure about that? Deleting the{" "}
<strong>{collection.name}</strong> collection is permanent and
cannot be restored, however documents within will be moved to the
trash.
</HelpText>
<Button type="submit" disabled={this.isDeleting} autoFocus danger>
{this.isDeleting ? "Deleting…" : "Im sure  Delete"}
</Button>
</form>
</Flex>
);
}
}
export default observer(CollectionDelete);
export default inject("collections", "ui")(withRouter(CollectionDelete));
+5 -5
View File
@@ -4,7 +4,7 @@ import { inject, observer } from "mobx-react";
import * as React from "react";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Button from "components/Button";
import Flex from "components/Flex";
@@ -16,7 +16,7 @@ import Switch from "components/Switch";
type Props = {
collection: Collection,
toasts: ToastsStore,
ui: UiStore,
auth: AuthStore,
onSubmit: () => void,
t: TFunction,
@@ -46,11 +46,11 @@ class CollectionEdit extends React.Component<Props> {
sort: this.sort,
});
this.props.onSubmit();
this.props.toasts.showToast(t("The collection was updated"), {
this.props.ui.showToast(t("The collection was updated"), {
type: "success",
});
} catch (err) {
this.props.toasts.showToast(err.message, { type: "error" });
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
@@ -148,5 +148,5 @@ class CollectionEdit extends React.Component<Props> {
}
export default withTranslation()<CollectionEdit>(
inject("toasts", "auth")(CollectionEdit)
inject("ui", "auth")(CollectionEdit)
);
+36 -32
View File
@@ -1,7 +1,9 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Button from "components/Button";
import Flex from "components/Flex";
@@ -9,41 +11,43 @@ import HelpText from "components/HelpText";
type Props = {
collection: Collection,
auth: AuthStore,
ui: UiStore,
onSubmit: () => void,
};
function CollectionExport({ collection, onSubmit }: Props) {
const [isLoading, setIsLoading] = React.useState();
const { t } = useTranslation();
@observer
class CollectionExport extends React.Component<Props> {
@observable isLoading: boolean = false;
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsLoading(true);
await collection.export();
setIsLoading(false);
onSubmit();
},
[collection, onSubmit]
);
this.isLoading = true;
await this.props.collection.export();
this.isLoading = false;
this.props.onSubmit();
};
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans
defaults="Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format."
values={{ collectionName: collection.name }}
components={{ em: <strong /> }}
/>
</HelpText>
<Button type="submit" disabled={isLoading} primary>
{isLoading ? `${t("Exporting")}` : t("Export Collection")}
</Button>
</form>
</Flex>
);
render() {
const { collection, auth } = this.props;
if (!auth.user) return null;
return (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
Exporting the collection <strong>{collection.name}</strong> may take
a few seconds. Your documents will be downloaded as a zip of folders
with files in Markdown format.
</HelpText>
<Button type="submit" disabled={this.isLoading} primary>
{this.isLoading ? "Exporting…" : "Export Collection"}
</Button>
</form>
</Flex>
);
}
}
export default observer(CollectionExport);
export default inject("ui", "auth")(CollectionExport);
+4 -4
View File
@@ -7,7 +7,7 @@ import { withTranslation, type TFunction, Trans } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import AuthStore from "stores/AuthStore";
import CollectionsStore from "stores/CollectionsStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Button from "components/Button";
import Flex from "components/Flex";
@@ -20,7 +20,7 @@ import Switch from "components/Switch";
type Props = {
history: RouterHistory,
auth: AuthStore,
toasts: ToastsStore,
ui: UiStore,
collections: CollectionsStore,
onSubmit: () => void,
t: TFunction,
@@ -55,7 +55,7 @@ class CollectionNew extends React.Component<Props> {
this.props.onSubmit();
this.props.history.push(collection.url);
} catch (err) {
this.props.toasts.showToast(err.message, { type: "error" });
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
@@ -169,5 +169,5 @@ class CollectionNew extends React.Component<Props> {
}
export default withTranslation()<CollectionNew>(
inject("collections", "toasts", "auth")(withRouter(CollectionNew))
inject("collections", "ui", "auth")(withRouter(CollectionNew))
);
@@ -8,7 +8,7 @@ import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import CollectionGroupMembershipsStore from "stores/CollectionGroupMembershipsStore";
import GroupsStore from "stores/GroupsStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Group from "models/Group";
import GroupNew from "scenes/GroupNew";
@@ -23,7 +23,7 @@ import Modal from "components/Modal";
import PaginatedList from "components/PaginatedList";
type Props = {
toasts: ToastsStore,
ui: UiStore,
auth: AuthStore,
collection: Collection,
collectionGroupMemberships: CollectionGroupMembershipsStore,
@@ -65,14 +65,14 @@ class AddGroupsToCollection extends React.Component<Props> {
groupId: group.id,
permission: "read_write",
});
this.props.toasts.showToast(
this.props.ui.showToast(
t("{{ groupName }} was added to the collection", {
groupName: group.name,
}),
{ type: "success" }
);
} catch (err) {
this.props.toasts.showToast(t("Could not add user"), { type: "error" });
this.props.ui.showToast(t("Could not add user"), { type: "error" });
console.error(err);
}
};
@@ -147,6 +147,6 @@ export default withTranslation()<AddGroupsToCollection>(
"auth",
"groups",
"collectionGroupMemberships",
"toasts"
"ui"
)(AddGroupsToCollection)
);
@@ -6,7 +6,7 @@ import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import MembershipsStore from "stores/MembershipsStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
import UsersStore from "stores/UsersStore";
import Collection from "models/Collection";
import User from "models/User";
@@ -21,7 +21,7 @@ import PaginatedList from "components/PaginatedList";
import MemberListItem from "./components/MemberListItem";
type Props = {
toasts: ToastsStore,
ui: UiStore,
auth: AuthStore,
collection: Collection,
memberships: MembershipsStore,
@@ -62,14 +62,14 @@ class AddPeopleToCollection extends React.Component<Props> {
userId: user.id,
permission: "read_write",
});
this.props.toasts.showToast(
this.props.ui.showToast(
t("{{ userName }} was added to the collection", {
userName: user.name,
}),
{ type: "success" }
);
} catch (err) {
this.props.toasts.showToast(t("Could not add user"), { type: "error" });
this.props.ui.showToast(t("Could not add user"), { type: "error" });
}
};
@@ -130,5 +130,5 @@ class AddPeopleToCollection extends React.Component<Props> {
}
export default withTranslation()<AddPeopleToCollection>(
inject("auth", "users", "memberships", "toasts")(AddPeopleToCollection)
inject("auth", "users", "memberships", "ui")(AddPeopleToCollection)
);
+24 -34
View File
@@ -17,10 +17,8 @@ import AddGroupsToCollection from "./AddGroupsToCollection";
import AddPeopleToCollection from "./AddPeopleToCollection";
import CollectionGroupMemberListItem from "./components/CollectionGroupMemberListItem";
import MemberListItem from "./components/MemberListItem";
import useBoolean from "hooks/useBoolean";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
collection: Collection,
@@ -30,22 +28,14 @@ function CollectionPermissions({ collection }: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const {
ui,
memberships,
collectionGroupMemberships,
users,
groups,
} = useStores();
const { showToast } = useToasts();
const [
addGroupModalOpen,
handleAddGroupModalOpen,
handleAddGroupModalClose,
] = useBoolean();
const [
addMemberModalOpen,
handleAddMemberModalOpen,
handleAddMemberModalClose,
] = useBoolean();
const [addGroupModalOpen, setAddGroupModalOpen] = React.useState(false);
const [addMemberModalOpen, setAddMemberModalOpen] = React.useState(false);
const handleRemoveUser = React.useCallback(
async (user) => {
@@ -54,7 +44,7 @@ function CollectionPermissions({ collection }: Props) {
collectionId: collection.id,
userId: user.id,
});
showToast(
ui.showToast(
t(`{{ userName }} was removed from the collection`, {
userName: user.name,
}),
@@ -63,10 +53,10 @@ function CollectionPermissions({ collection }: Props) {
}
);
} catch (err) {
showToast(t("Could not remove user"), { type: "error" });
ui.showToast(t("Could not remove user"), { type: "error" });
}
},
[memberships, showToast, collection, t]
[memberships, ui, collection, t]
);
const handleUpdateUser = React.useCallback(
@@ -77,17 +67,17 @@ function CollectionPermissions({ collection }: Props) {
userId: user.id,
permission,
});
showToast(
ui.showToast(
t(`{{ userName }} permissions were updated`, { userName: user.name }),
{
type: "success",
}
);
} catch (err) {
showToast(t("Could not update user"), { type: "error" });
ui.showToast(t("Could not update user"), { type: "error" });
}
},
[memberships, showToast, collection, t]
[memberships, ui, collection, t]
);
const handleRemoveGroup = React.useCallback(
@@ -97,7 +87,7 @@ function CollectionPermissions({ collection }: Props) {
collectionId: collection.id,
groupId: group.id,
});
showToast(
ui.showToast(
t(`The {{ groupName }} group was removed from the collection`, {
groupName: group.name,
}),
@@ -106,10 +96,10 @@ function CollectionPermissions({ collection }: Props) {
}
);
} catch (err) {
showToast(t("Could not remove group"), { type: "error" });
ui.showToast(t("Could not remove group"), { type: "error" });
}
},
[collectionGroupMemberships, showToast, collection, t]
[collectionGroupMemberships, ui, collection, t]
);
const handleUpdateGroup = React.useCallback(
@@ -120,7 +110,7 @@ function CollectionPermissions({ collection }: Props) {
groupId: group.id,
permission,
});
showToast(
ui.showToast(
t(`{{ groupName }} permissions were updated`, {
groupName: group.name,
}),
@@ -129,24 +119,24 @@ function CollectionPermissions({ collection }: Props) {
}
);
} catch (err) {
showToast(t("Could not update user"), { type: "error" });
ui.showToast(t("Could not update user"), { type: "error" });
}
},
[collectionGroupMemberships, showToast, collection, t]
[collectionGroupMemberships, ui, collection, t]
);
const handleChangePermission = React.useCallback(
async (ev) => {
try {
await collection.save({ permission: ev.target.value });
showToast(t("Default access permissions were updated"), {
ui.showToast(t("Default access permissions were updated"), {
type: "success",
});
} catch (err) {
showToast(t("Could not update permissions"), { type: "error" });
ui.showToast(t("Could not update permissions"), { type: "error" });
}
},
[collection, showToast, t]
[collection, ui, t]
);
const fetchOptions = React.useMemo(() => ({ id: collection.id }), [
@@ -193,7 +183,7 @@ function CollectionPermissions({ collection }: Props) {
<Actions>
<Button
type="button"
onClick={handleAddGroupModalOpen}
onClick={() => setAddGroupModalOpen(true)}
icon={<PlusIcon />}
neutral
>
@@ -201,7 +191,7 @@ function CollectionPermissions({ collection }: Props) {
</Button>{" "}
<Button
type="button"
onClick={handleAddMemberModalOpen}
onClick={() => setAddMemberModalOpen(true)}
icon={<PlusIcon />}
neutral
>
@@ -254,24 +244,24 @@ function CollectionPermissions({ collection }: Props) {
title={t(`Add groups to {{ collectionName }}`, {
collectionName: collection.name,
})}
onRequestClose={handleAddGroupModalClose}
onRequestClose={() => setAddGroupModalOpen(false)}
isOpen={addGroupModalOpen}
>
<AddGroupsToCollection
collection={collection}
onSubmit={handleAddGroupModalClose}
onSubmit={() => setAddGroupModalOpen(false)}
/>
</Modal>
<Modal
title={t(`Add people to {{ collectionName }}`, {
collectionName: collection.name,
})}
onRequestClose={handleAddMemberModalClose}
onRequestClose={() => setAddMemberModalOpen(false)}
isOpen={addMemberModalOpen}
>
<AddPeopleToCollection
collection={collection}
onSubmit={handleAddMemberModalClose}
onSubmit={() => setAddMemberModalOpen(false)}
/>
</Modal>
</Flex>
@@ -70,13 +70,11 @@ const Wrapper = styled("div")`
display: none;
position: sticky;
top: 80px;
max-height: calc(100vh - 80px);
box-shadow: 1px 0 0 ${(props) => props.theme.divider};
margin-top: 40px;
margin-right: 2em;
min-height: 40px;
overflow-y: auto;
${breakpoint("desktopLarge")`
margin-left: -16em;
+29 -60
View File
@@ -4,15 +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 getTasks from "shared/utils/getTasks";
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";
@@ -21,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";
@@ -47,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,
@@ -61,8 +67,6 @@ type Props = {
theme: Theme,
auth: AuthStore,
ui: UiStore,
toasts: ToastsStore,
t: TFunction,
};
@observer
@@ -77,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();
@@ -96,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",
@@ -118,7 +116,6 @@ class DocumentScene extends React.Component<Props> {
document.injectTemplate = false;
this.title = document.title;
this.isDirty = true;
this.updateIsDirty();
}
}
@@ -224,8 +221,6 @@ class DocumentScene extends React.Component<Props> {
this.isSaving = true;
this.isPublishing = !!options.publish;
document.tasks = getTasks(document.text);
try {
const savedDocument = await document.save({
...options,
@@ -242,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;
@@ -307,7 +302,6 @@ class DocumentScene extends React.Component<Props> {
auth,
ui,
match,
t,
} = this.props;
const team = auth.team;
const { shareId } = match.params;
@@ -357,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}
/>
</>
)}
@@ -384,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}
@@ -394,51 +383,33 @@ class DocumentScene extends React.Component<Props> {
>
{document.isTemplate && !readOnly && (
<Notice muted>
<Trans>
Youre editing a template. Highlight some text and use the{" "}
<PlaceholderIcon color="currentColor" /> control to add
placeholders that can be filled out when creating new
documents from this template.
</Trans>
Youre editing a template. Highlight some text and use the{" "}
<PlaceholderIcon color="currentColor" /> control to add
placeholders that can be filled out when creating new
documents from this template.
</Notice>
)}
{document.archivedAt && !document.deletedAt && (
<Notice muted>
{t("Archived by {{userName}}", {
userName: document.updatedBy.name,
})}{" "}
<Time dateTime={document.updatedAt} addSuffix />
Archived by {document.updatedBy.name}{" "}
<Time dateTime={document.archivedAt} /> ago
</Notice>
)}
{document.deletedAt && (
<Notice muted>
<strong>
{t("Deleted by {{userName}}", {
userName: document.updatedBy.name,
})}{" "}
<Time dateTime={document.deletedAt || ""} addSuffix />
</strong>
Deleted by {document.updatedBy.name}{" "}
<Time dateTime={document.deletedAt} /> ago
{document.permanentlyDeletedAt && (
<>
<br />
{document.template ? (
<Trans>
This template will be permanently deleted in{" "}
<Time dateTime={document.permanentlyDeletedAt} />{" "}
unless restored.
</Trans>
) : (
<Trans>
This document will be permanently deleted in{" "}
<Time dateTime={document.permanentlyDeletedAt} />{" "}
unless restored.
</Trans>
)}
This {document.noun} will be permanently deleted in{" "}
<Time dateTime={document.permanentlyDeletedAt} /> unless
restored.
</>
)}
</Notice>
)}
<React.Suspense fallback={<PlaceholderDocument />}>
<React.Suspense fallback={<LoadingPlaceholder />}>
<Flex auto={!readOnly}>
{showContents && <Contents headings={headings} />}
<Editor
@@ -536,7 +507,5 @@ const MaxWidth = styled(Flex)`
`;
export default withRouter(
withTranslation()<DocumentScene>(
inject("ui", "auth", "toasts")(DocumentScene)
)
inject("ui", "auth", "policies", "revisions")(DocumentScene)
);
+10 -24
View File
@@ -1,15 +1,13 @@
// @flow
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import Textarea from "react-autosize-textarea";
import { type TFunction, withTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { MAX_TITLE_LENGTH } from "shared/constants";
import { light } from "shared/theme";
import { light } from "shared/styles/theme";
import parseTitle from "shared/utils/parseTitle";
import PoliciesStore from "stores/PoliciesStore";
import Document from "models/Document";
import ClickablePadding from "components/ClickablePadding";
import DocumentMetaWithViews from "components/DocumentMetaWithViews";
@@ -30,14 +28,12 @@ type Props = {|
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
innerRef: { current: any },
children: React.Node,
policies: PoliciesStore,
t: TFunction,
|};
@observer
class DocumentEditor extends React.Component<Props> {
@observable activeLinkEvent: ?MouseEvent;
ref = React.createRef<HTMLDivElement | HTMLInputElement>();
ref = React.createRef<Textarea>();
focusAtStart = () => {
if (this.props.innerRef.current) {
@@ -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}
@@ -156,8 +143,9 @@ class DocumentEditor extends React.Component<Props> {
document={document}
to={documentHistoryUrl(document)}
rtl={
this.ref.current
? window.getComputedStyle(this.ref.current).direction === "rtl"
this.ref.current?.textarea
? window.getComputedStyle(this.ref.current.textarea)
.direction === "rtl"
: false
}
/>
@@ -165,7 +153,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 +225,4 @@ const Title = styled(Textarea)`
}
`;
export default withTranslation()<DocumentEditor>(
inject("policies")(DocumentEditor)
);
export default DocumentEditor;
+1 -14
View File
@@ -24,7 +24,6 @@ import useMobile from "hooks/useMobile";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import NewChildDocumentMenu from "menus/NewChildDocumentMenu";
import TableOfContentsMenu from "menus/TableOfContentsMenu";
import TemplatesMenu from "menus/TemplatesMenu";
import { type NavigationNode } from "types";
import { metaDisplay } from "utils/keyboard";
@@ -47,7 +46,6 @@ type Props = {|
publish?: boolean,
autosave?: boolean,
}) => void,
headings: { title: string, level: number, id: string }[],
|};
function DocumentHeader({
@@ -62,7 +60,6 @@ function DocumentHeader({
publishingIsDisabled,
sharedTree,
onSave,
headings,
}: Props) {
const { t } = useTranslation();
const { auth, ui, policies } = useStores();
@@ -76,7 +73,7 @@ function DocumentHeader({
onSave({ done: true, publish: true });
}, [onSave]);
const isNew = document.isNewDocument;
const isNew = document.isNew;
const isTemplate = document.isTemplate;
const can = policies.abilities(document.id);
const canShareDocument = auth.team && auth.team.sharing && can.share;
@@ -156,11 +153,6 @@ function DocumentHeader({
}
actions={
<>
{isMobile && (
<TocWrapper>
<TableOfContentsMenu headings={headings} />
</TocWrapper>
)}
{!isPublishing && isSaving && <Status>{t("Saving")}</Status>}
<Collaborators
document={document}
@@ -282,9 +274,4 @@ const Status = styled(Action)`
color: ${(props) => props.theme.slate};
`;
const TocWrapper = styled(Action)`
position: absolute;
left: 42px;
`;
export default observer(DocumentHeader);
@@ -8,15 +8,20 @@ import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import Guide from "components/Guide";
import NudeButton from "components/NudeButton";
import Tooltip from "components/Tooltip";
import useBoolean from "hooks/useBoolean";
function KeyboardShortcutsButton() {
const { t } = useTranslation();
const [
keyboardShortcutsOpen,
handleOpenKeyboardShortcuts,
handleCloseKeyboardShortcuts,
] = useBoolean();
const [keyboardShortcutsOpen, setKeyboardShortcutsOpen] = React.useState(
false
);
const handleCloseKeyboardShortcuts = React.useCallback(() => {
setKeyboardShortcutsOpen(false);
}, []);
const handleOpenKeyboardShortcuts = React.useCallback(() => {
setKeyboardShortcutsOpen(true);
}, []);
return (
<>
+2 -2
View File
@@ -2,8 +2,8 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import CenteredContent from "components/CenteredContent";
import LoadingPlaceholder from "components/LoadingPlaceholder";
import PageTitle from "components/PageTitle";
import PlaceholderDocument from "components/PlaceholderDocument";
import Container from "./Container";
import type { LocationWithState } from "types";
@@ -20,7 +20,7 @@ export default function Loading({ location }: Props) {
title={location.state ? location.state.title : t("Untitled")}
/>
<CenteredContent>
<PlaceholderDocument />
<LoadingPlaceholder />
</CenteredContent>
</Container>
);
@@ -16,7 +16,6 @@ import Input from "components/Input";
import Notice from "components/Notice";
import Switch from "components/Switch";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
document: Document,
@@ -27,8 +26,7 @@ type Props = {|
function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
const { t } = useTranslation();
const { policies, shares } = useStores();
const { showToast } = useToasts();
const { policies, shares, ui } = useStores();
const [isCopied, setIsCopied] = React.useState(false);
const timeout = React.useRef<?TimeoutID>();
const can = policies.abilities(share ? share.id : "");
@@ -48,10 +46,10 @@ function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
try {
await share.save({ published: event.currentTarget.checked });
} catch (err) {
showToast(err.message, { type: "error" });
ui.showToast(err.message, { type: "error" });
}
},
[document.id, shares, showToast]
[document.id, shares, ui]
);
const handleChildDocumentsChange = React.useCallback(
@@ -64,10 +62,10 @@ function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
includeChildDocuments: event.currentTarget.checked,
});
} catch (err) {
showToast(err.message, { type: "error" });
ui.showToast(err.message, { type: "error" });
}
},
[document.id, shares, showToast]
[document.id, shares, ui]
);
const handleCopied = React.useCallback(() => {
@@ -77,9 +75,9 @@ function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
setIsCopied(false);
onSubmit();
showToast(t("Share link copied"), { type: "info" });
ui.showToast(t("Share link copied"), { type: "info" });
}, 250);
}, [t, onSubmit, showToast]);
}, [t, onSubmit, ui]);
return (
<>
+1 -2
View File
@@ -8,7 +8,6 @@ import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import { collectionUrl, documentUrl } from "utils/routeHelpers";
type Props = {
@@ -22,7 +21,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
const history = useHistory();
const [isDeleting, setDeleting] = React.useState(false);
const [isArchiving, setArchiving] = React.useState(false);
const { showToast } = useToasts();
const { showToast } = ui;
const canArchive = !document.isDraft && !document.isArchived;
const collection = collections.get(document.collectionId);
+86 -75
View File
@@ -1,34 +1,37 @@
// @flow
import { Search } from "js-search";
import { last } from "lodash";
import { observer } from "mobx-react";
import { observable, computed } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import styled from "styled-components";
import { type DocumentPath } from "stores/CollectionsStore";
import CollectionsStore, { type DocumentPath } from "stores/CollectionsStore";
import DocumentsStore from "stores/DocumentsStore";
import UiStore from "stores/UiStore";
import Document from "models/Document";
import Flex from "components/Flex";
import { Outline } from "components/Input";
import Labeled from "components/Labeled";
import PathToDocument from "components/PathToDocument";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
document: Document,
documents: DocumentsStore,
collections: CollectionsStore,
ui: UiStore,
onRequestClose: () => void,
|};
function DocumentMove({ document, onRequestClose }: Props) {
const [searchTerm, setSearchTerm] = useState();
const { collections, documents } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
@observer
class DocumentMove extends React.Component<Props> {
@observable searchTerm: ?string;
@observable isSaving: boolean;
const searchIndex = useMemo(() => {
@computed
get searchIndex() {
const { collections, documents } = this.props;
const paths = collections.pathsToDocuments;
const index = new Search("id");
index.addIndex("title");
@@ -44,16 +47,19 @@ function DocumentMove({ document, onRequestClose }: Props) {
index.addDocuments(indexeableDocuments);
return index;
}, [documents, collections.pathsToDocuments]);
}
const results: DocumentPath[] = useMemo(() => {
@computed
get results(): DocumentPath[] {
const { document, collections } = this.props;
const onlyShowCollections = document.isTemplate;
let results = [];
if (collections.isLoaded) {
if (searchTerm) {
results = searchIndex.search(searchTerm);
if (this.searchTerm) {
results = this.searchIndex.search(this.searchTerm);
} else {
results = searchIndex._documents;
results = this.searchIndex._documents;
}
}
@@ -76,18 +82,19 @@ function DocumentMove({ document, onRequestClose }: Props) {
}
return results;
}, [document, collections, searchTerm, searchIndex]);
}
const handleSuccess = () => {
showToast(t("Document moved"), { type: "info" });
onRequestClose();
handleSuccess = () => {
this.props.ui.showToast("Document moved", { type: "info" });
this.props.onRequestClose();
};
const handleFilter = (ev: SyntheticInputEvent<*>) => {
setSearchTerm(ev.target.value);
handleFilter = (ev: SyntheticInputEvent<*>) => {
this.searchTerm = ev.target.value;
};
const renderPathToCurrentDocument = () => {
renderPathToCurrentDocument() {
const { collections, document } = this.props;
const result = collections.getPathForDocument(document.id);
if (result) {
@@ -98,71 +105,75 @@ function DocumentMove({ document, onRequestClose }: Props) {
/>
);
}
};
}
const row = ({ index, data, style }) => {
row = ({ index, data, style }) => {
const result = data[index];
const { document, collections } = this.props;
return (
<PathToDocument
result={result}
document={document}
collection={collections.get(result.collectionId)}
onSuccess={handleSuccess}
onSuccess={this.handleSuccess}
style={style}
/>
);
};
const data = results;
render() {
const { document, collections } = this.props;
const data = this.results;
if (!document || !collections.isLoaded) {
return null;
if (!document || !collections.isLoaded) {
return null;
}
return (
<Flex column>
<Section>
<Labeled label="Current location">
{this.renderPathToCurrentDocument()}
</Labeled>
</Section>
<Section column>
<Labeled label="Choose a new location" />
<NewLocation>
<InputWrapper>
<Input
type="search"
placeholder="Search collections & documents…"
onChange={this.handleFilter}
required
autoFocus
/>
</InputWrapper>
<Results>
<AutoSizer>
{({ width, height }) => (
<Flex role="listbox" column>
<List
key={data.length}
width={width}
height={height}
itemData={data}
itemCount={data.length}
itemSize={40}
itemKey={(index, data) => data[index].id}
>
{this.row}
</List>
</Flex>
)}
</AutoSizer>
</Results>
</NewLocation>
</Section>
</Flex>
);
}
return (
<Flex column>
<Section>
<Labeled label={t("Current location")}>
{renderPathToCurrentDocument()}
</Labeled>
</Section>
<Section column>
<Labeled label={t("Choose a new location")} />
<NewLocation>
<InputWrapper>
<Input
type="search"
placeholder={`${t("Search collections & documents")}`}
onChange={handleFilter}
required
autoFocus
/>
</InputWrapper>
<Results>
<AutoSizer>
{({ width, height }) => (
<Flex role="listbox" column>
<List
key={data.length}
width={width}
height={height}
itemData={data}
itemCount={data.length}
itemSize={40}
itemKey={(index, data) => data[index].id}
>
{row}
</List>
</Flex>
)}
</AutoSizer>
</Results>
</NewLocation>
</Section>
</Flex>
);
}
const InputWrapper = styled("div")`
@@ -199,4 +210,4 @@ const Section = styled(Flex)`
margin-bottom: 24px;
`;
export default observer(DocumentMove);
export default inject("documents", "collections", "ui")(DocumentMove);
+4 -6
View File
@@ -7,9 +7,8 @@ import { useTranslation } from "react-i18next";
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
import CenteredContent from "components/CenteredContent";
import Flex from "components/Flex";
import PlaceholderDocument from "components/PlaceholderDocument";
import LoadingPlaceholder from "components/LoadingPlaceholder";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import { editDocumentUrl } from "utils/routeHelpers";
function DocumentNew() {
@@ -17,8 +16,7 @@ function DocumentNew() {
const location = useLocation();
const match = useRouteMatch();
const { t } = useTranslation();
const { documents, collections } = useStores();
const { showToast } = useToasts();
const { documents, ui, collections } = useStores();
const id = match.params.id || "";
useEffect(() => {
@@ -38,7 +36,7 @@ function DocumentNew() {
history.replace(editDocumentUrl(document));
} catch (err) {
showToast(t("Couldnt create the document, try again?"), {
ui.showToast(t("Couldnt create the document, try again?"), {
type: "error",
});
history.goBack();
@@ -50,7 +48,7 @@ function DocumentNew() {
return (
<Flex column auto>
<CenteredContent>
<PlaceholderDocument />
<LoadingPlaceholder />
</CenteredContent>
</Flex>
);
+2 -3
View File
@@ -8,7 +8,6 @@ import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {|
document: Document,
@@ -18,8 +17,8 @@ type Props = {|
function DocumentPermanentDelete({ document, onSubmit }: Props) {
const [isDeleting, setIsDeleting] = React.useState(false);
const { t } = useTranslation();
const { documents } = useStores();
const { showToast } = useToasts();
const { ui, documents } = useStores();
const { showToast } = ui;
const history = useHistory();
const handleSubmit = React.useCallback(
+45 -46
View File
@@ -1,64 +1,63 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { withRouter, type RouterHistory } from "react-router-dom";
import UiStore from "stores/UiStore";
import Document from "models/Document";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useToasts from "hooks/useToasts";
import { documentUrl } from "utils/routeHelpers";
type Props = {
ui: UiStore,
document: Document,
history: RouterHistory,
onSubmit: () => void,
};
function DocumentTemplatize({ document, onSubmit }: Props) {
const [isSaving, setIsSaving] = useState();
const history = useHistory();
const { showToast } = useToasts();
const { t } = useTranslation();
@observer
class DocumentTemplatize extends React.Component<Props> {
@observable isSaving: boolean;
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsSaving(true);
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.isSaving = true;
try {
const template = await document.templatize();
history.push(documentUrl(template));
showToast(t("Template created, go ahead and customize it"), {
type: "info",
});
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
},
[document, showToast, history, onSubmit, t]
);
try {
const template = await this.props.document.templatize();
this.props.history.push(documentUrl(template));
this.props.ui.showToast("Template created, go ahead and customize it", {
type: "info",
});
this.props.onSubmit();
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
};
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans
defaults="Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents."
values={{ titleWithDefault: document.titleWithDefault }}
components={{ em: <strong /> }}
/>
</HelpText>
<Button type="submit">
{isSaving ? `${t("Creating")}` : t("Create template")}
</Button>
</form>
</Flex>
);
render() {
const { document } = this.props;
return (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
Creating a template from{" "}
<strong>{document.titleWithDefault}</strong> is a non-destructive
action we'll make a copy of the document and turn it into a
template that can be used as a starting point for new documents.
</HelpText>
<Button type="submit">
{this.isSaving ? "Creating…" : "Create template"}
</Button>
</form>
</Flex>
);
}
}
export default observer(DocumentTemplatize);
export default inject("ui")(withRouter(DocumentTemplatize));
+4 -7
View File
@@ -50,13 +50,10 @@ class Drafts extends React.Component<Props> {
}) => {
this.props.history.replace({
pathname: this.props.location.pathname,
search: queryString.stringify(
{
...queryString.parse(this.props.location.search),
...search,
},
{ skipEmptyString: true }
),
search: queryString.stringify({
...queryString.parse(this.props.location.search),
...search,
}),
});
};
+37 -35
View File
@@ -1,57 +1,59 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { withRouter, type RouterHistory } from "react-router-dom";
import { groupSettings } from "shared/utils/routeHelpers";
import UiStore from "stores/UiStore";
import Group from "models/Group";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useToasts from "hooks/useToasts";
type Props = {|
type Props = {
history: RouterHistory,
group: Group,
ui: UiStore,
onSubmit: () => void,
|};
};
function GroupDelete({ group, onSubmit }: Props) {
const { t } = useTranslation();
const { showToast } = useToasts();
const history = useHistory();
const [isDeleting, setIsDeleting] = React.useState();
@observer
class GroupDelete extends React.Component<Props> {
@observable isDeleting: boolean;
const handleSubmit = async (ev: SyntheticEvent<>) => {
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsDeleting(true);
this.isDeleting = true;
try {
await group.delete();
history.push(groupSettings());
onSubmit();
await this.props.group.delete();
this.props.history.push(groupSettings());
this.props.onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
this.props.ui.showToast(err.message, { type: "error" });
} finally {
setIsDeleting(false);
this.isDeleting = false;
}
};
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans
defaults="Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with."
values={{ groupName: group.name }}
components={{ em: <strong /> }}
/>
</HelpText>
<Button type="submit" danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure  Delete")}
</Button>
</form>
</Flex>
);
render() {
const { group } = this.props;
return (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
Are you sure about that? Deleting the <strong>{group.name}</strong>{" "}
group will cause its members to lose access to collections and
documents that it is associated with.
</HelpText>
<Button type="submit" danger>
{this.isDeleting ? "Deleting…" : "Im sure  Delete"}
</Button>
</form>
</Flex>
);
}
}
export default observer(GroupDelete);
export default inject("ui")(withRouter(GroupDelete));
+48 -49
View File
@@ -1,71 +1,70 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import UiStore from "stores/UiStore";
import Group from "models/Group";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Input from "components/Input";
import useToasts from "hooks/useToasts";
type Props = {
history: RouterHistory,
ui: UiStore,
group: Group,
onSubmit: () => void,
};
function GroupEdit({ group, onSubmit }: Props) {
const { showToast } = useToasts();
const { t } = useTranslation();
const [name, setName] = React.useState(group.name);
const [isSaving, setIsSaving] = React.useState();
@observer
class GroupEdit extends React.Component<Props> {
@observable name: string = this.props.group.name;
@observable isSaving: boolean;
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsSaving(true);
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.isSaving = true;
try {
await group.save({ name: name });
onSubmit();
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
},
[group, onSubmit, showToast, name]
);
try {
await this.props.group.save({ name: this.name });
this.props.onSubmit();
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
};
const handleNameChange = React.useCallback((ev: SyntheticInputEvent<*>) => {
setName(ev.target.value);
}, []);
handleNameChange = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
};
return (
<form onSubmit={handleSubmit}>
<HelpText>
<Trans>
render() {
return (
<form onSubmit={this.handleSubmit}>
<HelpText>
You can edit the name of this group at any time, however doing so too
often might confuse your team mates.
</Trans>
</HelpText>
<Flex>
<Input
type="text"
label={t("Name")}
onChange={handleNameChange}
value={name}
required
autoFocus
flex
/>
</Flex>
</HelpText>
<Flex>
<Input
type="text"
label="Name"
onChange={this.handleNameChange}
value={this.name}
required
autoFocus
flex
/>
</Flex>
<Button type="submit" disabled={isSaving || !name}>
{isSaving ? `${t("Saving")}` : t("Save")}
</Button>
</form>
);
<Button type="submit" disabled={this.isSaving || !this.name}>
{this.isSaving ? "Saving" : "Save"}
</Button>
</form>
);
}
}
export default observer(GroupEdit);
export default inject("ui")(withRouter(GroupEdit));
+5 -5
View File
@@ -6,7 +6,7 @@ import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import GroupMembershipsStore from "stores/GroupMembershipsStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
import UsersStore from "stores/UsersStore";
import Group from "models/Group";
import User from "models/User";
@@ -21,7 +21,7 @@ import PaginatedList from "components/PaginatedList";
import GroupMemberListItem from "./components/GroupMemberListItem";
type Props = {
toasts: ToastsStore,
ui: UiStore,
auth: AuthStore,
group: Group,
groupMemberships: GroupMembershipsStore,
@@ -62,12 +62,12 @@ class AddPeopleToGroup extends React.Component<Props> {
groupId: this.props.group.id,
userId: user.id,
});
this.props.toasts.showToast(
this.props.ui.showToast(
t(`{{userName}} was added to the group`, { userName: user.name }),
{ type: "success" }
);
} catch (err) {
this.props.toasts.showToast(t("Could not add user"), { type: "error" });
this.props.ui.showToast(t("Could not add user"), { type: "error" });
}
};
@@ -128,5 +128,5 @@ class AddPeopleToGroup extends React.Component<Props> {
}
export default withTranslation()<AddPeopleToGroup>(
inject("auth", "users", "groupMemberships", "toasts")(AddPeopleToGroup)
inject("auth", "users", "groupMemberships", "ui")(AddPeopleToGroup)
);
+95 -78
View File
@@ -1,8 +1,14 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { withTranslation, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import GroupMembershipsStore from "stores/GroupMembershipsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import UsersStore from "stores/UsersStore";
import Group from "models/Group";
import User from "models/User";
import Button from "components/Button";
@@ -14,101 +20,112 @@ import PaginatedList from "components/PaginatedList";
import Subheading from "components/Subheading";
import AddPeopleToGroup from "./AddPeopleToGroup";
import GroupMemberListItem from "./components/GroupMemberListItem";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {
ui: UiStore,
auth: AuthStore,
group: Group,
users: UsersStore,
policies: PoliciesStore,
groupMemberships: GroupMembershipsStore,
t: TFunction,
};
function GroupMembers({ group }: Props) {
const [addModalOpen, setAddModalOpen] = React.useState();
const { users, groupMemberships, policies } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const can = policies.abilities(group.id);
@observer
class GroupMembers extends React.Component<Props> {
@observable addModalOpen: boolean = false;
const handleAddModal = (state) => {
setAddModalOpen(state);
handleAddModalOpen = () => {
this.addModalOpen = true;
};
const handleRemoveUser = async (user: User) => {
handleAddModalClose = () => {
this.addModalOpen = false;
};
handleRemoveUser = async (user: User) => {
const { t } = this.props;
try {
await groupMemberships.delete({
groupId: group.id,
await this.props.groupMemberships.delete({
groupId: this.props.group.id,
userId: user.id,
});
showToast(
this.props.ui.showToast(
t(`{{userName}} was removed from the group`, { userName: user.name }),
{ type: "success" }
);
} catch (err) {
showToast(t("Could not remove user"), { type: "error" });
this.props.ui.showToast(t("Could not remove user"), { type: "error" });
}
};
return (
<Flex column>
{can.update ? (
<>
<HelpText>
<Trans
defaults="Add and remove team members in the <em>{{groupName}}</em> group. Adding people to the group will give them access to any collections this group has been added to."
values={{ groupName: group.name }}
components={{ em: <strong /> }}
/>
</HelpText>
<span>
<Button
type="button"
onClick={() => handleAddModal(true)}
icon={<PlusIcon />}
neutral
>
{t("Add people")}
</Button>
</span>
</>
) : (
<HelpText>
<Trans
defaults="Listing team members in the <em>{{groupName}}</em> group."
values={{ groupName: group.name }}
components={{ em: <strong /> }}
/>
</HelpText>
)}
render() {
const { group, users, groupMemberships, policies, t, auth } = this.props;
const { user } = auth;
if (!user) return null;
<Subheading>
<Trans>Members</Trans>
</Subheading>
<PaginatedList
items={users.inGroup(group.id)}
fetch={groupMemberships.fetchPage}
options={{ id: group.id }}
empty={<Empty>{t("This group has no members.")}</Empty>}
renderItem={(item) => (
<GroupMemberListItem
key={item.id}
user={item}
onRemove={can.update ? () => handleRemoveUser(item) : undefined}
/>
const can = policies.abilities(group.id);
return (
<Flex column>
{can.update ? (
<>
<HelpText>
Add and remove team members in the <strong>{group.name}</strong>{" "}
group. Adding people to the group will give them access to any
collections this group has been added to.
</HelpText>
<span>
<Button
type="button"
onClick={this.handleAddModalOpen}
icon={<PlusIcon />}
neutral
>
{t("Add people")}
</Button>
</span>
</>
) : (
<HelpText>
Listing team members in the <strong>{group.name}</strong> group.
</HelpText>
)}
/>
{can.update && (
<Modal
title={t(`Add people to {{groupName}}`, { groupName: group.name })}
onRequestClose={() => handleAddModal(false)}
isOpen={addModalOpen}
>
<AddPeopleToGroup
group={group}
onSubmit={() => handleAddModal(false)}
/>
</Modal>
)}
</Flex>
);
<Subheading>Members</Subheading>
<PaginatedList
items={users.inGroup(group.id)}
fetch={groupMemberships.fetchPage}
options={{ id: group.id }}
empty={<Empty>{t("This group has no members.")}</Empty>}
renderItem={(item) => (
<GroupMemberListItem
key={item.id}
user={item}
onRemove={
can.update ? () => this.handleRemoveUser(item) : undefined
}
/>
)}
/>
{can.update && (
<Modal
title={`Add people to ${group.name}`}
onRequestClose={this.handleAddModalClose}
isOpen={this.addModalOpen}
>
<AddPeopleToGroup
group={group}
onSubmit={this.handleAddModalClose}
/>
</Modal>
)}
</Flex>
);
}
}
export default observer(GroupMembers);
export default withTranslation()<GroupMembers>(
inject("auth", "users", "policies", "groupMemberships", "ui")(GroupMembers)
);
+55 -55
View File
@@ -1,7 +1,10 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import GroupsStore from "stores/GroupsStore";
import UiStore from "stores/UiStore";
import Group from "models/Group";
import GroupMembers from "scenes/GroupMembers";
import Button from "components/Button";
@@ -9,82 +12,79 @@ import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Input from "components/Input";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
type Props = {
history: RouterHistory,
ui: UiStore,
groups: GroupsStore,
onSubmit: () => void,
};
function GroupNew({ onSubmit }: Props) {
const { groups } = useStores();
const { t } = useTranslation();
const { showToast } = useToasts();
const [name, setName] = React.useState();
const [isSaving, setIsSaving] = React.useState();
const [group, setGroup] = React.useState();
@observer
class GroupNew extends React.Component<Props> {
@observable name: string = "";
@observable isSaving: boolean;
@observable group: Group;
const handleSubmit = async (ev: SyntheticEvent<>) => {
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsSaving(true);
this.isSaving = true;
const group = new Group(
{
name: name,
name: this.name,
},
groups
this.props.groups
);
try {
setGroup(await group.save());
this.group = await group.save();
} catch (err) {
showToast(err.message, { type: "error" });
this.props.ui.showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
this.isSaving = false;
}
};
const handleNameChange = (ev: SyntheticInputEvent<*>) => {
setName(ev.target.value);
handleNameChange = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
};
return (
<>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans>
render() {
return (
<>
<form onSubmit={this.handleSubmit}>
<HelpText>
Groups are for organizing your team. They work best when centered
around a function or a responsibility Support or Engineering for
example.
</Trans>
</HelpText>
<Flex>
<Input
type="text"
label="Name"
onChange={handleNameChange}
value={name}
required
autoFocus
flex
/>
</Flex>
<HelpText>
<Trans>Youll be able to add people to the group next.</Trans>
</HelpText>
</HelpText>
<Flex>
<Input
type="text"
label="Name"
onChange={this.handleNameChange}
value={this.name}
required
autoFocus
flex
/>
</Flex>
<HelpText>Youll be able to add people to the group next.</HelpText>
<Button type="submit" disabled={isSaving || !name}>
{isSaving ? `${t("Creating")}` : t("Continue")}
</Button>
</form>
<Modal
title={t("Group members")}
onRequestClose={onSubmit}
isOpen={!!group}
>
<GroupMembers group={group} />
</Modal>
</>
);
<Button type="submit" disabled={this.isSaving || !this.name}>
{this.isSaving ? "Creating" : "Continue"}
</Button>
</form>
<Modal
title="Group members"
onRequestClose={this.props.onSubmit}
isOpen={!!this.group}
>
<GroupMembers group={this.group} />
</Modal>
</>
);
}
}
export default observer(GroupNew);
export default inject("groups", "ui")(withRouter(GroupNew));
+7 -17
View File
@@ -5,7 +5,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Switch, Route } from "react-router-dom";
import { Action } from "components/Actions";
import Empty from "components/Empty";
import Heading from "components/Heading";
import InputSearchPage from "components/InputSearchPage";
import LanguagePrompt from "components/LanguagePrompt";
@@ -42,19 +41,19 @@ function Home() {
<Heading>{t("Home")}</Heading>
<Tabs>
<Tab to="/home" exact>
{t("Recently viewed")}
{t("Recently updated")}
</Tab>
<Tab to="/home/recent" exact>
{t("Recently updated")}
{t("Recently viewed")}
</Tab>
<Tab to="/home/created">{t("Created by me")}</Tab>
</Tabs>
<Switch>
<Route path="/home/recent">
<PaginatedDocumentList
documents={documents.recentlyUpdated}
fetch={documents.fetchRecentlyUpdated}
empty={<Empty>{t("Weird, this shouldnt ever be empty")}</Empty>}
key="recent"
documents={documents.recentlyViewed}
fetch={documents.fetchRecentlyViewed}
showCollection
/>
</Route>
@@ -64,22 +63,13 @@ function Home() {
documents={documents.createdByUser(user)}
fetch={documents.fetchOwned}
options={{ user }}
empty={<Empty>{t("Weird, this shouldnt ever be empty")}</Empty>}
showCollection
/>
</Route>
<Route path="/home">
<PaginatedDocumentList
key="recent"
documents={documents.recentlyViewed}
fetch={documents.fetchRecentlyViewed}
empty={
<Empty>
{t(
"Documents youve recently viewed will be here for easy access"
)}
</Empty>
}
documents={documents.recentlyUpdated}
fetch={documents.fetchRecentlyUpdated}
showCollection
/>
</Route>
+165 -175
View File
@@ -1,10 +1,14 @@
// @flow
import { observer } from "mobx-react";
import { observable, action } from "mobx";
import { inject, observer } from "mobx-react";
import { LinkIcon, CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
import { Link, withRouter, type RouterHistory } from "react-router-dom";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import UsersStore from "stores/UsersStore";
import Button from "components/Button";
import CopyToClipboard from "components/CopyToClipboard";
import Flex from "components/Flex";
@@ -12,212 +16,198 @@ import HelpText from "components/HelpText";
import Input from "components/Input";
import NudeButton from "components/NudeButton";
import Tooltip from "components/Tooltip";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
const MAX_INVITES = 20;
type Props = {|
type Props = {
auth: AuthStore,
users: UsersStore,
history: RouterHistory,
policies: PoliciesStore,
ui: UiStore,
onSubmit: () => void,
|};
};
type InviteRequest = {
email: string,
name: string,
};
function Invite({ onSubmit }: Props) {
const [isSaving, setIsSaving] = React.useState();
const [linkCopied, setLinkCopied] = React.useState<boolean>(false);
const [invites, setInvites] = React.useState<InviteRequest[]>([
@observer
class Invite extends React.Component<Props> {
@observable isSaving: boolean;
@observable linkCopied: boolean = false;
@observable
invites: InviteRequest[] = [
{ email: "", name: "" },
{ email: "", name: "" },
{ email: "", name: "" },
]);
];
const { users, policies } = useStores();
const { showToast } = useToasts();
const user = useCurrentUser();
const team = useCurrentTeam();
const { t } = useTranslation();
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.isSaving = true;
const predictedDomain = user.email.split("@")[1];
const can = policies.abilities(team.id);
try {
await this.props.users.invite(this.invites);
this.props.onSubmit();
this.props.ui.showToast("We sent out your invites!", { type: "success" });
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
} finally {
this.isSaving = false;
}
};
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
setIsSaving(true);
@action
handleChange = (ev, index) => {
this.invites[index][ev.target.name] = ev.target.value;
};
try {
await users.invite(invites);
onSubmit();
showToast(t("We sent out your invites!"), { type: "success" });
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
},
[onSubmit, showToast, invites, t, users]
);
@action
handleGuestChange = (ev, index) => {
this.invites[index][ev.target.name] = ev.target.checked;
};
const handleChange = React.useCallback((ev, index) => {
setInvites((prevInvites) => {
const newInvites = [...prevInvites];
newInvites[index][ev.target.name] = ev.target.value;
return newInvites;
});
}, []);
const handleAdd = React.useCallback(() => {
if (invites.length >= MAX_INVITES) {
showToast(
t("Sorry, you can only send {{MAX_INVITES}} invites at a time", {
MAX_INVITES,
}),
@action
handleAdd = () => {
if (this.invites.length >= MAX_INVITES) {
this.props.ui.showToast(
`Sorry, you can only send ${MAX_INVITES} invites at a time`,
{ type: "warning" }
);
}
setInvites((prevInvites) => {
const newInvites = [...prevInvites];
newInvites.push({ email: "", name: "" });
return newInvites;
});
}, [showToast, invites, t]);
this.invites.push({ email: "", name: "" });
};
const handleRemove = React.useCallback(
(ev: SyntheticEvent<>, index: number) => {
ev.preventDefault();
@action
handleRemove = (ev: SyntheticEvent<>, index: number) => {
ev.preventDefault();
this.invites.splice(index, 1);
};
setInvites((prevInvites) => {
const newInvites = [...prevInvites];
newInvites.splice(index, 1);
return newInvites;
});
},
[]
);
const handleCopy = React.useCallback(() => {
setLinkCopied(true);
showToast(t("Share link copied"), {
handleCopy = () => {
this.linkCopied = true;
this.props.ui.showToast("Share link copied", {
type: "success",
});
}, [showToast, t]);
};
return (
<form onSubmit={handleSubmit}>
{team.guestSignin ? (
<HelpText>
<Trans
defaults="Invite team members or guests to join your knowledge base. Team members can sign in with {{signinMethods}} or use their email address."
values={{ signinMethods: team.signinMethods }}
/>
</HelpText>
) : (
<HelpText>
<Trans
defaults="Invite team members to join your knowledge base. They will need to sign in with {{signinMethods}}."
values={{ signinMethods: team.signinMethods }}
/>{" "}
{can.update && (
<Trans>
As an admin you can also{" "}
<Link to="/settings/security">enable email sign-in</Link>.
</Trans>
)}
</HelpText>
)}
{team.subdomain && (
<CopyBlock>
<Flex align="flex-end">
render() {
const { team, user } = this.props.auth;
if (!team || !user) return null;
const predictedDomain = user.email.split("@")[1];
const can = this.props.policies.abilities(team.id);
return (
<form onSubmit={this.handleSubmit}>
{team.guestSignin ? (
<HelpText>
Invite team members or guests to join your knowledge base. Team
members can sign in with {team.signinMethods} or use their email
address.
</HelpText>
) : (
<HelpText>
Invite team members to join your knowledge base. They will need to
sign in with {team.signinMethods}.{" "}
{can.update && (
<>
As an admin you can also{" "}
<Link to="/settings/security">enable email sign-in</Link>.
</>
)}
</HelpText>
)}
{team.subdomain && (
<CopyBlock>
<Flex align="flex-end">
<Input
type="text"
value={team.url}
label="Want a link to share directly with your team?"
readOnly
flex
/>
&nbsp;&nbsp;
<CopyToClipboard text={team.url} onCopy={this.handleCopy}>
<Button
type="button"
icon={<LinkIcon />}
style={{ marginBottom: "16px" }}
neutral
>
{this.linkCopied ? "Link copied" : "Copy link"}
</Button>
</CopyToClipboard>
</Flex>
<p>
<hr />
</p>
</CopyBlock>
)}
{this.invites.map((invite, index) => (
<Flex key={index}>
<Input
type="text"
value={team.url}
label={t("Want a link to share directly with your team?")}
readOnly
type="email"
name="email"
label="Email"
labelHidden={index !== 0}
onChange={(ev) => this.handleChange(ev, index)}
placeholder={`example@${predictedDomain}`}
value={invite.email}
required={index === 0}
autoFocus={index === 0}
flex
/>
&nbsp;&nbsp;
<CopyToClipboard text={team.url} onCopy={handleCopy}>
<Button
type="button"
icon={<LinkIcon />}
style={{ marginBottom: "16px" }}
neutral
>
{linkCopied ? t("Link copied") : t("Copy link")}
</Button>
</CopyToClipboard>
<Input
type="text"
name="name"
label="Full name"
labelHidden={index !== 0}
onChange={(ev) => this.handleChange(ev, index)}
value={invite.name}
required={!!invite.email}
flex
/>
{index !== 0 && (
<Remove>
<Tooltip tooltip="Remove invite" placement="top">
<NudeButton onClick={(ev) => this.handleRemove(ev, index)}>
<CloseIcon />
</NudeButton>
</Tooltip>
</Remove>
)}
</Flex>
<p>
<hr />
</p>
</CopyBlock>
)}
{invites.map((invite, index) => (
<Flex key={index}>
<Input
type="email"
name="email"
label={t("Email")}
labelHidden={index !== 0}
onChange={(ev) => handleChange(ev, index)}
placeholder={`example@${predictedDomain}`}
value={invite.email}
required={index === 0}
autoFocus={index === 0}
flex
/>
&nbsp;&nbsp;
<Input
type="text"
name="name"
label={t("Full name")}
labelHidden={index !== 0}
onChange={(ev) => handleChange(ev, index)}
value={invite.name}
required={!!invite.email}
flex
/>
{index !== 0 && (
<Remove>
<Tooltip tooltip={t("Remove invite")} placement="top">
<NudeButton onClick={(ev) => handleRemove(ev, index)}>
<CloseIcon />
</NudeButton>
</Tooltip>
</Remove>
))}
<Flex justify="space-between">
{this.invites.length <= MAX_INVITES ? (
<Button type="button" onClick={this.handleAdd} neutral>
Add another
</Button>
) : (
<span />
)}
</Flex>
))}
<Flex justify="space-between">
{invites.length <= MAX_INVITES ? (
<Button type="button" onClick={handleAdd} neutral>
<Trans>Add another</Trans>
<Button
type="submit"
disabled={this.isSaving}
data-on="click"
data-event-category="invite"
data-event-action="sendInvites"
>
{this.isSaving ? "Inviting…" : "Send Invites"}
</Button>
) : (
<span />
)}
<Button
type="submit"
disabled={isSaving}
data-on="click"
data-event-category="invite"
data-event-action="sendInvites"
>
{isSaving ? `${t("Inviting")}` : t("Send Invites")}
</Button>
</Flex>
<br />
</form>
);
</Flex>
<br />
</form>
);
}
}
const CopyBlock = styled("div")`
@@ -231,4 +221,4 @@ const Remove = styled("div")`
right: -32px;
`;
export default observer(Invite);
export default inject("auth", "users", "policies", "ui")(withRouter(Invite));
+5 -9
View File
@@ -1,7 +1,6 @@
// @flow
import { EmailIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import styled from "styled-components";
import AuthLogo from "components/AuthLogo";
import ButtonLarge from "components/ButtonLarge";
@@ -14,7 +13,6 @@ type Props = {
authUrl: string,
isCreate: boolean,
onEmailSuccess: (email: string) => void,
t: TFunction,
};
type State = {
@@ -58,7 +56,7 @@ class Provider extends React.Component<Props, State> {
};
render() {
const { isCreate, id, name, authUrl, t } = this.props;
const { isCreate, id, name, authUrl } = this.props;
if (id === "email") {
if (isCreate) {
@@ -86,12 +84,12 @@ class Provider extends React.Component<Props, State> {
short
/>
<ButtonLarge type="submit" disabled={this.state.isSubmitting}>
{t("Sign In")}
Sign In
</ButtonLarge>
</>
) : (
<ButtonLarge type="submit" icon={<EmailIcon />} fullwidth>
{t("Continue with Email")}
Continue with Email
</ButtonLarge>
)}
</Form>
@@ -106,9 +104,7 @@ class Provider extends React.Component<Props, State> {
icon={<AuthLogo providerName={id} />}
fullwidth
>
{t("Continue with {{ authProviderName }}", {
authProviderName: name,
})}
Continue with {name}
</ButtonLarge>
</Wrapper>
);
@@ -126,4 +122,4 @@ const Form = styled.form`
justify-content: space-between;
`;
export default withTranslation()<Provider>(Provider);
export default Provider;
+13 -35
View File
@@ -3,8 +3,7 @@ import { find } from "lodash";
import { observer } from "mobx-react";
import { BackIcon, EmailIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link, type Location, Redirect } from "react-router-dom";
import { Redirect, Link, type Location } from "react-router-dom";
import styled from "styled-components";
import { setCookie } from "tiny-cookie";
import ButtonLarge from "components/ButtonLarge";
@@ -15,7 +14,6 @@ import HelpText from "components/HelpText";
import OutlineLogo from "components/OutlineLogo";
import PageTitle from "components/PageTitle";
import TeamLogo from "components/TeamLogo";
import { changeLanguage, detectLanguage } from "../../utils/language";
import Notices from "./Notices";
import Provider from "./Provider";
import env from "env";
@@ -28,7 +26,6 @@ type Props = {|
function Login({ location }: Props) {
const query = useQuery();
const { t, i18n } = useTranslation();
const { auth } = useStores();
const { config } = auth;
const [emailLinkSentTo, setEmailLinkSentTo] = React.useState("");
@@ -46,13 +43,6 @@ function Login({ location }: Props) {
auth.fetchConfig();
}, [auth]);
// TODO: Persist detected language to new user
// Try to detect the user's language and show the login page on its idiom
// if translation is available
React.useEffect(() => {
changeLanguage(detectLanguage(), i18n);
}, [i18n]);
React.useEffect(() => {
const entries = Object.fromEntries(query.entries());
@@ -83,11 +73,11 @@ function Login({ location }: Props) {
env.DEPLOYMENT === "hosted" &&
(config.hostname ? (
<Back href={env.URL}>
<BackIcon color="currentColor" /> {t("Back to home")}
<BackIcon color="currentColor" /> Back to home
</Back>
) : (
<Back href="https://www.getoutline.com">
<BackIcon color="currentColor" /> {t("Back to website")}
<BackIcon color="currentColor" /> Back to website
</Back>
));
@@ -98,17 +88,15 @@ function Login({ location }: Props) {
<Centered align="center" justify="center" column auto>
<PageTitle title="Check your email" />
<CheckEmailIcon size={38} color="currentColor" />
<Heading centered>{t("Check your email")}</Heading>
<Heading centered>Check your email</Heading>
<Note>
<Trans
defaults="A magic sign-in link has been sent to the email <em>{{ emailLinkSentTo }}</em>, no password needed."
values={{ emailLinkSentTo: emailLinkSentTo }}
components={{ em: <em /> }}
/>
A magic sign-in link has been sent to the email{" "}
<em>{emailLinkSentTo}</em>, no password needed.
</Note>
<br />
<ButtonLarge onClick={handleReset} fullwidth neutral>
{t("Back to login")}
Back to login
</ButtonLarge>
</Centered>
</Background>
@@ -130,19 +118,13 @@ function Login({ location }: Props) {
{isCreate ? (
<>
<Heading centered>{t("Create an account")}</Heading>
<Heading centered>Create an account</Heading>
<GetStarted>
{t(
"Get started by choosing a sign-in method for your new team below…"
)}
Get started by choosing a sign-in method for your new team below
</GetStarted>
</>
) : (
<Heading centered>
{t("Login to {{ authProviderName }}", {
authProviderName: config.name || "Outline",
})}
</Heading>
<Heading centered>Login to {config.name || "Outline"}</Heading>
)}
<Notices />
@@ -157,9 +139,7 @@ function Login({ location }: Props) {
{hasMultipleProviders && (
<>
<Note>
{t("You signed in with {{ authProviderName }} last time.", {
authProviderName: defaultProvider.name,
})}
You signed in with {defaultProvider.name} last time.
</Note>
<Or />
</>
@@ -184,9 +164,7 @@ function Login({ location }: Props) {
{isCreate && (
<Note>
<Trans>
Already have an account? Go to <Link to="/">login</Link>.
</Trans>
Already have an account? Go to <Link to="/">login</Link>.
</Note>
)}
</Centered>
+4 -7
View File
@@ -140,13 +140,10 @@ class Search extends React.Component<Props> {
}) => {
this.props.history.replace({
pathname: this.props.location.pathname,
search: queryString.stringify(
{
...queryString.parse(this.props.location.search),
...search,
},
{ skipEmptyString: true }
),
search: queryString.stringify({
...queryString.parse(this.props.location.search),
...search,
}),
});
};
+124 -116
View File
@@ -1,10 +1,11 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { TeamIcon } from "outline-icons";
import * as React from "react";
import { useRef, useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
import Button from "components/Button";
import Flex from "components/Flex";
import Heading from "components/Heading";
@@ -13,130 +14,137 @@ import Input, { LabelText } from "components/Input";
import Scene from "components/Scene";
import ImageUpload from "./components/ImageUpload";
import env from "env";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
function Details() {
const { auth } = useStores();
const { showToast } = useToasts();
const team = useCurrentTeam();
const { t } = useTranslation();
const form = useRef<?HTMLFormElement>();
const [name, setName] = useState(team.name);
const [subdomain, setSubdomain] = useState(team.subdomain);
const [avatarUrl, setAvatarUrl] = useState();
type Props = {
auth: AuthStore,
ui: UiStore,
};
const handleSubmit = React.useCallback(
async (event: ?SyntheticEvent<>) => {
if (event) {
event.preventDefault();
}
@observer
class Details extends React.Component<Props> {
timeout: TimeoutID;
form: ?HTMLFormElement;
try {
await auth.updateTeam({
name,
avatarUrl,
subdomain,
});
showToast(t("Settings saved"), { type: "success" });
} catch (err) {
showToast(err.message, { type: "error" });
}
},
[auth, showToast, name, avatarUrl, subdomain, t]
);
@observable name: string;
@observable subdomain: ?string;
@observable avatarUrl: ?string;
const handleNameChange = React.useCallback((ev: SyntheticInputEvent<*>) => {
setName(ev.target.value);
}, []);
componentDidMount() {
const { team } = this.props.auth;
if (team) {
this.name = team.name;
this.subdomain = team.subdomain;
}
}
const handleSubdomainChange = React.useCallback(
(ev: SyntheticInputEvent<*>) => {
setSubdomain(ev.target.value.toLowerCase());
},
[]
);
componentWillUnmount() {
clearTimeout(this.timeout);
}
const handleAvatarUpload = React.useCallback(
(avatarUrl: string) => {
setAvatarUrl(avatarUrl);
handleSubmit();
},
[handleSubmit]
);
handleSubmit = async (event: ?SyntheticEvent<>) => {
if (event) {
event.preventDefault();
}
const handleAvatarError = React.useCallback(
(error: ?string) => {
showToast(error || t("Unable to upload new logo"));
},
[showToast, t]
);
try {
await this.props.auth.updateTeam({
name: this.name,
avatarUrl: this.avatarUrl,
subdomain: this.subdomain,
});
this.props.ui.showToast("Settings saved", { type: "success" });
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
}
};
const isValid = form.current && form.current.checkValidity();
handleNameChange = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
};
return (
<Scene title={t("Details")} icon={<TeamIcon color="currentColor" />}>
<Heading>{t("Details")}</Heading>
<HelpText>
<Trans>
handleSubdomainChange = (ev: SyntheticInputEvent<*>) => {
this.subdomain = ev.target.value.toLowerCase();
};
handleAvatarUpload = (avatarUrl: string) => {
this.avatarUrl = avatarUrl;
this.handleSubmit();
};
handleAvatarError = (error: ?string) => {
this.props.ui.showToast(error || "Unable to upload new logo");
};
get isValid() {
return this.form && this.form.checkValidity();
}
render() {
const { team, isSaving } = this.props.auth;
if (!team) return null;
const avatarUrl = this.avatarUrl || team.avatarUrl;
return (
<Scene title="Details" icon={<TeamIcon color="currentColor" />}>
<Heading>Details</Heading>
<HelpText>
These details affect the way that your Outline appears to everyone on
the team.
</Trans>
</HelpText>
</HelpText>
<ProfilePicture column>
<LabelText>{t("Logo")}</LabelText>
<AvatarContainer>
<ImageUpload
onSuccess={handleAvatarUpload}
onError={handleAvatarError}
submitText={t("Crop logo")}
borderRadius={0}
>
<Avatar src={avatarUrl} />
<Flex auto align="center" justify="center">
<Trans>Upload</Trans>
</Flex>
</ImageUpload>
</AvatarContainer>
</ProfilePicture>
<form onSubmit={handleSubmit} ref={form}>
<Input
label={t("Name")}
name="name"
autoComplete="organization"
value={name}
onChange={handleNameChange}
required
short
/>
{env.SUBDOMAINS_ENABLED && (
<>
<Input
label={t("Subdomain")}
name="subdomain"
value={subdomain || ""}
onChange={handleSubdomainChange}
autoComplete="off"
minLength={4}
maxLength={32}
short
/>
{subdomain && (
<HelpText small>
<Trans>Your knowledge base will be accessible at</Trans>{" "}
<strong>{subdomain}.getoutline.com</strong>
</HelpText>
)}
</>
)}
<Button type="submit" disabled={auth.isSaving || !isValid}>
{auth.isSaving ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
);
<ProfilePicture column>
<LabelText>Logo</LabelText>
<AvatarContainer>
<ImageUpload
onSuccess={this.handleAvatarUpload}
onError={this.handleAvatarError}
submitText="Crop logo"
borderRadius={0}
>
<Avatar src={avatarUrl} />
<Flex auto align="center" justify="center">
Upload
</Flex>
</ImageUpload>
</AvatarContainer>
</ProfilePicture>
<form onSubmit={this.handleSubmit} ref={(ref) => (this.form = ref)}>
<Input
label="Name"
name="name"
autoComplete="organization"
value={this.name}
onChange={this.handleNameChange}
required
short
/>
{env.SUBDOMAINS_ENABLED && (
<>
<Input
label="Subdomain"
name="subdomain"
value={this.subdomain || ""}
onChange={this.handleSubdomainChange}
autoComplete="off"
minLength={4}
maxLength={32}
short
/>
{this.subdomain && (
<HelpText small>
Your knowledge base will be accessible at{" "}
<strong>{this.subdomain}.getoutline.com</strong>
</HelpText>
)}
</>
)}
<Button type="submit" disabled={isSaving || !this.isValid}>
{isSaving ? "Saving" : "Save"}
</Button>
</form>
</Scene>
);
}
}
const ProfilePicture = styled(Flex)`
@@ -178,4 +186,4 @@ const Avatar = styled.img`
${avatarStyles};
`;
export default observer(Details);
export default inject("auth", "ui")(Details);

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