Merge main

This commit is contained in:
Tom Moor
2021-02-07 12:58:17 -08:00
233 changed files with 7243 additions and 4147 deletions

View File

@@ -1,6 +1,5 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import {
ArchiveIcon,
HomeIcon,
@@ -10,14 +9,11 @@ import {
ShapesIcon,
TrashIcon,
PlusIcon,
SettingsIcon,
} from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import CollectionNew from "scenes/CollectionNew";
import Invite from "scenes/Invite";
import Flex from "components/Flex";
@@ -29,175 +25,179 @@ import Collections from "./components/Collections";
import HeaderBlock from "./components/HeaderBlock";
import Section from "./components/Section";
import SidebarLink from "./components/SidebarLink";
import useStores from "hooks/useStores";
import AccountMenu from "menus/AccountMenu";
type Props = {
auth: AuthStore,
documents: DocumentsStore,
policies: PoliciesStore,
t: TFunction,
};
function MainSidebar() {
const { t } = useTranslation();
const { policies, auth, documents } = useStores();
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
const [
createCollectionModalOpen,
setCreateCollectionModalOpen,
] = React.useState(false);
@observer
class MainSidebar extends React.Component<Props> {
@observable inviteModalOpen = false;
@observable createCollectionModalOpen = false;
React.useEffect(() => {
documents.fetchDrafts();
documents.fetchTemplates();
}, [documents]);
componentDidMount() {
this.props.documents.fetchDrafts();
this.props.documents.fetchTemplates();
}
const handleCreateCollectionModalOpen = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
setCreateCollectionModalOpen(true);
},
[]
);
handleCreateCollectionModalOpen = (ev: SyntheticEvent<>) => {
const handleCreateCollectionModalClose = React.useCallback(() => {
setCreateCollectionModalOpen(false);
}, []);
const handleInviteModalOpen = React.useCallback((ev: SyntheticEvent<>) => {
ev.preventDefault();
this.createCollectionModalOpen = true;
};
setInviteModalOpen(true);
}, []);
handleCreateCollectionModalClose = (ev: SyntheticEvent<>) => {
this.createCollectionModalOpen = false;
};
const handleInviteModalClose = React.useCallback(() => {
setInviteModalOpen(false);
}, []);
handleInviteModalOpen = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.inviteModalOpen = true;
};
const { user, team } = auth;
if (!user || !team) return null;
handleInviteModalClose = () => {
this.inviteModalOpen = false;
};
const can = policies.abilities(team.id);
render() {
const { auth, documents, policies, t } = this.props;
const { user, team } = auth;
if (!user || !team) return null;
const can = policies.abilities(team.id);
return (
<Sidebar>
<AccountMenu
label={
<HeaderBlock
subheading={user.name}
teamName={team.name}
logoUrl={team.avatarUrl}
showDisclosure
return (
<Sidebar>
<AccountMenu>
{(props) => (
<HeaderBlock
{...props}
subheading={user.name}
teamName={team.name}
logoUrl={team.avatarUrl}
showDisclosure
/>
)}
</AccountMenu>
<Flex auto column>
<Scrollable shadow>
<Section>
<SidebarLink
to="/home"
icon={<HomeIcon color="currentColor" />}
exact={false}
label={t("Home")}
/>
}
/>
<Flex auto column>
<Scrollable shadow>
<Section>
<SidebarLink
to={{
pathname: "/search",
state: { fromMenu: true },
}}
icon={<SearchIcon color="currentColor" />}
label={t("Search")}
exact={false}
/>
<SidebarLink
to="/starred"
icon={<StarredIcon color="currentColor" />}
exact={false}
label={t("Starred")}
/>
<SidebarLink
to="/templates"
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
active={documents.active ? documents.active.template : undefined}
/>
<SidebarLink
to="/drafts"
icon={<EditIcon color="currentColor" />}
label={
<Drafts align="center">
{t("Drafts")}
{documents.totalDrafts > 0 && (
<Bubble count={documents.totalDrafts} />
)}
</Drafts>
}
active={
documents.active
? !documents.active.publishedAt &&
!documents.active.isDeleted &&
!documents.active.isTemplate
: undefined
}
/>
</Section>
<Section>
<Collections onCreateCollection={handleCreateCollectionModalOpen} />
</Section>
</Scrollable>
<Secondary>
<Section>
<SidebarLink
to="/archive"
icon={<ArchiveIcon color="currentColor" />}
exact={false}
label={t("Archive")}
active={
documents.active
? documents.active.isArchived && !documents.active.isDeleted
: undefined
}
/>
<SidebarLink
to="/trash"
icon={<TrashIcon color="currentColor" />}
exact={false}
label={t("Trash")}
active={documents.active ? documents.active.isDeleted : undefined}
/>
<SidebarLink
to="/settings"
icon={<SettingsIcon color="currentColor" />}
exact={false}
label={t("Settings")}
/>
{can.invite && (
<SidebarLink
to="/home"
icon={<HomeIcon color="currentColor" />}
exact={false}
label={t("Home")}
to="/settings/people"
onClick={handleInviteModalOpen}
icon={<PlusIcon color="currentColor" />}
label={`${t("Invite people")}`}
/>
<SidebarLink
to={{
pathname: "/search",
state: { fromMenu: true },
}}
icon={<SearchIcon color="currentColor" />}
label={t("Search")}
exact={false}
/>
<SidebarLink
to="/starred"
icon={<StarredIcon color="currentColor" />}
exact={false}
label={t("Starred")}
/>
<SidebarLink
to="/templates"
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
active={
documents.active ? documents.active.template : undefined
}
/>
<SidebarLink
to="/drafts"
icon={<EditIcon color="currentColor" />}
label={
<Drafts align="center">
{t("Drafts")}
{documents.totalDrafts > 0 && (
<Bubble count={documents.totalDrafts} />
)}
</Drafts>
}
active={
documents.active
? !documents.active.publishedAt &&
!documents.active.isDeleted &&
!documents.active.isTemplate
: undefined
}
/>
</Section>
<Section>
<Collections
onCreateCollection={this.handleCreateCollectionModalOpen}
/>
</Section>
<Section>
<SidebarLink
to="/archive"
icon={<ArchiveIcon color="currentColor" />}
exact={false}
label={t("Archive")}
active={
documents.active
? documents.active.isArchived && !documents.active.isDeleted
: undefined
}
/>
<SidebarLink
to="/trash"
icon={<TrashIcon color="currentColor" />}
exact={false}
label={t("Trash")}
active={
documents.active ? documents.active.isDeleted : undefined
}
/>
{can.invite && (
<SidebarLink
to="/settings/people"
onClick={this.handleInviteModalOpen}
icon={<PlusIcon color="currentColor" />}
label={t("Invite people…")}
/>
)}
</Section>
</Scrollable>
</Flex>
<Modal
title={t("Invite people")}
onRequestClose={this.handleInviteModalClose}
isOpen={this.inviteModalOpen}
>
<Invite onSubmit={this.handleInviteModalClose} />
</Modal>
<Modal
title={t("Create a collection")}
onRequestClose={this.handleCreateCollectionModalClose}
isOpen={this.createCollectionModalOpen}
>
<CollectionNew onSubmit={this.handleCreateCollectionModalClose} />
</Modal>
</Sidebar>
);
}
)}
</Section>
</Secondary>
</Flex>
<Modal
title={t("Invite people")}
onRequestClose={handleInviteModalClose}
isOpen={inviteModalOpen}
>
<Invite onSubmit={handleInviteModalClose} />
</Modal>
<Modal
title={t("Create a collection")}
onRequestClose={handleCreateCollectionModalClose}
isOpen={createCollectionModalOpen}
>
<CollectionNew onSubmit={handleCreateCollectionModalClose} />
</Modal>
</Sidebar>
);
}
const Secondary = styled.div`
overflow-x: hidden;
flex-shrink: 0;
`;
const Drafts = styled(Flex)`
height: 24px;
`;
export default withTranslation()<MainSidebar>(
inject("documents", "policies", "auth")(MainSidebar)
);
export default observer(MainSidebar);

View File

@@ -1,5 +1,5 @@
// @flow
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import {
DocumentIcon,
EmailIcon,
@@ -13,11 +13,9 @@ import {
ExpandedIcon,
} from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import type { RouterHistory } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import PoliciesStore from "stores/PoliciesStore";
import Flex from "components/Flex";
import Scrollable from "components/Scrollable";
@@ -30,131 +28,123 @@ import Version from "./components/Version";
import SlackIcon from "./icons/Slack";
import ZapierIcon from "./icons/Zapier";
import env from "env";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
const isHosted = env.DEPLOYMENT === "hosted";
type Props = {
history: RouterHistory,
policies: PoliciesStore,
auth: AuthStore,
t: TFunction,
};
function SettingsSidebar() {
const { t } = useTranslation();
const history = useHistory();
const team = useCurrentTeam();
const { policies } = useStores();
const can = policies.abilities(team.id);
@observer
class SettingsSidebar extends React.Component<Props> {
returnToDashboard = () => {
this.props.history.push("/home");
};
const returnToDashboard = React.useCallback(() => {
history.push("/home");
}, [history]);
render() {
const { policies, t, auth } = this.props;
const { team } = auth;
if (!team) return null;
return (
<Sidebar>
<HeaderBlock
subheading={
<ReturnToApp align="center">
<BackIcon color="currentColor" /> {t("Return to App")}
</ReturnToApp>
}
teamName={team.name}
logoUrl={team.avatarUrl}
onClick={returnToDashboard}
/>
const can = policies.abilities(team.id);
return (
<Sidebar>
<HeaderBlock
subheading={
<ReturnToApp align="center">
<BackIcon color="currentColor" /> {t("Return to App")}
</ReturnToApp>
}
teamName={team.name}
logoUrl={team.avatarUrl}
onClick={this.returnToDashboard}
/>
<Flex auto column>
<Scrollable shadow>
<Section>
<Header>Account</Header>
<SidebarLink
to="/settings"
icon={<ProfileIcon color="currentColor" />}
label={t("Profile")}
/>
<SidebarLink
to="/settings/notifications"
icon={<EmailIcon color="currentColor" />}
label={t("Notifications")}
/>
<SidebarLink
to="/settings/tokens"
icon={<CodeIcon color="currentColor" />}
label={t("API Tokens")}
/>
</Section>
<Section>
<Header>Team</Header>
{can.update && (
<SidebarLink
to="/settings/details"
icon={<TeamIcon color="currentColor" />}
label={t("Details")}
/>
)}
{can.update && (
<SidebarLink
to="/settings/security"
icon={<PadlockIcon color="currentColor" />}
label={t("Security")}
/>
)}
<SidebarLink
to="/settings/people"
icon={<UserIcon color="currentColor" />}
exact={false}
label={t("People")}
/>
<SidebarLink
to="/settings/groups"
icon={<GroupIcon color="currentColor" />}
exact={false}
label={t("Groups")}
/>
<SidebarLink
to="/settings/shares"
icon={<LinkIcon color="currentColor" />}
label={t("Share Links")}
/>
{can.export && (
<SidebarLink
to="/settings/import-export"
icon={<DocumentIcon color="currentColor" />}
label={t("Import / Export")}
/>
)}
</Section>
<Flex auto column>
<Scrollable topShadow>
<Section>
<Header>{t("Account")}</Header>
<SidebarLink
to="/settings"
icon={<ProfileIcon color="currentColor" />}
label={t("Profile")}
/>
<SidebarLink
to="/settings/notifications"
icon={<EmailIcon color="currentColor" />}
label={t("Notifications")}
/>
<SidebarLink
to="/settings/tokens"
icon={<CodeIcon color="currentColor" />}
label={t("API Tokens")}
/>
</Section>
<Section>
<Header>{t("Team")}</Header>
{can.update && (
<Section>
<Header>{t("Integrations")}</Header>
<SidebarLink
to="/settings/details"
icon={<TeamIcon color="currentColor" />}
label={t("Details")}
/>
)}
{can.update && (
<SidebarLink
to="/settings/security"
icon={<PadlockIcon color="currentColor" />}
label={t("Security")}
/>
)}
<SidebarLink
to="/settings/people"
icon={<UserIcon color="currentColor" />}
exact={false}
label={t("People")}
/>
<SidebarLink
to="/settings/groups"
icon={<GroupIcon color="currentColor" />}
exact={false}
label={t("Groups")}
/>
<SidebarLink
to="/settings/shares"
icon={<LinkIcon color="currentColor" />}
label={t("Share Links")}
/>
{can.export && (
<SidebarLink
to="/settings/import-export"
icon={<DocumentIcon color="currentColor" />}
label={t("Import / Export")}
/>
)}
</Section>
{can.update && (
<Section>
<Header>{t("Integrations")}</Header>
<SidebarLink
to="/settings/integrations/slack"
icon={<SlackIcon color="currentColor" />}
label="Slack"
/>
{isHosted && (
<SidebarLink
to="/settings/integrations/slack"
icon={<SlackIcon color="currentColor" />}
label="Slack"
to="/settings/integrations/zapier"
icon={<ZapierIcon color="currentColor" />}
label="Zapier"
/>
{isHosted && (
<SidebarLink
to="/settings/integrations/zapier"
icon={<ZapierIcon color="currentColor" />}
label="Zapier"
/>
)}
</Section>
)}
{can.update && !isHosted && (
<Section>
<Header>{t("Installation")}</Header>
<Version />
</Section>
)}
</Scrollable>
</Flex>
</Sidebar>
);
}
)}
</Section>
)}
{can.update && !isHosted && (
<Section>
<Header>{t("Installation")}</Header>
<Version />
</Section>
)}
</Scrollable>
</Flex>
</Sidebar>
);
}
const BackIcon = styled(ExpandedIcon)`
@@ -166,6 +156,4 @@ const ReturnToApp = styled(Flex)`
height: 16px;
`;
export default withTranslation()<SettingsSidebar>(
inject("auth", "policies")(SettingsSidebar)
);
export default observer(SettingsSidebar);

View File

@@ -1,55 +1,171 @@
// @flow
import { observer } from "mobx-react";
import { CloseIcon, MenuIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import { withRouter } from "react-router-dom";
import type { Location } from "react-router-dom";
import styled from "styled-components";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import Flex from "components/Flex";
import CollapseToggle, { Button } from "./components/CollapseToggle";
import CollapseToggle, {
Button as CollapseButton,
} from "./components/CollapseToggle";
import ResizeBorder from "./components/ResizeBorder";
import ResizeHandle from "./components/ResizeHandle";
import usePrevious from "hooks/usePrevious";
import useStores from "hooks/useStores";
let firstRender = true;
let BOUNCE_ANIMATION_MS = 250;
type Props = {
children: React.Node,
location: Location,
};
const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
const [offset, setOffset] = React.useState(0);
const [isAnimating, setAnimating] = React.useState(false);
const [isResizing, setResizing] = React.useState(false);
const isSmallerThanMinimum = width < minWidth;
const handleDrag = React.useCallback(
(event: MouseEvent) => {
// suppresses text selection
event.preventDefault();
// this is simple because the sidebar is always against the left edge
const width = Math.min(event.pageX - offset, maxWidth);
setWidth(width);
},
[offset, maxWidth, setWidth]
);
const handleStopDrag = React.useCallback(() => {
setResizing(false);
if (isSmallerThanMinimum) {
setWidth(minWidth);
setAnimating(true);
} else {
setWidth(width);
}
}, [isSmallerThanMinimum, minWidth, width, setWidth]);
const handleStartDrag = React.useCallback(
(event) => {
setOffset(event.pageX - width);
setResizing(true);
setAnimating(false);
},
[width]
);
React.useEffect(() => {
if (isAnimating) {
setTimeout(() => setAnimating(false), BOUNCE_ANIMATION_MS);
}
}, [isAnimating]);
React.useEffect(() => {
if (isResizing) {
document.addEventListener("mousemove", handleDrag);
document.addEventListener("mouseup", handleStopDrag);
}
return () => {
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", handleStopDrag);
};
}, [isResizing, handleDrag, handleStopDrag]);
return { isAnimating, isSmallerThanMinimum, isResizing, handleStartDrag };
};
function Sidebar({ location, children }: Props) {
const theme = useTheme();
const { t } = useTranslation();
const { ui } = useStores();
const previousLocation = usePrevious(location);
const width = ui.sidebarWidth;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const collapsed = ui.editMode || ui.sidebarCollapsed;
const {
isAnimating,
isSmallerThanMinimum,
isResizing,
handleStartDrag,
} = useResize({
width,
minWidth,
maxWidth,
setWidth: ui.setSidebarWidth,
});
const handleReset = React.useCallback(() => {
ui.setSidebarWidth(theme.sidebarWidth);
}, [ui, theme.sidebarWidth]);
React.useEffect(() => {
ui.setSidebarResizing(isResizing);
}, [ui, isResizing]);
React.useEffect(() => {
if (location !== previousLocation) {
ui.hideMobileSidebar();
}
}, [ui, location, previousLocation]);
const style = React.useMemo(
() => ({
width: `${width}px`,
left:
collapsed && !ui.mobileSidebarVisible
? `${-width + theme.sidebarCollapsedWidth}px`
: 0,
}),
[width, collapsed, theme.sidebarCollapsedWidth, ui.mobileSidebarVisible]
);
const content = (
<Container
mobileSidebarVisible={ui.mobileSidebarVisible}
collapsed={ui.editMode || ui.sidebarCollapsed}
style={style}
$sidebarWidth={ui.sidebarWidth}
$isAnimating={isAnimating}
$isSmallerThanMinimum={isSmallerThanMinimum}
$mobileSidebarVisible={ui.mobileSidebarVisible}
$collapsed={collapsed}
column
>
<CollapseToggle
collapsed={ui.sidebarCollapsed}
onClick={ui.toggleCollapsedSidebar}
/>
<Toggle
onClick={ui.toggleMobileSidebar}
mobileSidebarVisible={ui.mobileSidebarVisible}
>
{ui.mobileSidebarVisible ? (
<CloseIcon size={32} />
) : (
<MenuIcon size={32} />
)}
</Toggle>
{!isResizing && (
<CollapseToggle
collapsed={ui.sidebarCollapsed}
onClick={ui.toggleCollapsedSidebar}
/>
)}
{ui.mobileSidebarVisible && (
<Portal>
<Fade>
<Background onClick={ui.toggleMobileSidebar} />
</Fade>
</Portal>
)}
{children}
{!ui.sidebarCollapsed && (
<ResizeBorder
onMouseDown={handleStartDrag}
onDoubleClick={handleReset}
$isResizing={isResizing}
>
<ResizeHandle aria-label={t("Resize sidebar")} />
</ResizeBorder>
)}
</Container>
);
@@ -62,82 +178,67 @@ function Sidebar({ location, children }: Props) {
return content;
}
const Background = styled.a`
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
cursor: default;
z-index: ${(props) => props.theme.depths.sidebar - 1};
background: rgba(0, 0, 0, 0.5);
`;
const Container = styled(Flex)`
position: fixed;
top: 0;
bottom: 0;
width: 100%;
background: ${(props) => props.theme.sidebarBackground};
transition: box-shadow, 100ms, ease-in-out, left 100ms ease-out,
${(props) => props.theme.backgroundTransition};
margin-left: ${(props) => (props.mobileSidebarVisible ? 0 : "-100%")};
transition: box-shadow, 100ms, ease-in-out, margin-left 100ms ease-out,
left 100ms ease-out,
${(props) => props.theme.backgroundTransition}
${(props) =>
props.$isAnimating ? `,width ${BOUNCE_ANIMATION_MS}ms ease-out` : ""};
margin-left: ${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")};
z-index: ${(props) => props.theme.depths.sidebar};
max-width: 70%;
min-width: 280px;
@media print {
display: none;
left: 0;
}
&:before,
&:after {
content: "";
background: ${(props) => props.theme.sidebarBackground};
position: absolute;
top: -50vh;
left: 0;
width: 100%;
height: 50vh;
}
&:after {
top: auto;
bottom: -50vh;
}
${breakpoint("tablet")`
left: ${(props) =>
props.collapsed
? `calc(-${props.theme.sidebarWidth} + ${props.theme.sidebarCollapsedWidth})`
: 0};
width: ${(props) => props.theme.sidebarWidth};
margin: 0;
z-index: 3;
min-width: 0;
&:hover,
&:focus-within {
left: 0;
left: 0 !important;
box-shadow: ${(props) =>
props.collapsed ? "rgba(0, 0, 0, 0.2) 1px 0 4px" : "none"};
props.$collapsed
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
: props.$isSmallerThanMinimum
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
: "none"};
& ${Button} {
& ${CollapseButton} {
opacity: .75;
}
& ${Button}:hover {
& ${CollapseButton}:hover {
opacity: 1;
}
}
&:not(:hover):not(:focus-within) > div {
opacity: ${(props) => (props.collapsed ? "0" : "1")};
opacity: ${(props) => (props.$collapsed ? "0" : "1")};
transition: opacity 100ms ease-in-out;
}
`};
`;
const Toggle = styled.a`
display: flex;
align-items: center;
position: fixed;
top: 0;
left: ${(props) => (props.mobileSidebarVisible ? "auto" : 0)};
right: ${(props) => (props.mobileSidebarVisible ? 0 : "auto")};
z-index: 1;
margin: 12px;
${breakpoint("tablet")`
display: none;
`};
`;
export default withRouter(observer(Sidebar));

View File

@@ -8,7 +8,7 @@ import { meta } from "utils/keyboard";
type Props = {|
collapsed: boolean,
onClick?: () => void,
onClick?: (event: SyntheticEvent<>) => void,
|};
function CollapseToggle({ collapsed, ...rest }: Props) {
@@ -21,7 +21,7 @@ function CollapseToggle({ collapsed, ...rest }: Props) {
delay={500}
placement="bottom"
>
<Button {...rest} aria-hidden>
<Button {...rest} tabIndex="-1" aria-hidden>
{collapsed ? (
<NextIcon color="currentColor" />
) : (
@@ -43,7 +43,7 @@ export const Button = styled.button`
z-index: 1;
font-weight: 600;
color: ${(props) => props.theme.sidebarText};
background: ${(props) => props.theme.sidebarItemBackground};
background: transparent;
transition: opacity 100ms ease-in-out;
border-radius: 4px;
opacity: 0;

View File

@@ -2,16 +2,19 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useDrop } from "react-dnd";
import styled from "styled-components";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import Document from "models/Document";
import CollectionIcon from "components/CollectionIcon";
import DropToImport from "components/DropToImport";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
import CollectionMenu from "menus/CollectionMenu";
import CollectionSortMenu from "menus/CollectionSortMenu";
type Props = {|
collection: Collection,
@@ -39,11 +42,14 @@ function CollectionLink({
const { documents, policies } = useStores();
const expanded = collection.id === ui.activeCollectionId;
const manualSort = collection.sort.field === "index";
const can = policies.abilities(collection.id);
// Droppable
// Drop to re-parent
const [{ isOver, canDrop }, drop] = useDrop({
accept: "document",
drop: (item, monitor) => {
if (monitor.didDrop()) return;
if (!collection) return;
documents.move(item.id, collection.id);
},
@@ -51,16 +57,28 @@ function CollectionLink({
return policies.abilities(collection.id).update;
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
isOver: !!monitor.isOver({ shallow: true }),
canDrop: monitor.canDrop(),
}),
});
// Drop to reorder
const [{ isOverReorder }, dropToReorder] = useDrop({
accept: "document",
drop: async (item, monitor) => {
if (!collection) return;
documents.move(item.id, collection.id, undefined, 0);
},
collect: (monitor) => ({
isOverReorder: !!monitor.isOver(),
}),
});
return (
<>
<div ref={drop}>
<div ref={drop} style={{ position: "relative" }}>
<DropToImport key={collection.id} collectionId={collection.id}>
<SidebarLink
<SidebarLinkWithPadding
key={collection.id}
to={collection.url}
icon={
@@ -68,7 +86,7 @@ function CollectionLink({
}
iconColor={collection.color}
expanded={expanded}
menuOpen={menuOpen}
showActions={menuOpen || expanded}
isActiveDrop={isOver && canDrop}
label={
<EditableTitle
@@ -79,19 +97,30 @@ function CollectionLink({
}
exact={false}
menu={
<CollectionMenu
position="right"
collection={collection}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
<>
{can.update && (
<CollectionSortMenuWithMargin
collection={collection}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
)}
<CollectionMenu
collection={collection}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</>
}
></SidebarLink>
/>
</DropToImport>
{expanded && manualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</div>
{expanded &&
collection.documents.map((node) => (
collection.documents.map((node, index) => (
<DocumentLink
key={node.id}
node={node}
@@ -100,10 +129,19 @@ function CollectionLink({
prefetchDocument={prefetchDocument}
canUpdate={canUpdate}
depth={1.5}
index={index}
/>
))}
</>
);
}
const SidebarLinkWithPadding = styled(SidebarLink)`
padding-right: 60px;
`;
const CollectionSortMenuWithMargin = styled(CollectionSortMenu)`
margin-right: 4px;
`;
export default observer(CollectionLink);

View File

@@ -9,6 +9,7 @@ import Collection from "models/Collection";
import Document from "models/Document";
import DropToImport from "components/DropToImport";
import Fade from "components/Fade";
import DropCursor from "./DropCursor";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
@@ -20,19 +21,21 @@ type Props = {|
canUpdate: boolean,
collection?: Collection,
activeDocument: ?Document,
activeDocumentRef?: (?HTMLElement) => void,
prefetchDocument: (documentId: string) => Promise<void>,
depth: number,
index: number,
parentId?: string,
|};
function DocumentLink({
node,
canUpdate,
collection,
activeDocument,
activeDocumentRef,
prefetchDocument,
depth,
canUpdate,
index,
parentId,
}: Props) {
const { documents, policies } = useStores();
const { t } = useTranslation();
@@ -76,6 +79,14 @@ function DocumentLink({
}
}, [showChildren]);
// when the last child document is removed,
// also close the local folder state to closed
React.useEffect(() => {
if (expanded && !hasChildDocuments) {
setExpanded(false);
}
}, [expanded, hasChildDocuments]);
const handleDisclosureClick = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
@@ -108,6 +119,7 @@ function DocumentLink({
const [menuOpen, setMenuOpen] = React.useState(false);
const isMoving = documents.movingDocumentId === node.id;
const manualSort = collection?.sort.field === "index";
// Draggable
const [{ isDragging }, drag] = useDrag({
@@ -120,77 +132,131 @@ function DocumentLink({
},
});
// Droppable
const [{ isOver, canDrop }, drop] = useDrop({
const hoverExpanding = React.useRef(null);
// We set a timeout when the user first starts hovering over the document link,
// to trigger expansion of children. Clear this timeout when they stop hovering.
const resetHoverExpanding = React.useCallback(() => {
if (hoverExpanding.current) {
clearTimeout(hoverExpanding.current);
hoverExpanding.current = null;
}
}, []);
// Drop to re-parent
const [{ isOverReparent, canDropToReparent }, dropToReparent] = useDrop({
accept: "document",
drop: async (item, monitor) => {
if (monitor.didDrop()) return;
if (!collection) return;
documents.move(item.id, collection.id, node.id);
},
canDrop: (item, monitor) =>
pathToNode && !pathToNode.includes(monitor.getItem().id),
hover: (item, monitor) => {
// Enables expansion of document children when hovering over the document
// for more than half a second.
if (
hasChildDocuments &&
monitor.canDrop() &&
monitor.isOver({ shallow: true })
) {
if (!hoverExpanding.current) {
hoverExpanding.current = setTimeout(() => {
hoverExpanding.current = null;
if (monitor.isOver({ shallow: true })) {
setExpanded(true);
}
}, 500);
}
}
},
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: monitor.canDrop(),
isOverReparent: !!monitor.isOver({ shallow: true }),
canDropToReparent: monitor.canDrop(),
}),
});
// Drop to reorder
const [{ isOverReorder }, dropToReorder] = useDrop({
accept: "document",
drop: async (item, monitor) => {
if (!collection) return;
if (item.id === node.id) return;
if (expanded) {
documents.move(item.id, collection.id, node.id, 0);
return;
}
documents.move(item.id, collection.id, parentId, index + 1);
},
collect: (monitor) => ({
isOverReorder: !!monitor.isOver(),
}),
});
return (
<>
<Draggable
key={node.id}
ref={drag}
$isDragging={isDragging}
$isMoving={isMoving}
>
<div ref={drop}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
innerRef={isActiveDocument ? activeDocumentRef : undefined}
onMouseEnter={handleMouseEnter}
to={{
pathname: node.url,
state: { title: node.title },
}}
label={
<>
{hasChildDocuments && (
<Disclosure
expanded={expanded && !isDragging}
onClick={handleDisclosureClick}
<div style={{ position: "relative" }} onDragLeave={resetHoverExpanding}>
<Draggable
key={node.id}
ref={drag}
$isDragging={isDragging}
$isMoving={isMoving}
>
<div ref={dropToReparent}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
onMouseEnter={handleMouseEnter}
to={{
pathname: node.url,
state: { title: node.title },
}}
label={
<>
{hasChildDocuments && (
<Disclosure
expanded={expanded && !isDragging}
onClick={handleDisclosureClick}
/>
)}
<EditableTitle
title={node.title || t("Untitled")}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
/>
)}
<EditableTitle
title={node.title || t("Untitled")}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
/>
</>
}
isActiveDrop={isOver && canDrop}
depth={depth}
exact={false}
menuOpen={menuOpen}
menu={
document && !isMoving ? (
<Fade>
<DocumentMenu
position="right"
document={document}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</Fade>
) : undefined
}
/>
</DropToImport>
</div>
</Draggable>
</>
}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
exact={false}
showActions={menuOpen}
menu={
document && !isMoving ? (
<Fade>
<DocumentMenu
document={document}
onOpen={() => setMenuOpen(true)}
onClose={() => setMenuOpen(false)}
/>
</Fade>
) : undefined
}
/>
</DropToImport>
</div>
</Draggable>
{manualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</div>
{expanded && !isDragging && (
<>
{node.children.map((childNode) => (
{node.children.map((childNode, index) => (
<ObservedDocumentLink
key={childNode.id}
collection={collection}
@@ -199,6 +265,8 @@ function DocumentLink({
prefetchDocument={prefetchDocument}
depth={depth + 1}
canUpdate={canUpdate}
index={index}
parentId={node.id}
/>
))}
</>

View File

@@ -0,0 +1,42 @@
// @flow
import * as React from "react";
import styled, { withTheme } from "styled-components";
import { type Theme } from "types";
function DropCursor({
isActiveDrop,
innerRef,
theme,
}: {
isActiveDrop: boolean,
innerRef: React.Ref<any>,
theme: Theme,
}) {
return <Cursor isOver={isActiveDrop} ref={innerRef} />;
}
// transparent hover zone with a thin visible band vertically centered
const Cursor = styled("div")`
opacity: ${(props) => (props.isOver ? 1 : 0)};
transition: opacity 150ms;
position: absolute;
z-index: 1;
width: 100%;
height: 14px;
bottom: -7px;
background: transparent;
::after {
background: ${(props) => props.theme.slateDark};
position: absolute;
top: 6px;
content: "";
height: 2px;
border-radius: 2px;
width: 100%;
}
`;
export default withTheme(DropCursor);

View File

@@ -52,7 +52,9 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
setOriginalValue(value);
} catch (error) {
setValue(originalValue);
ui.showToast(error.message);
ui.showToast(error.message, {
type: "error",
});
throw error;
}
}

View File

@@ -5,33 +5,35 @@ import styled from "styled-components";
import Flex from "components/Flex";
import TeamLogo from "components/TeamLogo";
type Props = {
type Props = {|
teamName: string,
subheading: React.Node,
showDisclosure?: boolean,
onClick: (event: SyntheticEvent<>) => void,
logoUrl: string,
};
|};
function HeaderBlock({
showDisclosure,
teamName,
subheading,
logoUrl,
...rest
}: Props) {
return (
<Header justify="flex-start" align="center" {...rest}>
<TeamLogo alt={`${teamName} logo`} src={logoUrl} size="38px" />
<Flex align="flex-start" column>
<TeamName showDisclosure>
{teamName}{" "}
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
</TeamName>
<Subheading>{subheading}</Subheading>
</Flex>
</Header>
);
}
const HeaderBlock = React.forwardRef<Props, any>(
({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => (
<Wrapper>
<Header justify="flex-start" align="center" ref={ref} {...rest}>
<TeamLogo
alt={`${teamName} logo`}
src={logoUrl}
width={38}
height={38}
/>
<Flex align="flex-start" column>
<TeamName showDisclosure>
{teamName}{" "}
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
</TeamName>
<Subheading>{subheading}</Subheading>
</Flex>
</Header>
</Wrapper>
)
);
const StyledExpandedIcon = styled(ExpandedIcon)`
position: absolute;
@@ -44,6 +46,7 @@ const Subheading = styled.div`
font-size: 11px;
text-transform: uppercase;
font-weight: 500;
white-space: nowrap;
color: ${(props) => props.theme.sidebarText};
`;
@@ -53,16 +56,20 @@ const TeamName = styled.div`
padding-right: 24px;
font-weight: 600;
color: ${(props) => props.theme.text};
white-space: nowrap;
text-decoration: none;
font-size: 16px;
`;
const Wrapper = styled.div`
flex-shrink: 0;
overflow: hidden;
`;
const Header = styled.button`
display: flex;
align-items: center;
flex-shrink: 0;
padding: 20px 24px;
position: relative;
background: none;
line-height: inherit;
border: 0;

View File

@@ -0,0 +1,105 @@
// @flow
// ref: https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/NavLink.js
// This file is pulled almost 100% from react-router with the addition of one
// thing, automatic scroll to the active link. It's worth the copy paste because
// it avoids recalculating the link match again.
import { createLocation } from "history";
import * as React from "react";
import {
__RouterContext as RouterContext,
matchPath,
type Location,
} from "react-router";
import { Link } from "react-router-dom";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
const resolveToLocation = (to, currentLocation) =>
typeof to === "function" ? to(currentLocation) : to;
const normalizeToLocation = (to, currentLocation) => {
return typeof to === "string"
? createLocation(to, null, null, currentLocation)
: to;
};
const joinClassnames = (...classnames) => {
return classnames.filter((i) => i).join(" ");
};
type Props = {|
activeClassName?: String,
activeStyle?: Object,
className?: string,
exact?: boolean,
isActive?: any,
location?: Location,
strict?: boolean,
style?: Object,
to: string,
|};
/**
* A <Link> wrapper that knows if it's "active" or not.
*/
const NavLink = ({
"aria-current": ariaCurrent = "page",
activeClassName = "active",
activeStyle,
className: classNameProp,
exact,
isActive: isActiveProp,
location: locationProp,
strict,
style: styleProp,
to,
...rest
}: Props) => {
const linkRef = React.useRef();
const context = React.useContext(RouterContext);
const currentLocation = locationProp || context.location;
const toLocation = normalizeToLocation(
resolveToLocation(to, currentLocation),
currentLocation
);
const { pathname: path } = toLocation;
// Regex taken from: https://github.com/pillarjs/path-to-regexp/blob/master/index.js#L202
const escapedPath = path && path.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");
const match = escapedPath
? matchPath(currentLocation.pathname, {
path: escapedPath,
exact,
strict,
})
: null;
const isActive = !!(isActiveProp
? isActiveProp(match, currentLocation)
: match);
const className = isActive
? joinClassnames(classNameProp, activeClassName)
: classNameProp;
const style = isActive ? { ...styleProp, ...activeStyle } : styleProp;
React.useEffect(() => {
if (isActive && linkRef.current) {
scrollIntoView(linkRef.current, {
scrollMode: "if-needed",
behavior: "instant",
});
}
}, [linkRef, isActive]);
const props = {
"aria-current": (isActive && ariaCurrent) || null,
className,
style,
to: toLocation,
...rest,
};
return <Link ref={linkRef} {...props} />;
};
export default NavLink;

View File

@@ -0,0 +1,28 @@
// @flow
import styled from "styled-components";
import ResizeHandle from "./ResizeHandle";
const ResizeBorder = styled.div`
position: absolute;
top: 0;
bottom: 0;
right: -6px;
width: 12px;
cursor: ew-resize;
${(props) =>
props.$isResizing &&
`
${ResizeHandle} {
opacity: 1;
}
`}
&:hover {
${ResizeHandle} {
opacity: 1;
}
}
`;
export default ResizeBorder;

View File

@@ -0,0 +1,39 @@
// @flow
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
const ResizeHandle = styled.button`
opacity: 0;
transition: opacity 100ms ease-in-out;
transform: translateY(-50%);
position: absolute;
top: 50%;
height: 40px;
right: -10px;
width: 8px;
padding: 0;
border: 0;
background: ${(props) => props.theme.sidebarBackground};
border-radius: 8px;
pointer-events: none;
&:after {
content: "";
position: absolute;
top: -24px;
bottom: -24px;
left: -12px;
right: -12px;
}
&:active {
background: ${(props) => props.theme.sidebarText};
}
${breakpoint("tablet")`
pointer-events: all;
cursor: ew-resize;
`}
`;
export default ResizeHandle;

View File

@@ -5,7 +5,9 @@ import Flex from "components/Flex";
const Section = styled(Flex)`
position: relative;
flex-direction: column;
margin: 24px 8px;
margin: 20px 8px;
min-width: ${(props) => props.theme.sidebarMinWidth}px;
flex-shrink: 0;
`;
export default Section;

View File

@@ -1,7 +1,10 @@
// @flow
import * as React from "react";
import { withRouter, NavLink } from "react-router-dom";
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "components/EventBoundary";
import NavLink from "./NavLink";
import { type Theme } from "types";
type Props = {
@@ -10,14 +13,17 @@ type Props = {
innerRef?: (?HTMLElement) => void,
onClick?: (SyntheticEvent<>) => void,
onMouseEnter?: (SyntheticEvent<>) => void,
className?: string,
children?: React.Node,
icon?: React.Node,
label?: React.Node,
menu?: React.Node,
menuOpen?: boolean,
showActions?: boolean,
iconColor?: string,
active?: boolean,
isActiveDrop?: boolean,
history: RouterHistory,
match: Match,
theme: Theme,
exact?: boolean,
depth?: number,
@@ -33,13 +39,14 @@ function SidebarLink({
active,
isActiveDrop,
menu,
menuOpen,
showActions,
theme,
exact,
href,
innerRef,
depth,
...rest
history,
match,
className,
}: Props) {
const style = React.useMemo(() => {
return {
@@ -48,16 +55,20 @@ function SidebarLink({
}, [depth]);
const activeStyle = {
color: theme.text,
fontWeight: 600,
color: theme.text,
background: theme.sidebarItemBackground,
...style,
};
const activeDropStyle = {
fontWeight: 600,
};
return (
<StyledNavLink
<Link
$isActiveDrop={isActiveDrop}
activeStyle={isActiveDrop ? undefined : activeStyle}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
style={active ? activeStyle : style}
onClick={onClick}
onMouseEnter={onMouseEnter}
@@ -65,12 +76,12 @@ function SidebarLink({
to={to}
as={to ? undefined : href ? "a" : "div"}
href={href}
ref={innerRef}
className={className}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
{menu && <Action menuOpen={menuOpen}>{menu}</Action>}
</StyledNavLink>
{menu && <Actions showActions={showActions}>{menu}</Actions>}
</Link>
);
}
@@ -79,42 +90,49 @@ const IconWrapper = styled.span`
margin-left: -4px;
margin-right: 4px;
height: 24px;
overflow: hidden;
flex-shrink: 0;
`;
const Action = styled.span`
display: ${(props) => (props.menuOpen ? "inline" : "none")};
const Actions = styled(EventBoundary)`
display: ${(props) => (props.showActions ? "inline-flex" : "none")};
position: absolute;
top: 4px;
right: 4px;
color: ${(props) => props.theme.textTertiary};
transition: opacity 50ms;
svg {
opacity: 0.75;
color: ${(props) => props.theme.textSecondary};
fill: currentColor;
opacity: 0.5;
}
&:hover {
svg {
opacity: 1;
opacity: 0.75;
}
}
`;
const StyledNavLink = styled(NavLink)`
const Link = styled(NavLink)`
display: flex;
position: relative;
overflow: hidden;
text-overflow: ellipsis;
padding: 4px 16px;
padding: 6px 16px;
border-radius: 4px;
transition: background 50ms, color 50ms;
background: ${(props) =>
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.sidebarText};
font-size: 15px;
cursor: pointer;
overflow: hidden;
svg {
${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")}
transition: fill 50ms;
}
&:hover {
@@ -127,11 +145,20 @@ const StyledNavLink = styled(NavLink)`
background: ${(props) => props.theme.black05};
}
&:hover {
> ${Action} {
display: inline;
&:hover,
&:active {
> ${Actions} {
display: inline-flex;
svg {
opacity: 0.75;
}
}
}
${breakpoint("tablet")`
padding: 4px 16px;
`}
`;
const Label = styled.div`