fix: Keyboard accessible context menus (#1768)

- Makes menus fully accessible and keyboard driven
- Currently adds 2.8% to initial bundle size due to the inclusion of Reakit and its dependency, popperjs.
- Converts all menus to functional components
- Remove old custom menu system
- Various layout and flow improvements around the menus

closes #1766
This commit is contained in:
Tom Moor 2021-01-13 22:00:25 -08:00 committed by GitHub
parent 47369dd968
commit e8b7782f5e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1788 additions and 1881 deletions

View File

@ -32,6 +32,7 @@ module.file_ext=.json
esproposal.decorators=ignore esproposal.decorators=ignore
esproposal.class_static_fields=enable esproposal.class_static_fields=enable
esproposal.class_instance_fields=enable esproposal.class_instance_fields=enable
esproposal.optional_chaining=enable
suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe
suppress_comment=\\(.\\|\n\\)*\\$FlowIssue suppress_comment=\\(.\\|\n\\)*\\$FlowIssue

View File

@ -11,11 +11,6 @@ export const Action = styled(Flex)`
font-size: 15px; font-size: 15px;
flex-shrink: 0; flex-shrink: 0;
a {
color: ${(props) => props.theme.text};
height: 24px;
}
&:empty { &:empty {
display: none; display: none;
} }

View File

@ -4,7 +4,6 @@ import {
ArchiveIcon, ArchiveIcon,
EditIcon, EditIcon,
GoToIcon, GoToIcon,
MoreIcon,
PadlockIcon, PadlockIcon,
ShapesIcon, ShapesIcon,
TrashIcon, TrashIcon,
@ -14,18 +13,15 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import styled from "styled-components"; import styled from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import CollectionsStore from "stores/CollectionsStore";
import Document from "models/Document"; import Document from "models/Document";
import CollectionIcon from "components/CollectionIcon"; import CollectionIcon from "components/CollectionIcon";
import Flex from "components/Flex"; import Flex from "components/Flex";
import BreadcrumbMenu from "./BreadcrumbMenu";
import useStores from "hooks/useStores"; import useStores from "hooks/useStores";
import BreadcrumbMenu from "menus/BreadcrumbMenu";
import { collectionUrl } from "utils/routeHelpers"; import { collectionUrl } from "utils/routeHelpers";
type Props = { type Props = {
document: Document, document: Document,
collections: CollectionsStore,
onlyText: boolean, onlyText: boolean,
}; };
@ -133,7 +129,7 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
</CollectionName> </CollectionName>
{isNestedDocument && ( {isNestedDocument && (
<> <>
<Slash /> <BreadcrumbMenu label={<Overflow />} path={menuPath} /> <Slash /> <BreadcrumbMenu path={menuPath} />
</> </>
)} )}
{lastPath && ( {lastPath && (
@ -148,6 +144,11 @@ const Breadcrumb = ({ document, onlyText }: Props) => {
); );
}; };
export const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${(props) => props.theme.divider};
`;
const Wrapper = styled(Flex)` const Wrapper = styled(Flex)`
display: none; display: none;
@ -168,22 +169,6 @@ const SmallSlash = styled(GoToIcon)`
opacity: 0.25; opacity: 0.25;
`; `;
export const Slash = styled(GoToIcon)`
flex-shrink: 0;
fill: ${(props) => props.theme.divider};
`;
const Overflow = styled(MoreIcon)`
flex-shrink: 0;
transition: opacity 100ms ease-in-out;
fill: ${(props) => props.theme.divider};
&:active,
&:hover {
fill: ${(props) => props.theme.text};
}
`;
const Crumb = styled(Link)` const Crumb = styled(Link)`
color: ${(props) => props.theme.text}; color: ${(props) => props.theme.text};
font-size: 15px; font-size: 15px;
@ -199,12 +184,17 @@ const Crumb = styled(Link)`
const CollectionName = styled(Link)` const CollectionName = styled(Link)`
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 1;
color: ${(props) => props.theme.text}; color: ${(props) => props.theme.text};
font-size: 15px; font-size: 15px;
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
min-width: 0;
svg {
flex-shrink: 0;
}
`; `;
export default observer(Breadcrumb); export default observer(Breadcrumb);

View File

@ -1,22 +0,0 @@
// @flow
import * as React from "react";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
type Props = {
label: React.Node,
path: Array<any>,
};
export default function BreadcrumbMenu({ label, path }: Props) {
return (
<DropdownMenu label={label} position="center">
<DropdownMenuItems
items={path.map((item) => ({
title: item.title,
to: item.url,
}))}
/>
</DropdownMenu>
);
}

View File

@ -22,9 +22,13 @@ const RealButton = styled.button`
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
svg { ${(props) =>
fill: ${(props) => props.iconColor || props.theme.buttonText}; !props.borderOnHover &&
} `
svg {
fill: ${props.iconColor || props.theme.buttonText};
}
`}
&::-moz-focus-inner { &::-moz-focus-inner {
padding: 0; padding: 0;
@ -42,7 +46,7 @@ const RealButton = styled.button`
} }
${(props) => ${(props) =>
props.neutral && props.$neutral &&
` `
background: ${props.theme.buttonNeutralBackground}; background: ${props.theme.buttonNeutralBackground};
color: ${props.theme.buttonNeutralText}; color: ${props.theme.buttonNeutralText};
@ -52,9 +56,14 @@ const RealButton = styled.button`
: `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset` : `rgba(0, 0, 0, 0.07) 0px 1px 2px, ${props.theme.buttonNeutralBorder} 0 0 0 1px inset`
}; };
svg { ${
props.borderOnHover
? ""
: `svg {
fill: ${props.iconColor || props.theme.buttonNeutralText}; fill: ${props.iconColor || props.theme.buttonNeutralText};
}`
} }
&:hover { &:hover {
background: ${darken(0.05, props.theme.buttonNeutralBackground)}; background: ${darken(0.05, props.theme.buttonNeutralBackground)};
@ -72,9 +81,9 @@ const RealButton = styled.button`
background: ${props.theme.danger}; background: ${props.theme.danger};
color: ${props.theme.white}; color: ${props.theme.white};
&:hover { &:hover {
background: ${darken(0.05, props.theme.danger)}; background: ${darken(0.05, props.theme.danger)};
} }
`}; `};
`; `;
@ -108,6 +117,7 @@ export type Props = {
children?: React.Node, children?: React.Node,
innerRef?: React.ElementRef<any>, innerRef?: React.ElementRef<any>,
disclosure?: boolean, disclosure?: boolean,
neutral?: boolean,
fullwidth?: boolean, fullwidth?: boolean,
borderOnHover?: boolean, borderOnHover?: boolean,
}; };
@ -119,13 +129,14 @@ function Button({
value, value,
disclosure, disclosure,
innerRef, innerRef,
neutral,
...rest ...rest
}: Props) { }: Props) {
const hasText = children !== undefined || value !== undefined; const hasText = children !== undefined || value !== undefined;
const hasIcon = icon !== undefined; const hasIcon = icon !== undefined;
return ( return (
<RealButton type={type} ref={innerRef} {...rest}> <RealButton type={type} ref={innerRef} $neutral={neutral} {...rest}>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}> <Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
{hasIcon && icon} {hasIcon && icon}
{hasText && <Label hasIcon={hasIcon}>{children || value}</Label>} {hasText && <Label hasIcon={hasIcon}>{children || value}</Label>}

View File

@ -0,0 +1,13 @@
// @flow
import styled from "styled-components";
const Header = styled.h3`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: ${(props) => props.theme.sidebarText};
letter-spacing: 0.04em;
margin: 1em 12px 0.5em;
`;
export default Header;

View File

@ -1,6 +1,7 @@
// @flow // @flow
import { CheckmarkIcon } from "outline-icons"; import { CheckmarkIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled from "styled-components"; import styled from "styled-components";
type Props = { type Props = {
@ -8,31 +9,35 @@ type Props = {
children?: React.Node, children?: React.Node,
selected?: boolean, selected?: boolean,
disabled?: boolean, disabled?: boolean,
as?: string | React.ComponentType<*>,
}; };
const DropdownMenuItem = ({ const MenuItem = ({
onClick, onClick,
children, children,
selected, selected,
disabled, disabled,
as,
...rest ...rest
}: Props) => { }: Props) => {
return ( return (
<MenuItem <BaseMenuItem
onClick={disabled ? undefined : onClick} onClick={disabled ? undefined : onClick}
disabled={disabled} disabled={disabled}
role="menuitem"
tabIndex="-1"
{...rest} {...rest}
> >
{selected !== undefined && ( {(props) => (
<> <MenuAnchor as={onClick ? "button" : as} {...props}>
{selected ? <CheckmarkIcon /> : <Spacer />} {selected !== undefined && (
&nbsp; <>
</> {selected ? <CheckmarkIcon /> : <Spacer />}
&nbsp;
</>
)}
{children}
</MenuAnchor>
)} )}
{children} </BaseMenuItem>
</MenuItem>
); );
}; };
@ -41,13 +46,14 @@ const Spacer = styled.div`
height: 24px; height: 24px;
`; `;
const MenuItem = styled.a` export const MenuAnchor = styled.a`
display: flex; display: flex;
margin: 0; margin: 0;
border: 0;
padding: 6px 12px; padding: 6px 12px;
width: 100%; width: 100%;
min-height: 32px; min-height: 32px;
background: none;
color: ${(props) => color: ${(props) =>
props.disabled ? props.theme.textTertiary : props.theme.textSecondary}; props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
justify-content: left; justify-content: left;
@ -61,6 +67,7 @@ const MenuItem = styled.a`
} }
svg { svg {
flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)}; opacity: ${(props) => (props.disabled ? ".5" : 1)};
} }
@ -69,7 +76,8 @@ const MenuItem = styled.a`
? "pointer-events: none;" ? "pointer-events: none;"
: ` : `
&:hover { &:hover,
&.focus-visible {
color: ${props.theme.white}; color: ${props.theme.white};
background: ${props.theme.primary}; background: ${props.theme.primary};
box-shadow: none; box-shadow: none;
@ -87,4 +95,4 @@ const MenuItem = styled.a`
`}; `};
`; `;
export default DropdownMenuItem; export default MenuItem;

View File

@ -0,0 +1,21 @@
// @flow
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { MenuButton } from "reakit/Menu";
import NudeButton from "components/NudeButton";
export default function OverflowMenuButton({
iconColor,
className,
...rest
}: any) {
return (
<MenuButton {...rest}>
{(props) => (
<NudeButton className={className} {...props}>
<MoreIcon color={iconColor} />
</NudeButton>
)}
</MenuButton>
);
}

View File

@ -0,0 +1,16 @@
// @flow
import * as React from "react";
import { MenuSeparator } from "reakit/Menu";
import styled from "styled-components";
export default function Separator(rest: {}) {
return (
<MenuSeparator {...rest}>
{(props) => <HorizontalRule {...props} />}
</MenuSeparator>
);
}
const HorizontalRule = styled.hr`
margin: 0.5em 12px;
`;

View File

@ -1,13 +1,19 @@
// @flow // @flow
import { ExpandedIcon } from "outline-icons"; import { ExpandedIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import {
useMenuState,
MenuButton,
MenuItem as BaseMenuItem,
} from "reakit/Menu";
import styled from "styled-components"; import styled from "styled-components";
import Flex from "components/Flex"; import MenuItem, { MenuAnchor } from "./MenuItem";
import DropdownMenu from "./DropdownMenu"; import Separator from "./Separator";
import DropdownMenuItem from "./DropdownMenuItem"; import ContextMenu from ".";
type MenuItem = type TMenuItem =
| {| | {|
title: React.Node, title: React.Node,
to: string, to: string,
@ -35,7 +41,7 @@ type MenuItem =
disabled?: boolean, disabled?: boolean,
style?: Object, style?: Object,
hover?: boolean, hover?: boolean,
items: MenuItem[], items: TMenuItem[],
|} |}
| {| | {|
type: "separator", type: "separator",
@ -48,14 +54,35 @@ type MenuItem =
|}; |};
type Props = {| type Props = {|
items: MenuItem[], items: TMenuItem[],
|}; |};
const Disclosure = styled(ExpandedIcon)` const Disclosure = styled(ExpandedIcon)`
transform: rotate(270deg); transform: rotate(270deg);
justify-self: flex-end;
`; `;
export default function DropdownMenuItems({ items }: Props): React.Node { const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
const { t } = useTranslation();
const menu = useMenuState({ modal: true });
return (
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor {...props}>
{title} <Disclosure color="currentColor" />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Submenu")}>
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
);
});
function Template({ items, ...menu }: Props): React.Node {
let filtered = items.filter((item) => item.visible !== false); let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators // this block literally just trims unneccessary separators
@ -76,69 +103,67 @@ export default function DropdownMenuItems({ items }: Props): React.Node {
return filtered.map((item, index) => { return filtered.map((item, index) => {
if (item.to) { if (item.to) {
return ( return (
<DropdownMenuItem <MenuItem
as={Link} as={Link}
to={item.to} to={item.to}
key={index} key={index}
disabled={item.disabled} disabled={item.disabled}
selected={item.selected} selected={item.selected}
{...menu}
> >
{item.title} {item.title}
</DropdownMenuItem> </MenuItem>
); );
} }
if (item.href) { if (item.href) {
return ( return (
<DropdownMenuItem <MenuItem
href={item.href} href={item.href}
key={index} key={index}
disabled={item.disabled} disabled={item.disabled}
selected={item.selected} selected={item.selected}
target="_blank" target="_blank"
{...menu}
> >
{item.title} {item.title}
</DropdownMenuItem> </MenuItem>
); );
} }
if (item.onClick) { if (item.onClick) {
return ( return (
<DropdownMenuItem <MenuItem
as="button"
onClick={item.onClick} onClick={item.onClick}
disabled={item.disabled} disabled={item.disabled}
selected={item.selected} selected={item.selected}
key={index} key={index}
{...menu}
> >
{item.title} {item.title}
</DropdownMenuItem> </MenuItem>
); );
} }
if (item.items) { if (item.items) {
return ( return (
<DropdownMenu <BaseMenuItem
style={item.style}
label={
<DropdownMenuItem disabled={item.disabled}>
<Flex justify="space-between" align="center" auto>
{item.title}
<Disclosure color="currentColor" />
</Flex>
</DropdownMenuItem>
}
hover={item.hover}
key={index} key={index}
> as={Submenu}
<DropdownMenuItems items={item.items} /> templateItems={item.items}
</DropdownMenu> title={item.title}
{...menu}
/>
); );
} }
if (item.type === "separator") { if (item.type === "separator") {
return <hr key={index} />; return <Separator key={index} />;
} }
return null; return null;
}); });
} }
export default React.memo<Props>(Template);

View File

@ -0,0 +1,77 @@
// @flow
import { rgba } from "polished";
import * as React from "react";
import { Menu } from "reakit/Menu";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
import usePrevious from "hooks/usePrevious";
type Props = {|
"aria-label": string,
visible?: boolean,
animating?: boolean,
children: React.Node,
onOpen?: () => void,
onClose?: () => void,
|};
export default function ContextMenu({
children,
onOpen,
onClose,
...rest
}: Props) {
const previousVisible = usePrevious(rest.visible);
React.useEffect(() => {
if (rest.visible && !previousVisible) {
if (onOpen) {
onOpen();
}
}
if (!rest.visible && previousVisible) {
if (onClose) {
onClose();
}
}
}, [onOpen, onClose, previousVisible, rest.visible]);
return (
<Menu {...rest}>
{(props) => (
<Position {...props}>
<Background>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
)}
</Menu>
);
}
const Position = styled.div`
position: absolute;
z-index: ${(props) => props.theme.depths.menu};
`;
const Background = styled.div`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
background: ${(props) => rgba(props.theme.menuBackground, 0.95)};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
border-radius: 2px;
padding: 0.5em 0;
min-width: 180px;
overflow: hidden;
overflow-y: auto;
max-height: 75vh;
max-width: 276px;
box-shadow: ${(props) => props.theme.menuShadow};
pointer-events: all;
font-weight: normal;
@media print {
display: none;
}
`;

View File

@ -1,6 +1,5 @@
// @flow // @flow
import format from "date-fns/format"; import format from "date-fns/format";
import { MoreIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components"; import styled, { withTheme } from "styled-components";
@ -45,9 +44,7 @@ class RevisionListItem extends React.Component<Props> {
<StyledRevisionMenu <StyledRevisionMenu
document={document} document={document}
revision={revision} revision={revision}
label={ iconColor={selected ? theme.white : theme.textTertiary}
<MoreIcon color={selected ? theme.white : theme.textTertiary} />
}
/> />
)} )}
</StyledNavLink> </StyledNavLink>

View File

@ -3,8 +3,9 @@ import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons"; import { PlusIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link, useHistory } from "react-router-dom"; import { Link } from "react-router-dom";
import styled, { css } from "styled-components"; import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "models/Document"; import Document from "models/Document";
import Badge from "components/Badge"; import Badge from "components/Badge";
import Button from "components/Button"; import Button from "components/Button";
@ -18,7 +19,7 @@ import useCurrentUser from "hooks/useCurrentUser";
import DocumentMenu from "menus/DocumentMenu"; import DocumentMenu from "menus/DocumentMenu";
import { newDocumentUrl } from "utils/routeHelpers"; import { newDocumentUrl } from "utils/routeHelpers";
type Props = { type Props = {|
document: Document, document: Document,
highlight?: ?string, highlight?: ?string,
context?: ?string, context?: ?string,
@ -27,7 +28,7 @@ type Props = {
showPin?: boolean, showPin?: boolean,
showDraft?: boolean, showDraft?: boolean,
showTemplate?: boolean, showTemplate?: boolean,
}; |};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi; const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@ -40,7 +41,6 @@ function replaceResultMarks(tag: string) {
function DocumentListItem(props: Props) { function DocumentListItem(props: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const history = useHistory();
const [menuOpen, setMenuOpen] = React.useState(false); const [menuOpen, setMenuOpen] = React.useState(false);
const { const {
document, document,
@ -53,23 +53,11 @@ function DocumentListItem(props: Props) {
context, context,
} = props; } = props;
const handleNewFromTemplate = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
history.push(
newDocumentUrl(document.collectionId, {
templateId: document.id,
})
);
},
[history, document]
);
const queryIsInTitle = const queryIsInTitle =
!!highlight && !!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase()); !!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
return ( return (
<DocumentLink <DocumentLink
@ -80,83 +68,102 @@ function DocumentListItem(props: Props) {
state: { title: document.titleWithDefault }, state: { title: document.titleWithDefault },
}} }}
> >
<Heading> <Content>
<Title text={document.titleWithDefault} highlight={highlight} /> <Heading>
{document.isNew && document.createdBy.id !== currentUser.id && ( <Title text={document.titleWithDefault} highlight={highlight} />
<Badge yellow>{t("New")}</Badge> {document.isNew && document.createdBy.id !== currentUser.id && (
<Badge yellow>{t("New")}</Badge>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isDraft && showDraft && (
<Tooltip
tooltip={t("Only visible to you")}
delay={500}
placement="top"
>
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
)} )}
{!document.isDraft && !document.isArchived && !document.isTemplate && ( <DocumentMeta
<Actions> document={document}
<StarButton document={document} /> showCollection={showCollection}
</Actions> showPublished={showPublished}
)} showLastViewed
{document.isDraft && showDraft && ( />
<Tooltip </Content>
tooltip={t("Only visible to you")} <Actions>
delay={500} {document.isTemplate && !document.isArchived && !document.isDeleted && (
placement="top" <>
> <Button
<Badge>{t("Draft")}</Badge> as={Link}
</Tooltip> to={newDocumentUrl(document.collectionId, {
)} templateId: document.id,
{document.isTemplate && showTemplate && ( })}
<Badge primary>{t("Template")}</Badge> icon={<PlusIcon />}
)} neutral
<SecondaryActions> >
{document.isTemplate && !document.isArchived && !document.isDeleted && (
<Button onClick={handleNewFromTemplate} icon={<PlusIcon />} neutral>
{t("New doc")} {t("New doc")}
</Button> </Button>
)} &nbsp;
&nbsp; </>
<EventBoundary> )}
<DocumentMenu <DocumentMenu
document={document} document={document}
showPin={showPin} showPin={showPin}
onOpen={() => setMenuOpen(true)} onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)} onClose={() => setMenuOpen(false)}
/> modal={false}
</EventBoundary>
</SecondaryActions>
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/> />
)} </Actions>
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showLastViewed
/>
</DocumentLink> </DocumentLink>
); );
} }
const SecondaryActions = styled(Flex)` const Content = styled.div`
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
`;
const Actions = styled(EventBoundary)`
display: none;
align-items: center; align-items: center;
position: absolute; margin: 8px;
right: 16px; flex-shrink: 0;
top: 50%; flex-grow: 0;
transform: translateY(-50%);
${breakpoint("tablet")`
display: flex;
`};
`; `;
const DocumentLink = styled(Link)` const DocumentLink = styled(Link)`
display: block; display: flex;
align-items: center;
margin: 10px -8px; margin: 10px -8px;
padding: 6px 8px; padding: 6px 8px;
border-radius: 8px; border-radius: 8px;
max-height: 50vh; max-height: 50vh;
min-width: 100%; min-width: 100%;
max-width: calc(100vw - 40px); max-width: calc(100vw - 40px);
overflow: hidden;
position: relative;
${SecondaryActions} { ${Actions} {
opacity: 0; opacity: 0;
} }
@ -166,10 +173,11 @@ const DocumentLink = styled(Link)`
&:hover, &:hover,
&:active, &:active,
&:focus { &:focus,
&:focus-within {
background: ${(props) => props.theme.listItemHoverBackground}; background: ${(props) => props.theme.listItemHoverBackground};
${SecondaryActions} { ${Actions} {
opacity: 1; opacity: 1;
} }
@ -187,7 +195,7 @@ const DocumentLink = styled(Link)`
css` css`
background: ${(props) => props.theme.listItemHoverBackground}; background: ${(props) => props.theme.listItemHoverBackground};
${SecondaryActions} { ${Actions} {
opacity: 1; opacity: 1;
} }
@ -210,7 +218,7 @@ const Heading = styled.h3`
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`; `;
const Actions = styled(Flex)` const StarPositioner = styled(Flex)`
margin-left: 4px; margin-left: 4px;
align-items: center; align-items: center;
`; `;

View File

@ -15,6 +15,7 @@ const Container = styled(Flex)`
font-size: 13px; font-size: 13px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
min-width: 0;
`; `;
const Modified = styled.span` const Modified = styled.span`

View File

@ -1,289 +0,0 @@
// @flow
import invariant from "invariant";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { MoreIcon } from "outline-icons";
import { rgba } from "polished";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { PortalWithState } from "react-portal";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
import Flex from "components/Flex";
import NudeButton from "components/NudeButton";
let previousClosePortal;
let counter = 0;
type Children =
| React.Node
| ((options: { closePortal: () => void }) => React.Node);
type Props = {|
label?: React.Node,
onOpen?: () => void,
onClose?: () => void,
children?: Children,
className?: string,
hover?: boolean,
style?: Object,
position?: "left" | "right" | "center",
t: TFunction,
|};
@observer
class DropdownMenu extends React.Component<Props> {
id: string = `menu${counter++}`;
closeTimeout: TimeoutID;
@observable top: ?number;
@observable bottom: ?number;
@observable right: ?number;
@observable left: ?number;
@observable position: "left" | "right" | "center";
@observable fixed: ?boolean;
@observable bodyRect: ClientRect;
@observable labelRect: ClientRect;
@observable dropdownRef: { current: null | HTMLElement } = React.createRef();
@observable menuRef: { current: null | HTMLElement } = React.createRef();
handleOpen = (
openPortal: (SyntheticEvent<>) => void,
closePortal: () => void
) => {
return (ev: SyntheticMouseEvent<HTMLElement>) => {
ev.preventDefault();
const currentTarget = ev.currentTarget;
invariant(document.body, "why you not here");
if (currentTarget instanceof HTMLDivElement) {
this.bodyRect = document.body.getBoundingClientRect();
this.labelRect = currentTarget.getBoundingClientRect();
this.top = this.labelRect.bottom - this.bodyRect.top;
this.bottom = undefined;
this.position = this.props.position || "left";
if (currentTarget.parentElement) {
const triggerParentStyle = getComputedStyle(
currentTarget.parentElement
);
if (triggerParentStyle.position === "static") {
this.fixed = true;
this.top = this.labelRect.bottom;
}
}
this.initPosition();
// attempt to keep only one flyout menu open at once
if (previousClosePortal && !this.props.hover) {
previousClosePortal();
}
previousClosePortal = closePortal;
openPortal(ev);
}
};
};
initPosition() {
if (this.position === "left") {
this.right =
this.bodyRect.width - this.labelRect.left - this.labelRect.width;
} else if (this.position === "center") {
this.left = this.labelRect.left + this.labelRect.width / 2;
} else {
this.left = this.labelRect.left;
}
}
onOpen = () => {
if (typeof this.props.onOpen === "function") {
this.props.onOpen();
}
this.fitOnTheScreen();
};
fitOnTheScreen() {
if (!this.dropdownRef || !this.dropdownRef.current) return;
const el = this.dropdownRef.current;
const sticksOutPastBottomEdge =
el.clientHeight + this.top > window.innerHeight;
if (sticksOutPastBottomEdge) {
this.top = undefined;
this.bottom = this.fixed ? 0 : -1 * window.pageYOffset;
} else {
this.bottom = undefined;
}
if (this.position === "left" || this.position === "right") {
const totalWidth =
Math.sign(this.position === "left" ? -1 : 1) * el.offsetLeft +
el.scrollWidth;
const isVisible = totalWidth < window.innerWidth;
if (!isVisible) {
if (this.position === "right") {
this.position = "left";
this.left = undefined;
} else if (this.position === "left") {
this.position = "right";
this.right = undefined;
}
}
}
this.initPosition();
this.forceUpdate();
}
closeAfterTimeout = (closePortal: () => void) => () => {
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
}
this.closeTimeout = setTimeout(closePortal, 500);
};
clearCloseTimeout = () => {
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
}
};
render() {
const { className, hover, label, children, t } = this.props;
return (
<div className={className}>
<PortalWithState
onOpen={this.onOpen}
onClose={this.props.onClose}
closeOnOutsideClick
closeOnEsc
>
{({ closePortal, openPortal, isOpen, portal }) => (
<>
<Label
onMouseMove={hover ? this.clearCloseTimeout : undefined}
onMouseOut={
hover ? this.closeAfterTimeout(closePortal) : undefined
}
onMouseEnter={
hover ? this.handleOpen(openPortal, closePortal) : undefined
}
onClick={
hover ? undefined : this.handleOpen(openPortal, closePortal)
}
>
{label || (
<NudeButton
id={`${this.id}button`}
aria-label={t("More options")}
aria-haspopup="true"
aria-expanded={isOpen ? "true" : "false"}
aria-controls={this.id}
>
<MoreIcon />
</NudeButton>
)}
</Label>
{portal(
<Position
ref={this.dropdownRef}
position={this.position}
fixed={this.fixed}
top={this.top}
bottom={this.bottom}
left={this.left}
right={this.right}
>
<Menu
ref={this.menuRef}
onMouseMove={hover ? this.clearCloseTimeout : undefined}
onMouseOut={
hover ? this.closeAfterTimeout(closePortal) : undefined
}
onClick={
typeof children === "function"
? undefined
: (ev) => {
ev.stopPropagation();
closePortal();
}
}
style={this.props.style}
id={this.id}
aria-labelledby={`${this.id}button`}
role="menu"
>
{typeof children === "function"
? children({ closePortal })
: children}
</Menu>
</Position>
)}
</>
)}
</PortalWithState>
</div>
);
}
}
const Label = styled(Flex).attrs({
justify: "center",
align: "center",
})`
z-index: ${(props) => props.theme.depths.menu};
cursor: pointer;
`;
const Position = styled.div`
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
display: flex;
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
${({ right }) => (right !== undefined ? `right: ${right}px` : "")};
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
${({ bottom }) => (bottom !== undefined ? `bottom: ${bottom}px` : "")};
max-height: 75%;
z-index: ${(props) => props.theme.depths.menu};
transform: ${(props) =>
props.position === "center" ? "translateX(-50%)" : "initial"};
pointer-events: none;
`;
const Menu = styled.div`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: ${(props) => (props.left !== undefined ? "25%" : "75%")} 0;
backdrop-filter: blur(10px);
background: ${(props) => rgba(props.theme.menuBackground, 0.8)};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
border-radius: 2px;
padding: 0.5em 0;
min-width: 180px;
overflow: hidden;
overflow-y: auto;
box-shadow: ${(props) => props.theme.menuShadow};
pointer-events: all;
hr {
margin: 0.5em 12px;
}
@media print {
display: none;
}
`;
export const Header = styled.h3`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: ${(props) => props.theme.sidebarText};
letter-spacing: 0.04em;
margin: 1em 12px 0.5em;
`;
export default withTranslation()<DropdownMenu>(DropdownMenu);

View File

@ -1,3 +0,0 @@
// @flow
export { default as DropdownMenu, Header } from "./DropdownMenu";
export { default as DropdownMenuItem } from "./DropdownMenuItem";

View File

@ -4,13 +4,18 @@ import * as React from "react";
type Props = { type Props = {
children: React.Node, children: React.Node,
className?: string,
}; };
export default function EventBoundary({ children }: Props) { export default function EventBoundary({ children, className }: Props) {
const handleClick = React.useCallback((event: SyntheticEvent<>) => { const handleClick = React.useCallback((event: SyntheticEvent<>) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
}, []); }, []);
return <span onClick={handleClick}>{children}</span>; return (
<span onClick={handleClick} className={className}>
{children}
</span>
);
} }

View File

@ -1,6 +1,4 @@
// @flow // @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import { import {
CollectionIcon, CollectionIcon,
CoinsIcon, CoinsIcon,
@ -22,14 +20,17 @@ import {
VehicleIcon, VehicleIcon,
} from "outline-icons"; } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
import styled from "styled-components"; import styled from "styled-components";
import { DropdownMenu } from "components/DropdownMenu"; import ContextMenu from "components/ContextMenu";
import Flex from "components/Flex"; import Flex from "components/Flex";
import HelpText from "components/HelpText"; import HelpText from "components/HelpText";
import { LabelText } from "components/Input"; import { LabelText } from "components/Input";
import NudeButton from "components/NudeButton"; import NudeButton from "components/NudeButton";
const style = { width: 30, height: 30 };
const TwitterPicker = React.lazy(() => const TwitterPicker = React.lazy(() =>
import("react-color/lib/components/twitter/Twitter") import("react-color/lib/components/twitter/Twitter")
); );
@ -122,107 +123,77 @@ const colors = [
"#2F362F", "#2F362F",
]; ];
type Props = { type Props = {|
onOpen?: () => void, onOpen?: () => void,
onChange: (color: string, icon: string) => void, onChange: (color: string, icon: string) => void,
icon: string, icon: string,
color: string, color: string,
t: TFunction, |};
};
function preventEventBubble(event) { function IconPicker({ onOpen, icon, color, onChange }: Props) {
event.stopPropagation(); const { t } = useTranslation();
const menu = useMenuState({
modal: true,
placement: "bottom-end",
});
const Component = icons[icon || "collection"].component;
return (
<Wrapper>
<Label>
<LabelText>{t("Icon")}</LabelText>
</Label>
<MenuButton {...menu}>
{(props) => (
<Button {...props}>
<Component role="button" color={color} size={30} />
</Button>
)}
</MenuButton>
<ContextMenu {...menu} onOpen={onOpen} aria-label={t("Choose icon")}>
<Icons>
{Object.keys(icons).map((name) => {
const Component = icons[name].component;
return (
<MenuItem
key={name}
onClick={() => onChange(color, name)}
{...menu}
>
{(props) => (
<IconButton style={style} {...props}>
<Component color={color} size={30} />
</IconButton>
)}
</MenuItem>
);
})}
</Icons>
<Flex>
<React.Suspense fallback={<Loading>{t("Loading")}</Loading>}>
<ColorPicker
color={color}
onChange={(color) => onChange(color.hex, icon)}
colors={colors}
triangle="hide"
/>
</React.Suspense>
</Flex>
</ContextMenu>
</Wrapper>
);
} }
@observer const Label = styled.label`
class IconPicker extends React.Component<Props> { display: block;
@observable isOpen: boolean = false; `;
node: ?HTMLElement;
componentDidMount() {
window.addEventListener("click", this.handleClickOutside);
}
componentWillUnmount() {
window.removeEventListener("click", this.handleClickOutside);
}
handleClose = () => {
this.isOpen = false;
};
handleOpen = () => {
this.isOpen = true;
if (this.props.onOpen) {
this.props.onOpen();
}
};
handleClickOutside = (ev: SyntheticMouseEvent<>) => {
// $FlowFixMe
if (ev.target && this.node && this.node.contains(ev.target)) {
return;
}
this.handleClose();
};
render() {
const { t } = this.props;
const Component = icons[this.props.icon || "collection"].component;
return (
<Wrapper ref={(ref) => (this.node = ref)}>
<label>
<LabelText>{t("Icon")}</LabelText>
</label>
<DropdownMenu
onOpen={this.handleOpen}
label={
<LabelButton>
<Component role="button" color={this.props.color} size={30} />
</LabelButton>
}
>
<Icons onClick={preventEventBubble}>
{Object.keys(icons).map((name) => {
const Component = icons[name].component;
return (
<IconButton
key={name}
onClick={() => this.props.onChange(this.props.color, name)}
style={{ width: 30, height: 30 }}
>
<Component color={this.props.color} size={30} />
</IconButton>
);
})}
</Icons>
<Flex onClick={preventEventBubble}>
<React.Suspense fallback={<Loading>{t("Loading")}</Loading>}>
<ColorPicker
color={this.props.color}
onChange={(color) =>
this.props.onChange(color.hex, this.props.icon)
}
colors={colors}
triangle="hide"
/>
</React.Suspense>
</Flex>
</DropdownMenu>
</Wrapper>
);
}
}
const Icons = styled.div` const Icons = styled.div`
padding: 15px 9px 9px 15px; padding: 15px 9px 9px 15px;
width: 276px; width: 276px;
`; `;
const LabelButton = styled(NudeButton)` const Button = styled(NudeButton)`
border: 1px solid ${(props) => props.theme.inputBorder}; border: 1px solid ${(props) => props.theme.inputBorder};
width: 32px; width: 32px;
height: 32px; height: 32px;
@ -249,4 +220,4 @@ const Wrapper = styled("div")`
position: relative; position: relative;
`; `;
export default withTranslation()<IconPicker>(IconPicker); export default IconPicker;

View File

@ -75,16 +75,17 @@ class MainSidebar extends React.Component<Props> {
return ( return (
<Sidebar> <Sidebar>
<AccountMenu <AccountMenu>
label={ {(props) => (
<HeaderBlock <HeaderBlock
{...props}
subheading={user.name} subheading={user.name}
teamName={team.name} teamName={team.name}
logoUrl={team.avatarUrl} logoUrl={team.avatarUrl}
showDisclosure showDisclosure
/> />
} )}
/> </AccountMenu>
<Flex auto column> <Flex auto column>
<Scrollable shadow> <Scrollable shadow>
<Section> <Section>

View File

@ -100,14 +100,12 @@ function CollectionLink({
<> <>
{can.update && ( {can.update && (
<CollectionSortMenuWithMargin <CollectionSortMenuWithMargin
position="right"
collection={collection} collection={collection}
onOpen={() => setMenuOpen(true)} onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)} onClose={() => setMenuOpen(false)}
/> />
)} )}
<CollectionMenu <CollectionMenu
position="right"
collection={collection} collection={collection}
onOpen={() => setMenuOpen(true)} onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)} onClose={() => setMenuOpen(false)}

View File

@ -242,7 +242,6 @@ function DocumentLink({
document && !isMoving ? ( document && !isMoving ? (
<Fade> <Fade>
<DocumentMenu <DocumentMenu
position="right"
document={document} document={document}
onOpen={() => setMenuOpen(true)} onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)} onClose={() => setMenuOpen(false)}

View File

@ -12,26 +12,22 @@ type Props = {
logoUrl: string, logoUrl: string,
}; };
function HeaderBlock({ const HeaderBlock = React.forwardRef<Props, any>(
showDisclosure, ({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => {
teamName, return (
subheading, <Header justify="flex-start" align="center" ref={ref} {...rest}>
logoUrl, <TeamLogo alt={`${teamName} logo`} src={logoUrl} size="38px" />
...rest <Flex align="flex-start" column>
}: Props) { <TeamName showDisclosure>
return ( {teamName}{" "}
<Header justify="flex-start" align="center" {...rest}> {showDisclosure && <StyledExpandedIcon color="currentColor" />}
<TeamLogo alt={`${teamName} logo`} src={logoUrl} size="38px" /> </TeamName>
<Flex align="flex-start" column> <Subheading>{subheading}</Subheading>
<TeamName showDisclosure> </Flex>
{teamName}{" "} </Header>
{showDisclosure && <StyledExpandedIcon color="currentColor" />} );
</TeamName> }
<Subheading>{subheading}</Subheading> );
</Flex>
</Header>
);
}
const StyledExpandedIcon = styled(ExpandedIcon)` const StyledExpandedIcon = styled(ExpandedIcon)`
position: absolute; position: absolute;

View File

@ -1,7 +1,13 @@
// @flow // @flow
import * as React from "react"; import * as React from "react";
import { withRouter, NavLink } from "react-router-dom"; import {
withRouter,
NavLink,
type RouterHistory,
type Match,
} from "react-router-dom";
import styled, { withTheme } from "styled-components"; import styled, { withTheme } from "styled-components";
import EventBoundary from "components/EventBoundary";
import { type Theme } from "types"; import { type Theme } from "types";
type Props = { type Props = {
@ -10,6 +16,7 @@ type Props = {
innerRef?: (?HTMLElement) => void, innerRef?: (?HTMLElement) => void,
onClick?: (SyntheticEvent<>) => void, onClick?: (SyntheticEvent<>) => void,
onMouseEnter?: (SyntheticEvent<>) => void, onMouseEnter?: (SyntheticEvent<>) => void,
className?: string,
children?: React.Node, children?: React.Node,
icon?: React.Node, icon?: React.Node,
label?: React.Node, label?: React.Node,
@ -18,6 +25,8 @@ type Props = {
iconColor?: string, iconColor?: string,
active?: boolean, active?: boolean,
isActiveDrop?: boolean, isActiveDrop?: boolean,
history: RouterHistory,
match: Match,
theme: Theme, theme: Theme,
exact?: boolean, exact?: boolean,
depth?: number, depth?: number,
@ -39,7 +48,9 @@ function SidebarLink({
href, href,
innerRef, innerRef,
depth, depth,
...rest history,
match,
className,
}: Props) { }: Props) {
const style = React.useMemo(() => { const style = React.useMemo(() => {
return { return {
@ -70,7 +81,7 @@ function SidebarLink({
as={to ? undefined : href ? "a" : "div"} as={to ? undefined : href ? "a" : "div"}
href={href} href={href}
ref={innerRef} ref={innerRef}
{...rest} className={className}
> >
{icon && <IconWrapper>{icon}</IconWrapper>} {icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label> <Label>{label}</Label>
@ -84,9 +95,10 @@ const IconWrapper = styled.span`
margin-left: -4px; margin-left: -4px;
margin-right: 4px; margin-right: 4px;
height: 24px; height: 24px;
overflow: hidden;
`; `;
const Actions = styled.span` const Actions = styled(EventBoundary)`
display: ${(props) => (props.showActions ? "inline-flex" : "none")}; display: ${(props) => (props.showActions ? "inline-flex" : "none")};
position: absolute; position: absolute;
top: 4px; top: 4px;
@ -110,7 +122,6 @@ const Actions = styled.span`
const StyledNavLink = styled(NavLink)` const StyledNavLink = styled(NavLink)`
display: flex; display: flex;
position: relative; position: relative;
overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
padding: 4px 16px; padding: 4px 16px;
border-radius: 4px; border-radius: 4px;
@ -121,6 +132,7 @@ const StyledNavLink = styled(NavLink)`
props.$isActiveDrop ? props.theme.white : props.theme.sidebarText}; props.$isActiveDrop ? props.theme.white : props.theme.sidebarText};
font-size: 15px; font-size: 15px;
cursor: pointer; cursor: pointer;
overflow: hidden;
svg { svg {
${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")} ${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")}

View File

@ -8,7 +8,7 @@ import type { Toast as TToast } from "types";
type Props = { type Props = {
onRequestClose: () => void, onRequestClose: () => void,
closeAfterMs: number, closeAfterMs?: number,
toast: TToast, toast: TToast,
}; };

View File

@ -1,134 +1,128 @@
// @flow // @flow
import { observable } from "mobx"; import { observer } from "mobx-react";
import { inject, observer } from "mobx-react";
import { SunIcon, MoonIcon } from "outline-icons"; import { SunIcon, MoonIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useMenuState, MenuButton } from "reakit/Menu";
import styled from "styled-components"; import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import Flex from "components/Flex";
import Modal from "components/Modal";
import { import {
developers, developers,
changelog, changelog,
githubIssuesUrl, githubIssuesUrl,
mailToUrl, mailToUrl,
settings, settings,
} from "../../shared/utils/routeHelpers"; } from "shared/utils/routeHelpers";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import ContextMenu from "components/ContextMenu";
import MenuItem, { MenuAnchor } from "components/ContextMenu/MenuItem";
import Separator from "components/ContextMenu/Separator";
import Flex from "components/Flex";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
type Props = { type Props = {|
label: React.Node, children: (props: any) => React.Node,
ui: UiStore, |};
auth: AuthStore,
t: TFunction,
};
@observer const AppearanceMenu = React.forwardRef((props, ref) => {
class AccountMenu extends React.Component<Props> { const { ui } = useStores();
@observable keyboardShortcutsOpen: boolean = false; const { t } = useTranslation();
const menu = useMenuState();
handleLogout = () => { return (
this.props.auth.logout(); <>
}; <MenuButton ref={ref} {...menu} {...props}>
{(props) => (
handleOpenKeyboardShortcuts = () => { <MenuAnchor {...props}>
this.keyboardShortcutsOpen = true; <ChangeTheme justify="space-between">
}; {t("Appearance")}
{ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />}
handleCloseKeyboardShortcuts = () => { </ChangeTheme>
this.keyboardShortcutsOpen = false; </MenuAnchor>
}; )}
</MenuButton>
render() { <ContextMenu {...menu} aria-label={t("Appearance")}>
const { ui, t } = this.props; <MenuItem
{...menu}
return ( onClick={() => ui.setTheme("system")}
<> selected={ui.theme === "system"}
<Modal
isOpen={this.keyboardShortcutsOpen}
onRequestClose={this.handleCloseKeyboardShortcuts}
title={t("Keyboard shortcuts")}
> >
<KeyboardShortcuts /> {t("System")}
</Modal> </MenuItem>
<DropdownMenu <MenuItem
style={{ marginRight: 10, marginTop: -10 }} {...menu}
label={this.props.label} onClick={() => ui.setTheme("light")}
selected={ui.theme === "light"}
> >
<DropdownMenuItem as={Link} to={settings()}> {t("Light")}
{t("Settings")} </MenuItem>
</DropdownMenuItem> <MenuItem
<DropdownMenuItem onClick={this.handleOpenKeyboardShortcuts}> {...menu}
{t("Keyboard shortcuts")} onClick={() => ui.setTheme("dark")}
</DropdownMenuItem> selected={ui.theme === "dark"}
<DropdownMenuItem href={developers()} target="_blank"> >
{t("API documentation")} {t("Dark")}
</DropdownMenuItem> </MenuItem>
<hr /> </ContextMenu>
<DropdownMenuItem href={changelog()} target="_blank"> </>
{t("Changelog")} );
</DropdownMenuItem> });
<DropdownMenuItem href={mailToUrl()} target="_blank">
{t("Send us feedback")} function AccountMenu(props: Props) {
</DropdownMenuItem> const menu = useMenuState({
<DropdownMenuItem href={githubIssuesUrl()} target="_blank"> placement: "bottom-start",
{t("Report a bug")} modal: true,
</DropdownMenuItem> });
<hr /> const { auth } = useStores();
<DropdownMenu const { t } = useTranslation();
position="right" const [keyboardShortcutsOpen, setKeyboardShortcutsOpen] = React.useState(
style={{ false
left: 170, );
position: "relative",
top: -40, return (
}} <>
label={ <Modal
<DropdownMenuItem> isOpen={keyboardShortcutsOpen}
<ChangeTheme justify="space-between"> onRequestClose={() => setKeyboardShortcutsOpen(false)}
{t("Appearance")} title={t("Keyboard shortcuts")}
{ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />} >
</ChangeTheme> <KeyboardShortcuts />
</DropdownMenuItem> </Modal>
} <MenuButton {...menu}>{props.children}</MenuButton>
hover <ContextMenu {...menu} aria-label={t("Account")}>
> <MenuItem {...menu} as={Link} to={settings()}>
<DropdownMenuItem {t("Settings")}
onClick={() => ui.setTheme("system")} </MenuItem>
selected={ui.theme === "system"} <MenuItem {...menu} onClick={() => setKeyboardShortcutsOpen(true)}>
> {t("Keyboard shortcuts")}
{t("System")} </MenuItem>
</DropdownMenuItem> <MenuItem {...menu} href={developers()} target="_blank">
<DropdownMenuItem {t("API documentation")}
onClick={() => ui.setTheme("light")} </MenuItem>
selected={ui.theme === "light"} <Separator {...menu} />
> <MenuItem {...menu} href={changelog()} target="_blank">
{t("Light")} {t("Changelog")}
</DropdownMenuItem> </MenuItem>
<DropdownMenuItem <MenuItem {...menu} href={mailToUrl()} target="_blank">
onClick={() => ui.setTheme("dark")} {t("Send us feedback")}
selected={ui.theme === "dark"} </MenuItem>
> <MenuItem {...menu} href={githubIssuesUrl()} target="_blank">
{t("Dark")} {t("Report a bug")}
</DropdownMenuItem> </MenuItem>
</DropdownMenu> <Separator {...menu} />
<hr /> <MenuItem {...menu} as={AppearanceMenu} />
<DropdownMenuItem onClick={this.handleLogout}> <Separator {...menu} />
{t("Log out")} <MenuItem {...menu} onClick={auth.logout}>
</DropdownMenuItem> {t("Log out")}
</DropdownMenu> </MenuItem>
</> </ContextMenu>
); </>
} );
} }
const ChangeTheme = styled(Flex)` const ChangeTheme = styled(Flex)`
width: 100%; width: 100%;
`; `;
export default withTranslation()<AccountMenu>( export default observer(AccountMenu);
inject("ui", "auth")(AccountMenu)
);

View File

@ -0,0 +1,34 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
type Props = {
path: Array<any>,
};
export default function BreadcrumbMenu({ path }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
placement: "bottom",
});
return (
<>
<OverflowMenuButton {...menu} />
<ContextMenu {...menu} aria-label={t("Path to document")}>
<Template
{...menu}
items={path.map((item) => ({
title: item.title,
to: item.url,
}))}
/>
</ContextMenu>
</>
);
}

View File

@ -0,0 +1,44 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
type Props = {|
onMembers: () => void,
onRemove: () => void,
|};
function CollectionGroupMemberMenu({ onMembers, onRemove }: Props) {
const { t } = useTranslation();
const menu = useMenuState({ modal: true });
return (
<>
<OverflowMenuButton {...menu} />
<ContextMenu {...menu} aria-label={t("Group member options")}>
<Template
{...menu}
items={[
{
title: t("Members"),
onClick: onMembers,
},
{
type: "separator",
},
{
title: t("Remove"),
onClick: onRemove,
},
]}
/>
</ContextMenu>
</>
);
}
export default observer(CollectionGroupMemberMenu);

View File

@ -1,229 +1,221 @@
// @flow // @flow
import { observable } from "mobx"; import { observer } from "mobx-react";
import { inject, observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next"; import { useTranslation } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import DocumentsStore from "stores/DocumentsStore"; import { useMenuState, MenuButton } from "reakit/Menu";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection"; import Collection from "models/Collection";
import CollectionDelete from "scenes/CollectionDelete"; import CollectionDelete from "scenes/CollectionDelete";
import CollectionEdit from "scenes/CollectionEdit"; import CollectionEdit from "scenes/CollectionEdit";
import CollectionExport from "scenes/CollectionExport"; import CollectionExport from "scenes/CollectionExport";
import CollectionMembers from "scenes/CollectionMembers"; import CollectionMembers from "scenes/CollectionMembers";
import { DropdownMenu } from "components/DropdownMenu"; import ContextMenu from "components/ContextMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems"; import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import Modal from "components/Modal"; import Modal from "components/Modal";
import VisuallyHidden from "components/VisuallyHidden"; import VisuallyHidden from "components/VisuallyHidden";
import useStores from "hooks/useStores";
import getDataTransferFiles from "utils/getDataTransferFiles"; import getDataTransferFiles from "utils/getDataTransferFiles";
import { newDocumentUrl } from "utils/routeHelpers"; import { newDocumentUrl } from "utils/routeHelpers";
type Props = { type Props = {|
position?: "left" | "right" | "center",
ui: UiStore,
policies: PoliciesStore,
documents: DocumentsStore,
collection: Collection, collection: Collection,
history: RouterHistory, placement?: string,
modal?: boolean,
label?: (any) => React.Node,
onOpen?: () => void, onOpen?: () => void,
onClose?: () => void, onClose?: () => void,
t: TFunction, |};
};
@observer function CollectionMenu({
class CollectionMenu extends React.Component<Props> { collection,
file: ?HTMLInputElement; label,
@observable showCollectionMembers = false; modal = true,
@observable showCollectionEdit = false; placement,
@observable showCollectionDelete = false; onOpen,
@observable showCollectionExport = false; onClose,
}: Props) {
const menu = useMenuState({ modal, placement });
const [renderModals, setRenderModals] = React.useState(false);
const { ui, documents, policies } = useStores();
const { t } = useTranslation();
const history = useHistory();
onNewDocument = (ev: SyntheticEvent<>) => { const file = React.useRef<?HTMLInputElement>();
ev.preventDefault(); const [showCollectionMembers, setShowCollectionMembers] = React.useState(
const { collection } = this.props; false
this.props.history.push(newDocumentUrl(collection.id)); );
}; const [showCollectionEdit, setShowCollectionEdit] = React.useState(false);
const [showCollectionDelete, setShowCollectionDelete] = React.useState(false);
const [showCollectionExport, setShowCollectionExport] = React.useState(false);
onImportDocument = (ev: SyntheticEvent<>) => { const handleOpen = React.useCallback(() => {
ev.preventDefault(); setRenderModals(true);
ev.stopPropagation(); if (onOpen) {
onOpen();
// simulate a click on the file upload input element
if (this.file) this.file.click();
};
onFilePicked = async (ev: SyntheticEvent<>) => {
const files = getDataTransferFiles(ev);
try {
const file = files[0];
const document = await this.props.documents.import(
file,
null,
this.props.collection.id,
{ publish: true }
);
this.props.history.push(document.url);
} catch (err) {
this.props.ui.showToast(err.message, {
type: "error",
});
} }
}; }, [onOpen]);
handleEditCollectionOpen = (ev: SyntheticEvent<>) => { const handleNewDocument = React.useCallback(
ev.preventDefault(); (ev: SyntheticEvent<>) => {
this.showCollectionEdit = true; ev.preventDefault();
}; history.push(newDocumentUrl(collection.id));
},
[history, collection.id]
);
handleEditCollectionClose = () => { const handleImportDocument = React.useCallback(
this.showCollectionEdit = false; (ev: SyntheticEvent<>) => {
}; ev.preventDefault();
ev.stopPropagation();
handleDeleteCollectionOpen = (ev: SyntheticEvent<>) => { // simulate a click on the file upload input element
ev.preventDefault(); if (file.current) {
this.showCollectionDelete = true; file.current.click();
}; }
},
[file]
);
handleDeleteCollectionClose = () => { const handleFilePicked = React.useCallback(
this.showCollectionDelete = false; async (ev: SyntheticEvent<>) => {
}; const files = getDataTransferFiles(ev);
handleExportCollectionOpen = (ev: SyntheticEvent<>) => { try {
ev.preventDefault(); const file = files[0];
this.showCollectionExport = true; const document = await documents.import(
}; file,
null,
this.props.collection.id,
{ publish: true }
);
history.push(document.url);
} catch (err) {
ui.showToast(err.message, {
type: "error",
});
}
},
[history, ui, documents]
);
handleExportCollectionClose = () => { const can = policies.abilities(collection.id);
this.showCollectionExport = false;
};
handleMembersModalOpen = (ev: SyntheticEvent<>) => { return (
ev.preventDefault(); <>
this.showCollectionMembers = true; <VisuallyHidden>
}; <input
type="file"
handleMembersModalClose = () => { ref={file}
this.showCollectionMembers = false; onChange={handleFilePicked}
}; onClick={(ev) => ev.stopPropagation()}
accept={documents.importFileTypes.join(", ")}
render() { tabIndex="-1"
const { />
policies, </VisuallyHidden>
documents, {label ? (
collection, <MenuButton {...menu}>{label}</MenuButton>
position, ) : (
onOpen, <OverflowMenuButton {...menu} />
onClose, )}
t, <ContextMenu
} = this.props; {...menu}
const can = policies.abilities(collection.id); onOpen={handleOpen}
onClose={onClose}
return ( aria-label={t("Collection")}
<> >
<VisuallyHidden> <Template
<input {...menu}
type="file" items={[
ref={(ref) => (this.file = ref)} {
onChange={this.onFilePicked} title: t("New document"),
onClick={(ev) => ev.stopPropagation()} visible: can.update,
accept={documents.importFileTypes.join(", ")} onClick: handleNewDocument,
/> },
</VisuallyHidden> {
title: t("Import document"),
<Modal visible: can.update,
title={t("Collection permissions")} onClick: handleImportDocument,
onRequestClose={this.handleMembersModalClose} },
isOpen={this.showCollectionMembers} {
> type: "separator",
<CollectionMembers },
collection={collection} {
onSubmit={this.handleMembersModalClose} title: `${t("Edit")}`,
handleEditCollectionOpen={this.handleEditCollectionOpen} visible: can.update,
onEdit={this.handleEditCollectionOpen} onClick: () => setShowCollectionEdit(true),
/> },
</Modal> {
<DropdownMenu onOpen={onOpen} onClose={onClose} position={position}> title: `${t("Permissions")}`,
<DropdownMenuItems visible: can.update,
items={[ onClick: () => setShowCollectionMembers(true),
{ },
title: t("New document"), {
visible: can.update, title: `${t("Export")}`,
onClick: this.onNewDocument, visible: !!(collection && can.export),
}, onClick: () => setShowCollectionExport(true),
{ },
title: t("Import document"), {
visible: can.update, type: "separator",
onClick: this.onImportDocument, },
}, {
{ type: "separator",
type: "separator", },
}, {
{ title: `${t("Delete")}`,
title: `${t("Edit")}`, visible: !!(collection && can.delete),
visible: can.update, onClick: () => setShowCollectionDelete(true),
onClick: this.handleEditCollectionOpen, },
}, ]}
{ />
title: `${t("Permissions")}`, </ContextMenu>
visible: can.update, {renderModals && (
onClick: this.handleMembersModalOpen, <>
}, <Modal
{ title={t("Collection permissions")}
title: `${t("Export")}`, onRequestClose={() => setShowCollectionMembers(false)}
visible: !!(collection && can.export), isOpen={showCollectionMembers}
onClick: this.handleExportCollectionOpen, >
}, <CollectionMembers
{ collection={collection}
type: "separator", onSubmit={() => setShowCollectionMembers(false)}
}, onEdit={() => setShowCollectionEdit(true)}
{ />
type: "separator", </Modal>
}, <Modal
{ title={t("Edit collection")}
title: `${t("Delete")}`, isOpen={showCollectionEdit}
visible: !!(collection && can.delete), onRequestClose={() => setShowCollectionEdit(false)}
onClick: this.handleDeleteCollectionOpen, >
}, <CollectionEdit
]} onSubmit={() => setShowCollectionEdit(false)}
/> collection={collection}
</DropdownMenu> />
<Modal </Modal>
title={t("Edit collection")} <Modal
isOpen={this.showCollectionEdit} title={t("Delete collection")}
onRequestClose={this.handleEditCollectionClose} isOpen={showCollectionDelete}
> onRequestClose={() => setShowCollectionDelete(false)}
<CollectionEdit >
onSubmit={this.handleEditCollectionClose} <CollectionDelete
collection={collection} onSubmit={() => setShowCollectionDelete(false)}
/> collection={collection}
</Modal> />
<Modal </Modal>
title={t("Delete collection")} <Modal
isOpen={this.showCollectionDelete} title={t("Export collection")}
onRequestClose={this.handleDeleteCollectionClose} isOpen={showCollectionExport}
> onRequestClose={() => setShowCollectionExport(false)}
<CollectionDelete >
onSubmit={this.handleDeleteCollectionClose} <CollectionExport
collection={collection} onSubmit={() => setShowCollectionExport(false)}
/> collection={collection}
</Modal> />
<Modal </Modal>
title={t("Export collection")} </>
isOpen={this.showCollectionExport} )}
onRequestClose={this.handleExportCollectionClose} </>
> );
<CollectionExport
onSubmit={this.handleExportCollectionClose}
collection={collection}
/>
</Modal>
</>
);
}
} }
export default withTranslation()<CollectionMenu>( export default observer(CollectionMenu);
inject("ui", "documents", "policies")(withRouter(CollectionMenu))
);

View File

@ -3,29 +3,25 @@ import { observer } from "mobx-react";
import { AlphabeticalSortIcon, ManualSortIcon } from "outline-icons"; import { AlphabeticalSortIcon, ManualSortIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton } from "reakit/Menu";
import Collection from "models/Collection"; import Collection from "models/Collection";
import { DropdownMenu } from "components/DropdownMenu"; import ContextMenu from "components/ContextMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems"; import Template from "components/ContextMenu/Template";
import NudeButton from "components/NudeButton"; import NudeButton from "components/NudeButton";
type Props = { type Props = {|
position?: "left" | "right" | "center",
collection: Collection, collection: Collection,
onOpen?: () => void, onOpen?: () => void,
onClose?: () => void, onClose?: () => void,
}; |};
function CollectionSortMenu({ function CollectionSortMenu({ collection, onOpen, onClose, ...rest }: Props) {
collection,
position,
onOpen,
onClose,
...rest
}: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const menu = useMenuState({ modal: true });
const handleChangeSort = React.useCallback( const handleChangeSort = React.useCallback(
(field: string) => { (field: string) => {
menu.hide();
return collection.save({ return collection.save({
sort: { sort: {
field, field,
@ -33,38 +29,43 @@ function CollectionSortMenu({
}, },
}); });
}, },
[collection] [collection, menu]
); );
const alphabeticalSort = collection.sort.field === "title"; const alphabeticalSort = collection.sort.field === "title";
return ( return (
<DropdownMenu <>
onOpen={onOpen} <MenuButton {...menu}>
onClose={onClose} {(props) => (
label={ <NudeButton {...props}>
<NudeButton aria-label={t("Sort in sidebar")} aria-haspopup="true"> {alphabeticalSort ? <AlphabeticalSortIcon /> : <ManualSortIcon />}
{alphabeticalSort ? <AlphabeticalSortIcon /> : <ManualSortIcon />} </NudeButton>
</NudeButton> )}
} </MenuButton>
position={position} <ContextMenu
{...rest} {...menu}
> onOpen={onOpen}
<DropdownMenuItems onClose={onClose}
items={[ aria-label={t("Sort in sidebar")}
{ >
title: t("Alphabetical sort"), <Template
onClick: () => handleChangeSort("title"), {...menu}
selected: alphabeticalSort, items={[
}, {
{ title: t("Alphabetical sort"),
title: t("Manual sort"), onClick: () => handleChangeSort("title"),
onClick: () => handleChangeSort("index"), selected: alphabeticalSort,
selected: !alphabeticalSort, },
}, {
]} title: t("Manual sort"),
/> onClick: () => handleChangeSort("index"),
</DropdownMenu> selected: !alphabeticalSort,
},
]}
/>
</ContextMenu>
</>
); );
} }

View File

@ -1,21 +1,21 @@
// @flow // @flow
import { observable } from "mobx"; import { observer } from "mobx-react";
import { inject, observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom"; import { useHistory } from "react-router-dom";
import AuthStore from "stores/AuthStore"; import { useMenuState, MenuButton } from "reakit/Menu";
import CollectionStore from "stores/CollectionsStore"; import styled from "styled-components";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import Document from "models/Document"; import Document from "models/Document";
import DocumentDelete from "scenes/DocumentDelete"; import DocumentDelete from "scenes/DocumentDelete";
import DocumentShare from "scenes/DocumentShare"; import DocumentShare from "scenes/DocumentShare";
import DocumentTemplatize from "scenes/DocumentTemplatize"; import DocumentTemplatize from "scenes/DocumentTemplatize";
import CollectionIcon from "components/CollectionIcon"; import CollectionIcon from "components/CollectionIcon";
import { DropdownMenu } from "components/DropdownMenu"; import ContextMenu from "components/ContextMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems"; import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import Modal from "components/Modal"; import Modal from "components/Modal";
import useStores from "hooks/useStores";
import { import {
documentHistoryUrl, documentHistoryUrl,
documentMoveUrl, documentMoveUrl,
@ -24,348 +24,319 @@ import {
newDocumentUrl, newDocumentUrl,
} from "utils/routeHelpers"; } from "utils/routeHelpers";
type Props = { type Props = {|
ui: UiStore,
auth: AuthStore,
position?: "left" | "right" | "center",
document: Document, document: Document,
collections: CollectionStore,
policies: PoliciesStore,
className: string, className: string,
isRevision?: boolean, isRevision?: boolean,
showPrint?: boolean, showPrint?: boolean,
modal?: boolean,
showToggleEmbeds?: boolean, showToggleEmbeds?: boolean,
showPin?: boolean, showPin?: boolean,
label?: React.Node, label?: (any) => React.Node,
onOpen?: () => void, onOpen?: () => void,
onClose?: () => void, onClose?: () => void,
t: TFunction, |};
};
@observer function DocumentMenu({
class DocumentMenu extends React.Component<Props> { document,
@observable redirectTo: ?string; isRevision,
@observable showDeleteModal = false; className,
@observable showTemplateModal = false; modal = true,
@observable showShareModal = false; showToggleEmbeds,
showPrint,
showPin,
label,
onOpen,
onClose,
}: Props) {
const { policies, collections, auth, ui } = useStores();
const menu = useMenuState({ modal });
const history = useHistory();
const { t } = useTranslation();
const [renderModals, setRenderModals] = React.useState(false);
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
const [showTemplateModal, setShowTemplateModal] = React.useState(false);
const [showShareModal, setShowShareModal] = React.useState(false);
componentDidUpdate() { const handleOpen = React.useCallback(() => {
this.redirectTo = undefined; setRenderModals(true);
} if (onOpen) {
onOpen();
handleNewChild = (ev: SyntheticEvent<>) => {
const { document } = this.props;
this.redirectTo = newDocumentUrl(document.collectionId, {
parentDocumentId: document.id,
});
};
handleDelete = (ev: SyntheticEvent<>) => {
this.showDeleteModal = true;
};
handleDocumentHistory = () => {
if (this.props.isRevision) {
this.redirectTo = documentUrl(this.props.document);
} else {
this.redirectTo = documentHistoryUrl(this.props.document);
} }
}; }, [onOpen]);
handleMove = (ev: SyntheticEvent<>) => { const handleDuplicate = React.useCallback(
this.redirectTo = documentMoveUrl(this.props.document); async (ev: SyntheticEvent<>) => {
}; const duped = await document.duplicate();
handleEdit = (ev: SyntheticEvent<>) => { // when duplicating, go straight to the duplicated document content
this.redirectTo = editDocumentUrl(this.props.document); history.push(duped.url);
}; ui.showToast(t("Document duplicated"), { type: "success" });
},
[ui, t, history, document]
);
handleDuplicate = async (ev: SyntheticEvent<>) => { const handleArchive = React.useCallback(
const duped = await this.props.document.duplicate(); async (ev: SyntheticEvent<>) => {
await document.archive();
ui.showToast(t("Document archived"), { type: "success" });
},
[ui, t, document]
);
// when duplicating, go straight to the duplicated document content const handleRestore = React.useCallback(
this.redirectTo = duped.url; async (ev: SyntheticEvent<>, options?: { collectionId: string }) => {
const { t } = this.props; await document.restore(options);
this.props.ui.showToast(t("Document duplicated"), { type: "success" }); ui.showToast(t("Document restored"), { type: "success" });
}; },
[ui, t, document]
);
handleOpenTemplateModal = () => { const handleUnpublish = React.useCallback(
this.showTemplateModal = true; async (ev: SyntheticEvent<>) => {
}; await document.unpublish();
ui.showToast(t("Document unpublished"), { type: "success" });
},
[ui, t, document]
);
handleCloseTemplateModal = () => { const handlePrint = React.useCallback((ev: SyntheticEvent<>) => {
this.showTemplateModal = false; window.print();
}; }, []);
handleCloseDeleteModal = () => { const handleStar = React.useCallback(
this.showDeleteModal = false; (ev: SyntheticEvent<>) => {
}; ev.stopPropagation();
document.star();
},
[document]
);
handleArchive = async (ev: SyntheticEvent<>) => { const handleUnstar = React.useCallback(
await this.props.document.archive(); (ev: SyntheticEvent<>) => {
const { t } = this.props; ev.stopPropagation();
this.props.ui.showToast(t("Document archived"), { type: "success" }); document.unstar();
}; },
[document]
);
handleRestore = async ( const handleShareLink = React.useCallback(
ev: SyntheticEvent<>, async (ev: SyntheticEvent<>) => {
options?: { collectionId: string } await document.share();
) => { setShowShareModal(true);
await this.props.document.restore(options); },
const { t } = this.props; [document]
this.props.ui.showToast(t("Document restored"), { type: "success" }); );
};
handleUnpublish = async (ev: SyntheticEvent<>) => { const can = policies.abilities(document.id);
await this.props.document.unpublish(); const canShareDocuments = !!(can.share && auth.team && auth.team.sharing);
const { t } = this.props; const canViewHistory = can.read && !can.restore;
this.props.ui.showToast(t("Document unpublished"), { type: "success" }); const collection = collections.get(document.collectionId);
};
handlePin = (ev: SyntheticEvent<>) => { return (
this.props.document.pin(); <>
}; {label ? (
<MenuButton {...menu}>{label}</MenuButton>
handleUnpin = (ev: SyntheticEvent<>) => { ) : (
this.props.document.unpin(); <OverflowMenuButton className={className} {...menu} />
}; )}
<ContextMenu
handleStar = (ev: SyntheticEvent<>) => { {...menu}
ev.stopPropagation(); aria-label={t("Document options")}
this.props.document.star(); onOpen={handleOpen}
}; onClose={onClose}
>
handleUnstar = (ev: SyntheticEvent<>) => { <Template
ev.stopPropagation(); {...menu}
this.props.document.unstar(); items={[
}; {
title: t("Restore"),
handleExport = (ev: SyntheticEvent<>) => { visible: !!can.unarchive,
this.props.document.download(); onClick: handleRestore,
}; },
{
handleShareLink = async (ev: SyntheticEvent<>) => { title: t("Restore"),
const { document } = this.props; visible: !!(collection && can.restore),
await document.share(); onClick: handleRestore,
this.showShareModal = true; },
}; {
title: t("Restore"),
handleCloseShareModal = () => { visible: !collection && !!can.restore,
this.showShareModal = false; style: {
}; left: -170,
position: "relative",
render() { top: -40,
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const {
policies,
document,
position,
className,
showToggleEmbeds,
showPrint,
showPin,
auth,
collections,
label,
onOpen,
onClose,
t,
} = this.props;
const can = policies.abilities(document.id);
const canShareDocuments = !!(can.share && auth.team && auth.team.sharing);
const canViewHistory = can.read && !can.restore;
const collection = collections.get(document.collectionId);
return (
<>
<DropdownMenu
className={className}
position={position}
onOpen={onOpen}
onClose={onClose}
label={label}
>
<DropdownMenuItems
items={[
{
title: t("Restore"),
visible: !!can.unarchive,
onClick: this.handleRestore,
}, },
{ hover: true,
title: t("Restore"), items: [
visible: !!(collection && can.restore), {
onClick: this.handleRestore, type: "heading",
}, title: t("Choose a collection"),
{
title: t("Restore"),
visible: !collection && !!can.restore,
style: {
left: -170,
position: "relative",
top: -40,
}, },
hover: true, ...collections.orderedData.map((collection) => {
items: [ const can = policies.abilities(collection.id);
{
type: "heading",
title: t("Choose a collection"),
},
...collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
return { return {
title: ( title: (
<> <Flex align="center">
<CollectionIcon collection={collection} /> <CollectionIcon collection={collection} />
&nbsp;{collection.name} <CollectionName>{collection.name}</CollectionName>
</> </Flex>
), ),
onClick: (ev) => onClick: (ev) =>
this.handleRestore(ev, { collectionId: collection.id }), handleRestore(ev, { collectionId: collection.id }),
disabled: !can.update, disabled: !can.update,
}; };
}), }),
], ],
}, },
{ {
title: t("Unpin"), title: t("Unpin"),
onClick: this.handleUnpin, onClick: document.unpin,
visible: !!(showPin && document.pinned && can.unpin), visible: !!(showPin && document.pinned && can.unpin),
}, },
{ {
title: t("Pin to collection"), title: t("Pin to collection"),
onClick: this.handlePin, onClick: document.pin,
visible: !!(showPin && !document.pinned && can.pin), visible: !!(showPin && !document.pinned && can.pin),
}, },
{ {
title: t("Unstar"), title: t("Unstar"),
onClick: this.handleUnstar, onClick: handleUnstar,
visible: document.isStarred && !!can.unstar, visible: document.isStarred && !!can.unstar,
}, },
{ {
title: t("Star"), title: t("Star"),
onClick: this.handleStar, onClick: handleStar,
visible: !document.isStarred && !!can.star, visible: !document.isStarred && !!can.star,
}, },
{ {
title: `${t("Share link")}`, title: `${t("Share link")}`,
onClick: this.handleShareLink, onClick: handleShareLink,
visible: canShareDocuments, visible: canShareDocuments,
}, },
{ {
title: t("Enable embeds"), title: t("Enable embeds"),
onClick: document.enableEmbeds, onClick: document.enableEmbeds,
visible: !!showToggleEmbeds && document.embedsDisabled, visible: !!showToggleEmbeds && document.embedsDisabled,
}, },
{ {
title: t("Disable embeds"), title: t("Disable embeds"),
onClick: document.disableEmbeds, onClick: document.disableEmbeds,
visible: !!showToggleEmbeds && !document.embedsDisabled, visible: !!showToggleEmbeds && !document.embedsDisabled,
}, },
{ {
type: "separator", type: "separator",
}, },
{ {
title: t("New nested document"), title: t("New nested document"),
onClick: this.handleNewChild, to: newDocumentUrl(document.collectionId, {
visible: !!can.createChildDocument, parentDocumentId: document.id,
}, }),
{ visible: !!can.createChildDocument,
title: `${t("Create template")}`, },
onClick: this.handleOpenTemplateModal, {
visible: !!can.update && !document.isTemplate, title: `${t("Create template")}`,
}, onClick: () => setShowTemplateModal(true),
{ visible: !!can.update && !document.isTemplate,
title: t("Edit"), },
onClick: this.handleEdit, {
visible: !!can.update, title: t("Edit"),
}, to: editDocumentUrl(document),
{ visible: !!can.update,
title: t("Duplicate"), },
onClick: this.handleDuplicate, {
visible: !!can.update, title: t("Duplicate"),
}, onClick: handleDuplicate,
{ visible: !!can.update,
title: t("Unpublish"), },
onClick: this.handleUnpublish, {
visible: !!can.unpublish, title: t("Unpublish"),
}, onClick: handleUnpublish,
{ visible: !!can.unpublish,
title: t("Archive"), },
onClick: this.handleArchive, {
visible: !!can.archive, title: t("Archive"),
}, onClick: handleArchive,
{ visible: !!can.archive,
title: `${t("Delete")}`, },
onClick: this.handleDelete, {
visible: !!can.delete, title: `${t("Delete")}`,
}, onClick: () => setShowDeleteModal(true),
{ visible: !!can.delete,
title: `${t("Move")}`, },
onClick: this.handleMove, {
visible: !!can.move, title: `${t("Move")}`,
}, to: documentMoveUrl(document),
{ visible: !!can.move,
type: "separator", },
}, {
{ type: "separator",
title: t("History"), },
onClick: this.handleDocumentHistory, {
visible: canViewHistory, title: t("History"),
}, to: isRevision
{ ? documentUrl(document)
title: t("Download"), : documentHistoryUrl(document),
onClick: this.handleExport, visible: canViewHistory,
visible: !!can.download, },
}, {
{ title: t("Download"),
title: t("Print"), onClick: document.download,
onClick: window.print, visible: !!can.download,
visible: !!showPrint, },
}, {
]} title: t("Print"),
/> onClick: handlePrint,
</DropdownMenu> visible: !!showPrint,
<Modal },
title={t("Delete {{ documentName }}", { ]}
documentName: this.props.document.noun, />
})} </ContextMenu>
onRequestClose={this.handleCloseDeleteModal} {renderModals && (
isOpen={this.showDeleteModal} <>
> <Modal
<DocumentDelete title={t("Delete {{ documentName }}", {
document={this.props.document} documentName: document.noun,
onSubmit={this.handleCloseDeleteModal} })}
/> onRequestClose={() => setShowDeleteModal(false)}
</Modal> isOpen={showDeleteModal}
<Modal >
title={t("Create template")} <DocumentDelete
onRequestClose={this.handleCloseTemplateModal} document={document}
isOpen={this.showTemplateModal} onSubmit={() => setShowDeleteModal(false)}
> />
<DocumentTemplatize </Modal>
document={this.props.document} <Modal
onSubmit={this.handleCloseTemplateModal} title={t("Create template")}
/> onRequestClose={() => setShowTemplateModal(false)}
</Modal> isOpen={showTemplateModal}
<Modal >
title={t("Share document")} <DocumentTemplatize
onRequestClose={this.handleCloseShareModal} document={document}
isOpen={this.showShareModal} onSubmit={() => setShowTemplateModal(false)}
> />
<DocumentShare </Modal>
document={this.props.document} <Modal
onSubmit={this.handleCloseShareModal} title={t("Share document")}
/> onRequestClose={() => setShowShareModal(false)}
</Modal> isOpen={showShareModal}
</> >
); <DocumentShare
} document={document}
onSubmit={() => setShowShareModal(false)}
/>
</Modal>
</>
)}
</>
);
} }
export default withTranslation()<DocumentMenu>( const CollectionName = styled.div`
inject("ui", "auth", "collections", "policies")(DocumentMenu) overflow: hidden;
); white-space: nowrap;
text-overflow: ellipsis;
`;
export default observer(DocumentMenu);

View File

@ -0,0 +1,36 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
type Props = {|
onRemove: () => void,
|};
function GroupMemberMenu({ onRemove }: Props) {
const { t } = useTranslation();
const menu = useMenuState({ modal: true });
return (
<>
<OverflowMenuButton {...menu} />
<ContextMenu {...menu} aria-label={t("Group member options")}>
<Template
{...menu}
items={[
{
title: t("Remove"),
onClick: onRemove,
},
]}
/>
</ContextMenu>
</>
);
}
export default observer(GroupMemberMenu);

View File

@ -1,108 +1,74 @@
// @flow // @flow
import { observable } from "mobx"; import { observer } from "mobx-react";
import { inject, observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next"; import { useTranslation } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom"; import { useMenuState } from "reakit/Menu";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import Group from "models/Group"; import Group from "models/Group";
import GroupDelete from "scenes/GroupDelete"; import GroupDelete from "scenes/GroupDelete";
import GroupEdit from "scenes/GroupEdit"; import GroupEdit from "scenes/GroupEdit";
import { DropdownMenu } from "components/DropdownMenu"; import ContextMenu from "components/ContextMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems"; import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import Modal from "components/Modal"; import Modal from "components/Modal";
import useStores from "hooks/useStores";
type Props = { type Props = {|
ui: UiStore,
policies: PoliciesStore,
group: Group, group: Group,
history: RouterHistory,
onMembers: () => void, onMembers: () => void,
onOpen?: () => void, |};
onClose?: () => void,
t: TFunction,
};
@observer function GroupMenu({ group, onMembers }: Props) {
class GroupMenu extends React.Component<Props> { const { t } = useTranslation();
@observable editModalOpen: boolean = false; const { policies } = useStores();
@observable deleteModalOpen: boolean = false; const menu = useMenuState({ modal: true });
const [editModalOpen, setEditModalOpen] = React.useState(false);
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
const can = policies.abilities(group.id);
onEdit = (ev: SyntheticEvent<>) => { return (
ev.preventDefault(); <>
this.editModalOpen = true; <Modal
}; title={t("Edit group")}
onRequestClose={() => setEditModalOpen(false)}
onDelete = (ev: SyntheticEvent<>) => { isOpen={editModalOpen}
ev.preventDefault(); >
this.deleteModalOpen = true; <GroupEdit group={group} onSubmit={() => setEditModalOpen(false)} />
}; </Modal>
<Modal
handleEditModalClose = () => { title={t("Delete group")}
this.editModalOpen = false; onRequestClose={() => setDeleteModalOpen(false)}
}; isOpen={deleteModalOpen}
>
handleDeleteModalClose = () => { <GroupDelete group={group} onSubmit={() => setDeleteModalOpen(false)} />
this.deleteModalOpen = false; </Modal>
}; <OverflowMenuButton {...menu} />
<ContextMenu {...menu} aria-label={t("Group options")}>
render() { <Template
const { policies, group, onOpen, onClose, t } = this.props; {...menu}
const can = policies.abilities(group.id); items={[
{
return ( title: `${t("Members")}`,
<> onClick: onMembers,
<Modal visible: !!(group && can.read),
title={t("Edit group")} },
onRequestClose={this.handleEditModalClose} {
isOpen={this.editModalOpen} type: "separator",
> },
<GroupEdit {
group={this.props.group} title: `${t("Edit")}`,
onSubmit={this.handleEditModalClose} onClick: () => setEditModalOpen(true),
/> visible: !!(group && can.update),
</Modal> },
{
<Modal title: `${t("Delete")}`,
title={t("Delete group")} onClick: () => setDeleteModalOpen(true),
onRequestClose={this.handleDeleteModalClose} visible: !!(group && can.delete),
isOpen={this.deleteModalOpen} },
> ]}
<GroupDelete />
group={this.props.group} </ContextMenu>
onSubmit={this.handleDeleteModalClose} </>
/> );
</Modal>
<DropdownMenu onOpen={onOpen} onClose={onClose}>
<DropdownMenuItems
items={[
{
title: `${t("Members")}`,
onClick: this.props.onMembers,
visible: !!(group && can.read),
},
{
type: "separator",
},
{
title: `${t("Edit")}`,
onClick: this.onEdit,
visible: !!(group && can.update),
},
{
title: `${t("Delete")}`,
onClick: this.onDelete,
visible: !!(group && can.delete),
},
]}
/>
</DropdownMenu>
</>
);
}
} }
export default withTranslation()<GroupMenu>( export default observer(GroupMenu);
inject("policies")(withRouter(GroupMenu))
);

36
app/menus/MemberMenu.js Normal file
View File

@ -0,0 +1,36 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
type Props = {|
onRemove: () => void,
|};
function MemberMenu({ onRemove }: Props) {
const { t } = useTranslation();
const menu = useMenuState({ modal: true });
return (
<>
<OverflowMenuButton {...menu} />
<ContextMenu {...menu} aria-label={t("Member options")}>
<Template
{...menu}
items={[
{
title: t("Remove"),
onClick: onRemove,
},
]}
/>
</ContextMenu>
</>
);
}
export default observer(MemberMenu);

View File

@ -1,53 +1,32 @@
// @flow // @flow
import { observable } from "mobx"; import { observer } from "mobx-react";
import { observer, inject } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { Trans, withTranslation, type TFunction } from "react-i18next"; import { useTranslation, Trans } from "react-i18next";
import { Redirect } from "react-router-dom"; import { useMenuState, MenuButton } from "reakit/Menu";
import CollectionsStore from "stores/CollectionsStore";
import Document from "models/Document"; import Document from "models/Document";
import { DropdownMenu } from "components/DropdownMenu"; import ContextMenu from "components/ContextMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems"; import Template from "components/ContextMenu/Template";
import useStores from "hooks/useStores";
import { newDocumentUrl } from "utils/routeHelpers"; import { newDocumentUrl } from "utils/routeHelpers";
type Props = { type Props = {
label?: React.Node, label?: (any) => React.Node,
document: Document, document: Document,
collections: CollectionsStore,
t: TFunction,
}; };
@observer function NewChildDocumentMenu({ document, label }: Props) {
class NewChildDocumentMenu extends React.Component<Props> { const menu = useMenuState({ modal: true });
@observable redirectTo: ?string; const { collections } = useStores();
const { t } = useTranslation();
const collection = collections.get(document.collectionId);
const collectionName = collection ? collection.name : t("collection");
componentDidUpdate() { return (
this.redirectTo = undefined; <>
} <MenuButton {...menu}>{label}</MenuButton>
<ContextMenu {...menu} aria-label={t("New child document")}>
handleNewDocument = () => { <Template
const { document } = this.props; {...menu}
this.redirectTo = newDocumentUrl(document.collectionId);
};
handleNewChild = () => {
const { document } = this.props;
this.redirectTo = newDocumentUrl(document.collectionId, {
parentDocumentId: document.id,
});
};
render() {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const { label, document, collections, t } = this.props;
const collection = collections.get(document.collectionId);
const collectionName = collection ? collection.name : t("collection");
return (
<DropdownMenu label={label}>
<DropdownMenuItems
items={[ items={[
{ {
title: ( title: (
@ -57,19 +36,19 @@ class NewChildDocumentMenu extends React.Component<Props> {
</Trans> </Trans>
</span> </span>
), ),
onClick: this.handleNewDocument, to: newDocumentUrl(document.collectionId),
}, },
{ {
title: t("New nested document"), title: t("New nested document"),
onClick: this.handleNewChild, to: newDocumentUrl(document.collectionId, {
parentDocumentId: document.id,
}),
}, },
]} ]}
/> />
</DropdownMenu> </ContextMenu>
); </>
} );
} }
export default withTranslation()<NewChildDocumentMenu>( export default observer(NewChildDocumentMenu);
inject("collections")(NewChildDocumentMenu)
);

View File

@ -1,92 +1,72 @@
// @flow // @flow
import { observable } from "mobx"; import { observer } from "mobx-react";
import { inject, observer } from "mobx-react";
import { PlusIcon } from "outline-icons"; import { PlusIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom"; import { Link } from "react-router-dom";
import { MenuButton, useMenuState } from "reakit/Menu";
import CollectionsStore from "stores/CollectionsStore"; import styled from "styled-components";
import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import Button from "components/Button"; import Button from "components/Button";
import CollectionIcon from "components/CollectionIcon"; import CollectionIcon from "components/CollectionIcon";
import { DropdownMenu, Header } from "components/DropdownMenu"; import ContextMenu from "components/ContextMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems"; import Header from "components/ContextMenu/Header";
import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import useStores from "hooks/useStores";
import { newDocumentUrl } from "utils/routeHelpers"; import { newDocumentUrl } from "utils/routeHelpers";
type Props = { function NewDocumentMenu() {
label?: React.Node, const menu = useMenuState();
documents: DocumentsStore, const { t } = useTranslation();
collections: CollectionsStore, const { collections, policies } = useStores();
policies: PoliciesStore, const singleCollection = collections.orderedData.length === 1;
t: TFunction,
};
@observer if (singleCollection) {
class NewDocumentMenu extends React.Component<Props> { return (
@observable redirectTo: ?string; <Button
as={Link}
componentDidUpdate() { to={newDocumentUrl(collections.orderedData[0].id)}
this.redirectTo = undefined; icon={<PlusIcon />}
small
>
{t("New doc")}
</Button>
);
} }
handleNewDocument = ( return (
collectionId: string, <>
options?: { <MenuButton {...menu}>
parentDocumentId?: string, {(props) => (
template?: boolean, <Button icon={<PlusIcon />} {...props} small>
templateId?: string, {`${t("New doc")}`}
} </Button>
) => { )}
this.redirectTo = newDocumentUrl(collectionId, options); </MenuButton>
}; <ContextMenu {...menu} aria-label={t("New document")}>
onOpen = () => {
const { collections } = this.props;
if (collections.orderedData.length === 1) {
this.handleNewDocument(collections.orderedData[0].id);
}
};
render() {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const { collections, documents, policies, label, t, ...rest } = this.props;
const singleCollection = collections.orderedData.length === 1;
return (
<DropdownMenu
label={
label || (
<Button icon={<PlusIcon />} small>
{t("New doc")}
{singleCollection ? "" : "…"}
</Button>
)
}
onOpen={this.onOpen}
{...rest}
>
<Header>{t("Choose a collection")}</Header> <Header>{t("Choose a collection")}</Header>
<DropdownMenuItems <Template
{...menu}
items={collections.orderedData.map((collection) => ({ items={collections.orderedData.map((collection) => ({
onClick: () => this.handleNewDocument(collection.id), to: newDocumentUrl(collection.id),
disabled: !policies.abilities(collection.id).update, disabled: !policies.abilities(collection.id).update,
title: ( title: (
<> <Flex align="center">
<CollectionIcon collection={collection} /> <CollectionIcon collection={collection} />
&nbsp;{collection.name} <CollectionName>{collection.name}</CollectionName>
</> </Flex>
), ),
}))} }))}
/> />
</DropdownMenu> </ContextMenu>
); </>
} );
} }
export default withTranslation()<NewDocumentMenu>( const CollectionName = styled.div`
inject("collections", "documents", "policies")(NewDocumentMenu) overflow: hidden;
); white-space: nowrap;
text-overflow: ellipsis;
`;
export default observer(NewDocumentMenu);

View File

@ -1,74 +1,59 @@
// @flow // @flow
import { observable } from "mobx"; import { observer } from "mobx-react";
import { inject, observer } from "mobx-react";
import { PlusIcon } from "outline-icons"; import { PlusIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom"; import { useMenuState, MenuButton } from "reakit/Menu";
import styled from "styled-components";
import CollectionsStore from "stores/CollectionsStore";
import PoliciesStore from "stores/PoliciesStore";
import Button from "components/Button"; import Button from "components/Button";
import CollectionIcon from "components/CollectionIcon"; import CollectionIcon from "components/CollectionIcon";
import { DropdownMenu, Header } from "components/DropdownMenu"; import ContextMenu from "components/ContextMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems"; import Header from "components/ContextMenu/Header";
import Template from "components/ContextMenu/Template";
import Flex from "components/Flex";
import useStores from "hooks/useStores";
import { newDocumentUrl } from "utils/routeHelpers"; import { newDocumentUrl } from "utils/routeHelpers";
type Props = { function NewTemplateMenu() {
label?: React.Node, const menu = useMenuState();
collections: CollectionsStore, const { t } = useTranslation();
policies: PoliciesStore, const { collections, policies } = useStores();
t: TFunction,
};
@observer return (
class NewTemplateMenu extends React.Component<Props> { <>
@observable redirectTo: ?string; <MenuButton {...menu}>
{(props) => (
componentDidUpdate() { <Button icon={<PlusIcon />} {...props} small>
this.redirectTo = undefined; {t("New template")}
} </Button>
)}
handleNewDocument = (collectionId: string) => { </MenuButton>
this.redirectTo = newDocumentUrl(collectionId, { <ContextMenu aria-label={t("New template")} {...menu}>
template: true,
});
};
render() {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const { collections, policies, label, t, ...rest } = this.props;
return (
<DropdownMenu
label={
label || (
<Button icon={<PlusIcon />} small>
{t("New template")}
</Button>
)
}
{...rest}
>
<Header>{t("Choose a collection")}</Header> <Header>{t("Choose a collection")}</Header>
<DropdownMenuItems <Template
{...menu}
items={collections.orderedData.map((collection) => ({ items={collections.orderedData.map((collection) => ({
onClick: () => this.handleNewDocument(collection.id), to: newDocumentUrl(collection.id, {
template: true,
}),
disabled: !policies.abilities(collection.id).update, disabled: !policies.abilities(collection.id).update,
title: ( title: (
<> <Flex align="center">
<CollectionIcon collection={collection} /> <CollectionIcon collection={collection} />
&nbsp;{collection.name} <CollectionName>{collection.name}</CollectionName>
</> </Flex>
), ),
}))} }))}
/> />
</DropdownMenu> </ContextMenu>
); </>
} );
} }
export default withTranslation()<NewTemplateMenu>( const CollectionName = styled.div`
inject("collections", "policies")(NewTemplateMenu) overflow: hidden;
); white-space: nowrap;
text-overflow: ellipsis;
`;
export default observer(NewTemplateMenu);

View File

@ -1,68 +1,69 @@
// @flow // @flow
import { inject } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next"; import { useTranslation } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { useMenuState } from "reakit/Menu";
import UiStore from "stores/UiStore";
import Document from "models/Document"; import Document from "models/Document";
import Revision from "models/Revision"; import Revision from "models/Revision";
import ContextMenu from "components/ContextMenu";
import MenuItem from "components/ContextMenu/MenuItem";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Separator from "components/ContextMenu/Separator";
import CopyToClipboard from "components/CopyToClipboard"; import CopyToClipboard from "components/CopyToClipboard";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu"; import useStores from "hooks/useStores";
import { documentHistoryUrl } from "utils/routeHelpers"; import { documentHistoryUrl } from "utils/routeHelpers";
type Props = { type Props = {|
onOpen?: () => void,
onClose: () => void,
history: RouterHistory,
document: Document, document: Document,
revision: Revision, revision: Revision,
iconColor?: string,
className?: string, className?: string,
label: React.Node, |};
ui: UiStore,
t: TFunction,
};
class RevisionMenu extends React.Component<Props> { function RevisionMenu({ document, revision, className, iconColor }: Props) {
handleRestore = async (ev: SyntheticEvent<>) => { const { ui } = useStores();
ev.preventDefault(); const menu = useMenuState({ modal: true });
await this.props.document.restore({ revisionId: this.props.revision.id }); const { t } = useTranslation();
const { t } = this.props; const history = useHistory();
this.props.ui.showToast(t("Document restored"), { type: "success" });
this.props.history.push(this.props.document.url);
};
handleCopy = () => { const handleRestore = React.useCallback(
const { t } = this.props; async (ev: SyntheticEvent<>) => {
this.props.ui.showToast(t("Link copied"), { type: "info" }); ev.preventDefault();
}; await document.restore({ revisionId: revision.id });
ui.showToast(t("Document restored"), { type: "success" });
history.push(document.url);
},
[history, ui, t, document, revision]
);
render() { const handleCopy = React.useCallback(() => {
const { className, label, onOpen, onClose, t } = this.props; ui.showToast(t("Link copied"), { type: "info" });
const url = `${window.location.origin}${documentHistoryUrl( }, [ui, t]);
this.props.document,
this.props.revision.id
)}`;
return ( const url = `${window.location.origin}${documentHistoryUrl(
<DropdownMenu document,
onOpen={onOpen} revision.id
onClose={onClose} )}`;
return (
<>
<OverflowMenuButton
className={className} className={className}
label={label} iconColor={iconColor}
> {...menu}
<DropdownMenuItem onClick={this.handleRestore}> />
<ContextMenu {...menu} aria-label={t("Revision options")}>
<MenuItem {...menu} onClick={handleRestore}>
{t("Restore version")} {t("Restore version")}
</DropdownMenuItem> </MenuItem>
<hr /> <Separator />
<CopyToClipboard text={url} onCopy={this.handleCopy}> <CopyToClipboard text={url} onCopy={handleCopy}>
<DropdownMenuItem>{t("Copy link")}</DropdownMenuItem> <MenuItem {...menu}>{t("Copy link")}</MenuItem>
</CopyToClipboard> </CopyToClipboard>
</DropdownMenu> </ContextMenu>
); </>
} );
} }
export default withTranslation()<RevisionMenu>( export default observer(RevisionMenu);
withRouter(inject("ui")(RevisionMenu))
);

View File

@ -1,75 +1,69 @@
// @flow // @flow
import { observable } from "mobx"; import { observer } from "mobx-react";
import { inject, observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { useMenuState } from "reakit/Menu";
import SharesStore from "stores/SharesStore";
import UiStore from "stores/UiStore";
import Share from "models/Share"; import Share from "models/Share";
import ContextMenu from "components/ContextMenu";
import MenuItem from "components/ContextMenu/MenuItem";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import CopyToClipboard from "components/CopyToClipboard"; import CopyToClipboard from "components/CopyToClipboard";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu"; import useStores from "hooks/useStores";
type Props = { type Props = {
onOpen?: () => void,
onClose: () => void,
shares: SharesStore,
ui: UiStore,
share: Share, share: Share,
t: TFunction,
}; };
@observer function ShareMenu({ share }: Props) {
class ShareMenu extends React.Component<Props> { const menu = useMenuState({ modal: true });
@observable redirectTo: ?string; const { ui, shares } = useStores();
const { t } = useTranslation();
const history = useHistory();
componentDidUpdate() { const handleGoToDocument = React.useCallback(
this.redirectTo = undefined; (ev: SyntheticEvent<>) => {
} ev.preventDefault();
history.push(share.documentUrl);
},
[history, share]
);
handleGoToDocument = (ev: SyntheticEvent<>) => { const handleRevoke = React.useCallback(
ev.preventDefault(); async (ev: SyntheticEvent<>) => {
this.redirectTo = this.props.share.documentUrl; ev.preventDefault();
};
handleRevoke = async (ev: SyntheticEvent<>) => { try {
ev.preventDefault(); await shares.revoke(share);
ui.showToast(t("Share link revoked"), { type: "info" });
} catch (err) {
ui.showToast(err.message, { type: "error" });
}
},
[t, shares, share, ui]
);
try { const handleCopy = React.useCallback(() => {
await this.props.shares.revoke(this.props.share); ui.showToast(t("Share link copied"), { type: "info" });
const { t } = this.props; }, [t, ui]);
this.props.ui.showToast(t("Share link revoked"), { type: "info" });
} catch (err) {
this.props.ui.showToast(err.message, { type: "error" });
}
};
handleCopy = () => { return (
const { t } = this.props; <>
this.props.ui.showToast(t("Share link copied"), { type: "info" }); <OverflowMenuButton {...menu} />
}; <ContextMenu {...menu} aria-label={t("Share options")}>
<CopyToClipboard text={share.url} onCopy={handleCopy}>
render() { <MenuItem {...menu}>{t("Copy link")}</MenuItem>
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const { share, onOpen, onClose, t } = this.props;
return (
<DropdownMenu onOpen={onOpen} onClose={onClose}>
<CopyToClipboard text={share.url} onCopy={this.handleCopy}>
<DropdownMenuItem>{t("Copy link")}</DropdownMenuItem>
</CopyToClipboard> </CopyToClipboard>
<DropdownMenuItem onClick={this.handleGoToDocument}> <MenuItem {...menu} onClick={handleGoToDocument}>
{t("Go to document")} {t("Go to document")}
</DropdownMenuItem> </MenuItem>
<hr /> <hr />
<DropdownMenuItem onClick={this.handleRevoke}> <MenuItem {...menu} onClick={handleRevoke}>
{t("Revoke link")} {t("Revoke link")}
</DropdownMenuItem> </MenuItem>
</DropdownMenu> </ContextMenu>
); </>
} );
} }
export default withTranslation()<ShareMenu>(inject("shares", "ui")(ShareMenu)); export default observer(ShareMenu);

View File

@ -1,42 +1,42 @@
// @flow // @flow
import { observer, inject } from "mobx-react"; import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons"; import { DocumentIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next"; import { useTranslation } from "react-i18next";
import { MenuButton, useMenuState } from "reakit/Menu";
import styled from "styled-components"; import styled from "styled-components";
import DocumentsStore from "stores/DocumentsStore";
import Document from "models/Document"; import Document from "models/Document";
import Button from "components/Button"; import Button from "components/Button";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu"; import ContextMenu from "components/ContextMenu";
import MenuItem from "components/ContextMenu/MenuItem";
import useStores from "hooks/useStores";
type Props = { type Props = {|
document: Document, document: Document,
documents: DocumentsStore, |};
t: TFunction,
};
@observer function TemplatesMenu({ document }: Props) {
class TemplatesMenu extends React.Component<Props> { const menu = useMenuState({ modal: true });
render() { const { documents } = useStores();
const { documents, document, t, ...rest } = this.props; const { t } = useTranslation();
const templates = documents.templatesInCollection(document.collectionId); const templates = documents.templatesInCollection(document.collectionId);
if (!templates.length) { if (!templates.length) {
return null; return null;
} }
return ( return (
<DropdownMenu <>
position="left" <MenuButton {...menu}>
label={ {(props) => (
<Button disclosure neutral> <Button {...props} disclosure neutral>
{t("Templates")} {t("Templates")}
</Button> </Button>
} )}
{...rest} </MenuButton>
> <ContextMenu {...menu} aria-label={t("Templates")}>
{templates.map((template) => ( {templates.map((template) => (
<DropdownMenuItem <MenuItem
key={template.id} key={template.id}
onClick={() => document.updateFromTemplate(template)} onClick={() => document.updateFromTemplate(template)}
> >
@ -48,17 +48,15 @@ class TemplatesMenu extends React.Component<Props> {
{t("By {{ author }}", { author: template.createdBy.name })} {t("By {{ author }}", { author: template.createdBy.name })}
</Author> </Author>
</div> </div>
</DropdownMenuItem> </MenuItem>
))} ))}
</DropdownMenu> </ContextMenu>
); </>
} );
} }
const Author = styled.div` const Author = styled.div`
font-size: 13px; font-size: 13px;
`; `;
export default withTranslation()<TemplatesMenu>( export default observer(TemplatesMenu);
inject("documents")(TemplatesMenu)
);

View File

@ -1,98 +1,110 @@
// @flow // @flow
import { inject, observer } from "mobx-react"; import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next";
import { withTranslation, type TFunction } from "react-i18next"; import { useMenuState } from "reakit/Menu";
import UsersStore from "stores/UsersStore";
import User from "models/User"; import User from "models/User";
import { DropdownMenu } from "components/DropdownMenu"; import ContextMenu from "components/ContextMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems"; import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template";
import useStores from "hooks/useStores";
type Props = { type Props = {|
user: User, user: User,
users: UsersStore, |};
t: TFunction,
};
@observer function UserMenu({ user }: Props) {
class UserMenu extends React.Component<Props> { const { users } = useStores();
handlePromote = (ev: SyntheticEvent<>) => { const { t } = useTranslation();
ev.preventDefault(); const menu = useMenuState({ modal: true });
const { user, users, t } = this.props;
if ( const handlePromote = React.useCallback(
!window.confirm( (ev: SyntheticEvent<>) => {
t( ev.preventDefault();
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.", if (
{ userName: user.name } !window.confirm(
t(
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.",
{ userName: user.name }
)
) )
) ) {
) { return;
return; }
} users.promote(user);
users.promote(user); },
}; [users, user, t]
);
handleDemote = (ev: SyntheticEvent<>) => { const handleDemote = React.useCallback(
ev.preventDefault(); (ev: SyntheticEvent<>) => {
const { user, users, t } = this.props; ev.preventDefault();
if ( if (
!window.confirm( !window.confirm(
t("Are you sure you want to make {{ userName }} a member?", { t("Are you sure you want to make {{ userName }} a member?", {
userName: user.name, userName: user.name,
}) })
)
) {
return;
}
users.demote(user);
};
handleSuspend = (ev: SyntheticEvent<>) => {
ev.preventDefault();
const { user, users, t } = this.props;
if (
!window.confirm(
t(
"Are you sure you want to suspend this account? Suspended users will be prevented from logging in."
) )
) ) {
) { return;
return; }
} users.demote(user);
users.suspend(user); },
}; [users, user, t]
);
handleRevoke = (ev: SyntheticEvent<>) => { const handleSuspend = React.useCallback(
ev.preventDefault(); (ev: SyntheticEvent<>) => {
const { user, users } = this.props; ev.preventDefault();
users.delete(user, { confirmation: true }); if (
}; !window.confirm(
t(
"Are you sure you want to suspend this account? Suspended users will be prevented from logging in."
)
)
) {
return;
}
users.suspend(user);
},
[users, user, t]
);
handleActivate = (ev: SyntheticEvent<>) => { const handleRevoke = React.useCallback(
ev.preventDefault(); (ev: SyntheticEvent<>) => {
const { user, users } = this.props; ev.preventDefault();
users.activate(user); users.delete(user, { confirmation: true });
}; },
[users, user]
);
render() { const handleActivate = React.useCallback(
const { user, t } = this.props; (ev: SyntheticEvent<>) => {
ev.preventDefault();
users.activate(user);
},
[users, user]
);
return ( return (
<DropdownMenu> <>
<DropdownMenuItems <OverflowMenuButton {...menu} />
<ContextMenu {...menu} aria-label={t("User options")}>
<Template
{...menu}
items={[ items={[
{ {
title: t("Make {{ userName }} a member…", { title: t("Make {{ userName }} a member…", {
userName: user.name, userName: user.name,
}), }),
onClick: this.handleDemote, onClick: handleDemote,
visible: user.isAdmin, visible: user.isAdmin,
}, },
{ {
title: t("Make {{ userName }} an admin…", { title: t("Make {{ userName }} an admin…", {
userName: user.name, userName: user.name,
}), }),
onClick: this.handlePromote, onClick: handlePromote,
visible: !user.isAdmin && !user.isSuspended, visible: !user.isAdmin && !user.isSuspended,
}, },
{ {
@ -100,24 +112,24 @@ class UserMenu extends React.Component<Props> {
}, },
{ {
title: `${t("Revoke invite")}`, title: `${t("Revoke invite")}`,
onClick: this.handleRevoke, onClick: handleRevoke,
visible: user.isInvited, visible: user.isInvited,
}, },
{ {
title: t("Activate account"), title: t("Activate account"),
onClick: this.handleActivate, onClick: handleActivate,
visible: !user.isInvited && user.isSuspended, visible: !user.isInvited && user.isSuspended,
}, },
{ {
title: `${t("Suspend account")}`, title: `${t("Suspend account")}`,
onClick: this.handleSuspend, onClick: handleSuspend,
visible: !user.isInvited && !user.isSuspended, visible: !user.isInvited && !user.isSuspended,
}, },
]} ]}
/> />
</DropdownMenu> </ContextMenu>
); </>
} );
} }
export default withTranslation()<UserMenu>(inject("users")(UserMenu)); export default observer(UserMenu);

View File

@ -2,7 +2,7 @@
import { observable } from "mobx"; import { observable } from "mobx";
import { observer, inject } from "mobx-react"; import { observer, inject } from "mobx-react";
import { NewDocumentIcon, PlusIcon, PinIcon } from "outline-icons"; import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { withTranslation, Trans, type TFunction } from "react-i18next"; import { withTranslation, Trans, type TFunction } from "react-i18next";
import { Redirect, Link, Switch, Route, type Match } from "react-router-dom"; import { Redirect, Link, Switch, Route, type Match } from "react-router-dom";
@ -164,7 +164,20 @@ class CollectionScene extends React.Component<Props> {
</> </>
)} )}
<Action> <Action>
<CollectionMenu collection={this.collection} /> <CollectionMenu
collection={this.collection}
placement="bottom-end"
modal={false}
label={(props) => (
<Button
icon={<MoreIcon />}
{...props}
borderOnHover
neutral
small
/>
)}
/>
</Action> </Action>
</Actions> </Actions>
); );

View File

@ -4,9 +4,9 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components"; import styled from "styled-components";
import CollectionGroupMembership from "models/CollectionGroupMembership"; import CollectionGroupMembership from "models/CollectionGroupMembership";
import Group from "models/Group"; import Group from "models/Group";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import GroupListItem from "components/GroupListItem"; import GroupListItem from "components/GroupListItem";
import InputSelect from "components/InputSelect"; import InputSelect from "components/InputSelect";
import CollectionGroupMemberMenu from "menus/CollectionGroupMemberMenu";
type Props = { type Props = {
group: Group, group: Group,
@ -50,15 +50,10 @@ const MemberListItem = ({
labelHidden labelHidden
/> />
<ButtonWrap> <ButtonWrap>
<DropdownMenu> <CollectionGroupMemberMenu
<DropdownMenuItem onClick={openMembersModal}> onMembers={openMembersModal}
{t("Members")} onRemove={onRemove}
</DropdownMenuItem> />
<hr />
<DropdownMenuItem onClick={onRemove}>
{t("Remove")}
</DropdownMenuItem>
</DropdownMenu>
</ButtonWrap> </ButtonWrap>
</> </>
)} )}

View File

@ -7,11 +7,11 @@ import User from "models/User";
import Avatar from "components/Avatar"; import Avatar from "components/Avatar";
import Badge from "components/Badge"; import Badge from "components/Badge";
import Button from "components/Button"; import Button from "components/Button";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import Flex from "components/Flex"; import Flex from "components/Flex";
import InputSelect from "components/InputSelect"; import InputSelect from "components/InputSelect";
import ListItem from "components/List/Item"; import ListItem from "components/List/Item";
import Time from "components/Time"; import Time from "components/Time";
import MemberMenu from "menus/MemberMenu";
type Props = { type Props = {
user: User, user: User,
@ -69,13 +69,7 @@ const MemberListItem = ({
/> />
)} )}
&nbsp;&nbsp; &nbsp;&nbsp;
{canEdit && onRemove && ( {canEdit && onRemove && <MemberMenu onRemove={onRemove} />}
<DropdownMenu>
<DropdownMenuItem onClick={onRemove}>
{t("Remove")}
</DropdownMenuItem>
</DropdownMenu>
)}
{canEdit && onAdd && ( {canEdit && onAdd && (
<Button onClick={onAdd} neutral> <Button onClick={onAdd} neutral>
{t("Add")} {t("Add")}

View File

@ -290,18 +290,18 @@ class Header extends React.Component<Props> {
<Action> <Action>
<NewChildDocumentMenu <NewChildDocumentMenu
document={document} document={document}
label={ label={(props) => (
<Tooltip <Tooltip
tooltip={t("New document")} tooltip={t("New document")}
shortcut="n" shortcut="n"
delay={500} delay={500}
placement="bottom" placement="bottom"
> >
<Button icon={<PlusIcon />} neutral> <Button icon={<PlusIcon />} {...props} neutral>
{t("New doc")} {t("New doc")}
</Button> </Button>
</Tooltip> </Tooltip>
} )}
/> />
</Action> </Action>
)} )}
@ -343,15 +343,16 @@ class Header extends React.Component<Props> {
<DocumentMenu <DocumentMenu
document={document} document={document}
isRevision={isRevision} isRevision={isRevision}
label={ label={(props) => (
<Button <Button
icon={<MoreIcon />} icon={<MoreIcon />}
iconColor="currentColor" iconColor="currentColor"
{...props}
borderOnHover borderOnHover
neutral neutral
small small
/> />
} )}
showToggleEmbeds={canToggleEmbeds} showToggleEmbeds={canToggleEmbeds}
showPrint showPrint
/> />

View File

@ -112,7 +112,6 @@ class AddPeopleToGroup extends React.Component<Props> {
key={item.id} key={item.id}
user={item} user={item}
onAdd={() => this.handleAddUser(item)} onAdd={() => this.handleAddUser(item)}
canEdit
/> />
)} )}
/> />

View File

@ -103,7 +103,6 @@ class GroupMembers extends React.Component<Props> {
<GroupMemberListItem <GroupMemberListItem
key={item.id} key={item.id}
user={item} user={item}
membership={groupMemberships.get(`${item.id}-${group.id}`)}
onRemove={ onRemove={
can.update ? () => this.handleRemoveUser(item) : undefined can.update ? () => this.handleRemoveUser(item) : undefined
} }

View File

@ -1,28 +1,21 @@
// @flow // @flow
import * as React from "react"; import * as React from "react";
import GroupMembership from "models/GroupMembership";
import User from "models/User"; import User from "models/User";
import Avatar from "components/Avatar"; import Avatar from "components/Avatar";
import Badge from "components/Badge"; import Badge from "components/Badge";
import Button from "components/Button"; import Button from "components/Button";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import Flex from "components/Flex"; import Flex from "components/Flex";
import ListItem from "components/List/Item"; import ListItem from "components/List/Item";
import Time from "components/Time"; import Time from "components/Time";
import GroupMemberMenu from "menus/GroupMemberMenu";
type Props = { type Props = {|
user: User, user: User,
groupMembership?: ?GroupMembership,
onAdd?: () => Promise<void>, onAdd?: () => Promise<void>,
onRemove?: () => Promise<void>, onRemove?: () => Promise<void>,
}; |};
const GroupMemberListItem = ({ const GroupMemberListItem = ({ user, onRemove, onAdd }: Props) => {
user,
groupMembership,
onRemove,
onAdd,
}: Props) => {
return ( return (
<ListItem <ListItem
title={user.name} title={user.name}
@ -42,11 +35,7 @@ const GroupMemberListItem = ({
image={<Avatar src={user.avatarUrl} size={40} />} image={<Avatar src={user.avatarUrl} size={40} />}
actions={ actions={
<Flex align="center"> <Flex align="center">
{onRemove && ( {onRemove && <GroupMemberMenu onRemove={onRemove} />}
<DropdownMenu>
<DropdownMenuItem onClick={onRemove}>Remove</DropdownMenuItem>
</DropdownMenu>
)}
{onAdd && ( {onAdd && (
<Button onClick={onAdd} neutral> <Button onClick={onAdd} neutral>
Add Add

View File

@ -361,6 +361,7 @@ class Search extends React.Component<Props> {
highlight={this.query} highlight={this.query}
context={result.context} context={result.context}
showCollection showCollection
showTemplate
/> />
); );
})} })}

View File

@ -1,45 +1,61 @@
// @flow // @flow
import { CheckmarkIcon } from "outline-icons"; import { CheckmarkIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { MenuItem } from "reakit/Menu";
import styled from "styled-components"; import styled from "styled-components";
import Flex from "components/Flex"; import Flex from "components/Flex";
import HelpText from "components/HelpText"; import HelpText from "components/HelpText";
type Props = { type Props = {|
label: string, label: string,
note?: string, note?: string,
onSelect: () => void, onSelect: () => void,
active: boolean, active: boolean,
}; |};
const FilterOption = ({ label, note, onSelect, active }: Props) => { const FilterOption = ({ label, note, onSelect, active, ...rest }: Props) => {
return ( return (
<ListItem active={active}> <MenuItem onClick={active ? undefined : onSelect} {...rest}>
<Anchor onClick={active ? undefined : onSelect}> {(props) => (
<Flex align="center" justify="space-between"> <ListItem>
<span> <Button active={active} {...props}>
{label} <Flex align="center" justify="space-between">
{note && <HelpText small>{note}</HelpText>} <span>
</span> {label}
{active && <Checkmark />} {note && <Description small>{note}</Description>}
</Flex> </span>
</Anchor> {active && <Checkmark />}
</ListItem> </Flex>
</Button>
</ListItem>
)}
</MenuItem>
); );
}; };
const Description = styled(HelpText)`
margin-bottom: 0;
`;
const Checkmark = styled(CheckmarkIcon)` const Checkmark = styled(CheckmarkIcon)`
flex-shrink: 0; flex-shrink: 0;
padding-left: 4px; padding-left: 4px;
fill: ${(props) => props.theme.text}; fill: ${(props) => props.theme.text};
`; `;
const Anchor = styled("a")` const Button = styled.button`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-size: 15px; font-size: 15px;
padding: 4px 8px; padding: 4px 8px;
margin: 0;
border: 0;
background: none;
color: ${(props) => props.theme.text}; color: ${(props) => props.theme.text};
text-align: left;
font-weight: ${(props) => (props.active ? "600" : "normal")};
justify-content: center;
width: 100%;
min-height: 32px; min-height: 32px;
${HelpText} { ${HelpText} {
@ -54,7 +70,7 @@ const Anchor = styled("a")`
const ListItem = styled("li")` const ListItem = styled("li")`
list-style: none; list-style: none;
font-weight: ${(props) => (props.active ? "600" : "normal")}; max-width: 250px;
`; `;
export default FilterOption; export default FilterOption;

View File

@ -1,63 +1,74 @@
// @flow // @flow
import { find } from "lodash"; import { find } from "lodash";
import * as React from "react"; import * as React from "react";
import { useMenuState, MenuButton } from "reakit/Menu";
import styled from "styled-components"; import styled from "styled-components";
import Button, { Inner } from "components/Button"; import Button, { Inner } from "components/Button";
import { DropdownMenu } from "components/DropdownMenu"; import ContextMenu from "components/ContextMenu";
import FilterOption from "./FilterOption"; import FilterOption from "./FilterOption";
type Props = { type TFilterOption = {|
options: { key: string,
key: string, label: string,
label: string, note?: string,
note?: string, |};
}[],
type Props = {|
options: TFilterOption[],
activeKey: ?string, activeKey: ?string,
defaultLabel?: string, defaultLabel?: string,
selectedPrefix?: string, selectedPrefix?: string,
className?: string,
onSelect: (key: ?string) => void, onSelect: (key: ?string) => void,
}; |};
const FilterOptions = ({ const FilterOptions = ({
options, options,
activeKey = "", activeKey = "",
defaultLabel, defaultLabel,
selectedPrefix = "", selectedPrefix = "",
className,
onSelect, onSelect,
}: Props) => { }: Props) => {
const menu = useMenuState();
const selected = find(options, { key: activeKey }) || options[0]; const selected = find(options, { key: activeKey }) || options[0];
const selectedLabel = selected ? `${selectedPrefix} ${selected.label}` : ""; const selectedLabel = selected ? `${selectedPrefix} ${selected.label}` : "";
return ( return (
<DropdownButton label={activeKey ? selectedLabel : defaultLabel}> <SearchFilter>
{({ closeMenu }) => ( <MenuButton {...menu}>
{(props) => (
<StyledButton
{...props}
className={className}
neutral
disclosure
small
>
{activeKey ? selectedLabel : defaultLabel}
</StyledButton>
)}
</MenuButton>
<ContextMenu aria-label={defaultLabel} {...menu}>
<List> <List>
{options.map((option) => ( {options.map((option) => (
<FilterOption <FilterOption
key={option.key} key={option.key}
onSelect={() => { onSelect={() => {
onSelect(option.key); onSelect(option.key);
closeMenu(); menu.hide();
}} }}
active={option.key === activeKey} active={option.key === activeKey}
{...option} {...option}
{...menu}
/> />
))} ))}
</List> </List>
)} </ContextMenu>
</DropdownButton> </SearchFilter>
); );
}; };
const Content = styled("div")`
padding: 0 8px;
width: 250px;
p {
margin-bottom: 0;
}
`;
const StyledButton = styled(Button)` const StyledButton = styled(Button)`
box-shadow: none; box-shadow: none;
text-transform: none; text-transform: none;
@ -73,32 +84,14 @@ const StyledButton = styled(Button)`
} }
`; `;
const SearchFilter = (props) => { const SearchFilter = styled.div`
return (
<DropdownMenu
className={props.className}
label={
<StyledButton neutral disclosure small>
{props.label}
</StyledButton>
}
position="right"
>
{({ closePortal }) => (
<Content>{props.children({ closeMenu: closePortal })}</Content>
)}
</DropdownMenu>
);
};
const DropdownButton = styled(SearchFilter)`
margin-right: 8px; margin-right: 8px;
`; `;
const List = styled("ol")` const List = styled("ol")`
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0 8px;
`; `;
export default FilterOptions; export default FilterOptions;

View File

@ -1,7 +1,7 @@
// @flow // @flow
import Compressor from "compressorjs"; import Compressor from "compressorjs";
type Options = Omit<Compressor.Options, "success" | "error">; type Options = { maxWidth?: number, maxHeight?: number };
export const compressImage = async ( export const compressImage = async (
file: File | Blob, file: File | Blob,

View File

@ -152,6 +152,7 @@
"react-virtualized-auto-sizer": "^1.0.2", "react-virtualized-auto-sizer": "^1.0.2",
"react-waypoint": "^9.0.2", "react-waypoint": "^9.0.2",
"react-window": "^1.8.6", "react-window": "^1.8.6",
"reakit": "^1.3.4",
"rich-markdown-editor": "^11.0.11", "rich-markdown-editor": "^11.0.11",
"semver": "^7.3.2", "semver": "^7.3.2",
"sequelize": "^6.3.4", "sequelize": "^6.3.4",

View File

@ -8,6 +8,7 @@
"Drafts": "Drafts", "Drafts": "Drafts",
"Templates": "Templates", "Templates": "Templates",
"Deleted Collection": "Deleted Collection", "Deleted Collection": "Deleted Collection",
"Submenu": "Submenu",
"New": "New", "New": "New",
"Only visible to you": "Only visible to you", "Only visible to you": "Only visible to you",
"Draft": "Draft", "Draft": "Draft",
@ -22,7 +23,6 @@
"Never viewed": "Never viewed", "Never viewed": "Never viewed",
"Viewed": "Viewed", "Viewed": "Viewed",
"in": "in", "in": "in",
"More options": "More options",
"Insert column after": "Insert column after", "Insert column after": "Insert column after",
"Insert column before": "Insert column before", "Insert column before": "Insert column before",
"Insert row after": "Insert row after", "Insert row after": "Insert row after",
@ -76,6 +76,7 @@
"Warning": "Warning", "Warning": "Warning",
"Warning notice": "Warning notice", "Warning notice": "Warning notice",
"Icon": "Icon", "Icon": "Icon",
"Choose icon": "Choose icon",
"Loading": "Loading", "Loading": "Loading",
"Search": "Search", "Search": "Search",
"Outline is available in your language {{optionLabel}}, would you like to change?": "Outline is available in your language {{optionLabel}}, would you like to change?", "Outline is available in your language {{optionLabel}}, would you like to change?": "Outline is available in your language {{optionLabel}}, would you like to change?",
@ -104,23 +105,29 @@
"Export Data": "Export Data", "Export Data": "Export Data",
"Integrations": "Integrations", "Integrations": "Integrations",
"Installation": "Installation", "Installation": "Installation",
"Appearance": "Appearance",
"System": "System",
"Light": "Light",
"Dark": "Dark",
"Account": "Account",
"Settings": "Settings", "Settings": "Settings",
"API documentation": "API documentation", "API documentation": "API documentation",
"Changelog": "Changelog", "Changelog": "Changelog",
"Send us feedback": "Send us feedback", "Send us feedback": "Send us feedback",
"Report a bug": "Report a bug", "Report a bug": "Report a bug",
"Appearance": "Appearance",
"System": "System",
"Light": "Light",
"Dark": "Dark",
"Log out": "Log out", "Log out": "Log out",
"Collection permissions": "Collection permissions", "Path to document": "Path to document",
"Group member options": "Group member options",
"Members": "Members",
"Remove": "Remove",
"Collection": "Collection",
"New document": "New document", "New document": "New document",
"Import document": "Import document", "Import document": "Import document",
"Edit": "Edit", "Edit": "Edit",
"Permissions": "Permissions", "Permissions": "Permissions",
"Export": "Export", "Export": "Export",
"Delete": "Delete", "Delete": "Delete",
"Collection permissions": "Collection permissions",
"Edit collection": "Edit collection", "Edit collection": "Edit collection",
"Delete collection": "Delete collection", "Delete collection": "Delete collection",
"Export collection": "Export collection", "Export collection": "Export collection",
@ -131,6 +138,7 @@
"Document archived": "Document archived", "Document archived": "Document archived",
"Document restored": "Document restored", "Document restored": "Document restored",
"Document unpublished": "Document unpublished", "Document unpublished": "Document unpublished",
"Document options": "Document options",
"Restore": "Restore", "Restore": "Restore",
"Choose a collection": "Choose a collection", "Choose a collection": "Choose a collection",
"Unpin": "Unpin", "Unpin": "Unpin",
@ -152,21 +160,26 @@
"Share document": "Share document", "Share document": "Share document",
"Edit group": "Edit group", "Edit group": "Edit group",
"Delete group": "Delete group", "Delete group": "Delete group",
"Members": "Members", "Group options": "Group options",
"Member options": "Member options",
"collection": "collection", "collection": "collection",
"New child document": "New child document",
"New document in <1>{{collectionName}}</1>": "New document in <1>{{collectionName}}</1>", "New document in <1>{{collectionName}}</1>": "New document in <1>{{collectionName}}</1>",
"New template": "New template", "New template": "New template",
"Link copied": "Link copied", "Link copied": "Link copied",
"Revision options": "Revision options",
"Restore version": "Restore version", "Restore version": "Restore version",
"Copy link": "Copy link", "Copy link": "Copy link",
"Share link revoked": "Share link revoked", "Share link revoked": "Share link revoked",
"Share link copied": "Share link copied", "Share link copied": "Share link copied",
"Share options": "Share options",
"Go to document": "Go to document", "Go to document": "Go to document",
"Revoke link": "Revoke link", "Revoke link": "Revoke link",
"By {{ author }}": "By {{ author }}", "By {{ author }}": "By {{ author }}",
"Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.", "Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.": "Are you sure you want to make {{ userName }} an admin? Admins can modify team and billing information.",
"Are you sure you want to make {{ userName }} a member?": "Are you sure you want to make {{ userName }} a member?", "Are you sure you want to make {{ userName }} a member?": "Are you sure you want to make {{ userName }} a member?",
"Are you sure you want to suspend this account? Suspended users will be prevented from logging in.": "Are you sure you want to suspend this account? Suspended users will be prevented from logging in.", "Are you sure you want to suspend this account? Suspended users will be prevented from logging in.": "Are you sure you want to suspend this account? Suspended users will be prevented from logging in.",
"User options": "User options",
"Make {{ userName }} a member…": "Make {{ userName }} a member…", "Make {{ userName }} a member…": "Make {{ userName }} a member…",
"Make {{ userName }} an admin…": "Make {{ userName }} an admin…", "Make {{ userName }} an admin…": "Make {{ userName }} an admin…",
"Revoke invite": "Revoke invite", "Revoke invite": "Revoke invite",
@ -212,7 +225,6 @@
"No people left to add": "No people left to add", "No people left to add": "No people left to add",
"Read only": "Read only", "Read only": "Read only",
"Read & Edit": "Read & Edit", "Read & Edit": "Read & Edit",
"Remove": "Remove",
"Active <1></1> ago": "Active <1></1> ago", "Active <1></1> ago": "Active <1></1> ago",
"Never signed in": "Never signed in", "Never signed in": "Never signed in",
"Invited": "Invited", "Invited": "Invited",

View File

@ -1365,6 +1365,11 @@
dependencies: dependencies:
"@types/node" ">= 8" "@types/node" ">= 8"
"@popperjs/core@^2.5.4":
version "2.6.0"
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.6.0.tgz#f022195afdfc942e088ee2101285a1d31c7d727f"
integrity sha512-cPqjjzuFWNK3BSKLm0abspP0sp/IGOli4p5I5fKFAzdS8fvjdOwDCfZqAaIiXd9lPkOWi3SUUfZof3hEb7J/uw==
"@react-dnd/asap@^4.0.0": "@react-dnd/asap@^4.0.0":
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-4.0.0.tgz#b300eeed83e9801f51bd66b0337c9a6f04548651" resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-4.0.0.tgz#b300eeed83e9801f51bd66b0337c9a6f04548651"
@ -2604,6 +2609,11 @@ bn.js@^5.0.0, bn.js@^5.1.1:
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.1.3.tgz#beca005408f642ebebea80b042b4d18d2ac0ee6b" resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.1.3.tgz#beca005408f642ebebea80b042b4d18d2ac0ee6b"
integrity sha512-GkTiFpjFtUzU9CbMeJ5iazkCzGL3jrhzerzZIuqLABjbwRaFt33I9tUdSNryIptM+RxDet6OKm2WnLXzW51KsQ== integrity sha512-GkTiFpjFtUzU9CbMeJ5iazkCzGL3jrhzerzZIuqLABjbwRaFt33I9tUdSNryIptM+RxDet6OKm2WnLXzW51KsQ==
body-scroll-lock@^3.1.5:
version "3.1.5"
resolved "https://registry.yarnpkg.com/body-scroll-lock/-/body-scroll-lock-3.1.5.tgz#c1392d9217ed2c3e237fee1e910f6cdd80b7aaec"
integrity sha512-Yi1Xaml0EvNA0OYWxXiYNqY24AfWkbA6w5vxE7GWxtKfzIbZM+Qw+aSmkgsbWzbHiy/RCSkUZBplVxTA+E4jJg==
boolbase@~1.0.0: boolbase@~1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
@ -10078,6 +10088,36 @@ readdirp@~3.5.0:
dependencies: dependencies:
picomatch "^2.2.1" picomatch "^2.2.1"
reakit-system@^0.15.1:
version "0.15.1"
resolved "https://registry.yarnpkg.com/reakit-system/-/reakit-system-0.15.1.tgz#bf5cc7a03f60a817373bc9cbb4a689c1f4100547"
integrity sha512-PkqfAyEohtcEu/gUvKriCv42NywDtUgvocEN3147BI45dOFAB89nrT7wRIbIcKJiUT598F+JlPXAZZVLWhc1Kg==
dependencies:
reakit-utils "^0.15.1"
reakit-utils@^0.15.1:
version "0.15.1"
resolved "https://registry.yarnpkg.com/reakit-utils/-/reakit-utils-0.15.1.tgz#797f0a43f6a1dbc22d161224d5d2272e287dbfe3"
integrity sha512-6cZgKGvOkAMQgkwU9jdYbHfkuIN1Pr+vwcB19plLvcTfVN0Or10JhIuj9X+JaPZyI7ydqTDFaKNdUcDP69o/+Q==
reakit-warning@^0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/reakit-warning/-/reakit-warning-0.6.1.tgz#dba33bb8866aebe30e67ac433ead707d16d38a36"
integrity sha512-poFUV0EyxB+CcV9uTNBAFmcgsnR2DzAbOTkld4Ul+QOKSeEHZB3b3+MoZQgcYHmbvG19Na1uWaM7ES+/Eyr8tQ==
dependencies:
reakit-utils "^0.15.1"
reakit@^1.3.4:
version "1.3.4"
resolved "https://registry.yarnpkg.com/reakit/-/reakit-1.3.4.tgz#1c83614130fbcee624ba510547db79c90b3435a4"
integrity sha512-aLR0GBOc9vELTk4PKK/zndBbIs4RSw4B7GSGY6yoMHQ/vna0iLNd5BMhjtXspD/B7hhkYERlT6di8pIyD3HSPw==
dependencies:
"@popperjs/core" "^2.5.4"
body-scroll-lock "^3.1.5"
reakit-system "^0.15.1"
reakit-utils "^0.15.1"
reakit-warning "^0.6.1"
rechoir@^0.6.2: rechoir@^0.6.2:
version "0.6.2" version "0.6.2"
resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384"