chore: Menu templates (#1644)

* chore: Menu template system

* NewTemplateMenu

* UserMenu

* MenuItemsTemplate -> DropdownMenuItems

* support nested menus

* DocumentMenu

* BreadcrumbMenu

* isInvited
This commit is contained in:
Tom Moor
2020-11-14 20:44:31 -08:00
committed by GitHub
parent 19ab32f551
commit 12a2e1c387
16 changed files with 440 additions and 300 deletions

View File

@ -1,25 +1,22 @@
// @flow
import * as React from "react";
import { Link } from "react-router-dom";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
type Props = {
label: React.Node,
path: Array<any>,
};
export default class BreadcrumbMenu extends React.Component<Props> {
render() {
const { path } = this.props;
return (
<DropdownMenu label={this.props.label} position="center">
{path.map((item) => (
<DropdownMenuItem as={Link} to={item.url} key={item.id}>
{item.title}
</DropdownMenuItem>
))}
</DropdownMenu>
);
}
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

@ -18,7 +18,7 @@ type Children =
| React.Node
| ((options: { closePortal: () => void }) => React.Node);
type Props = {
type Props = {|
label?: React.Node,
onOpen?: () => void,
onClose?: () => void,
@ -27,7 +27,7 @@ type Props = {
hover?: boolean,
style?: Object,
position?: "left" | "right" | "center",
};
|};
@observer
class DropdownMenu extends React.Component<Props> {

View File

@ -0,0 +1,128 @@
// @flow
import * as React from "react";
import { Link } from "react-router-dom";
import DropdownMenu from "./DropdownMenu";
import DropdownMenuItem from "./DropdownMenuItem";
type MenuItem =
| {|
title: React.Node,
to: string,
visible?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
visible?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
href: string,
visible?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
visible?: boolean,
disabled?: boolean,
style?: Object,
hover?: boolean,
items: MenuItem[],
|}
| {|
type: "separator",
visible?: boolean,
|}
| {|
type: "heading",
visible?: boolean,
title: React.Node,
|};
type Props = {|
items: MenuItem[],
|};
export default function DropdownMenuItems({ items }: Props): React.Node {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators
filtered = filtered.reduce((acc, item, index) => {
// trim separators from start / end
if (item.type === "separator" && index === 0) return acc;
if (item.type === "separator" && index === filtered.length - 1) return acc;
// trim double separators looking ahead / behind
const prev = filtered[index - 1];
if (prev && prev.type === "separator" && item.type === "separator")
return acc;
// otherwise, continue
return [...acc, item];
}, []);
return filtered.map((item, index) => {
if (item.to) {
return (
<DropdownMenuItem
as={Link}
to={item.to}
key={index}
disabled={item.disabled}
>
{item.title}
</DropdownMenuItem>
);
}
if (item.href) {
return (
<DropdownMenuItem
href={item.href}
key={index}
disabled={item.disabled}
target="_blank"
>
{item.title}
</DropdownMenuItem>
);
}
if (item.onClick) {
return (
<DropdownMenuItem
onClick={item.onClick}
disabled={item.disabled}
key={index}
>
{item.title}
</DropdownMenuItem>
);
}
if (item.items) {
return (
<DropdownMenu
style={item.style}
label={
<DropdownMenuItem disabled={item.disabled}>
{item.title}
</DropdownMenuItem>
}
hover={item.hover}
key={index}
>
<DropdownMenuItems items={item.items} />
</DropdownMenu>
);
}
if (item.type === "separator") {
return <hr key={index} />;
}
return null;
});
}

View File

@ -11,7 +11,8 @@ import CollectionDelete from "scenes/CollectionDelete";
import CollectionEdit from "scenes/CollectionEdit";
import CollectionExport from "scenes/CollectionExport";
import CollectionMembers from "scenes/CollectionMembers";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import Modal from "components/Modal";
import VisuallyHidden from "components/VisuallyHidden";
import getDataTransferFiles from "utils/getDataTransferFiles";
@ -139,41 +140,43 @@ class CollectionMenu extends React.Component<Props> {
/>
</Modal>
<DropdownMenu onOpen={onOpen} onClose={onClose} position={position}>
{collection && (
<>
{can.update && (
<DropdownMenuItem onClick={this.onNewDocument}>
New document
</DropdownMenuItem>
)}
{can.update && (
<DropdownMenuItem onClick={this.onImportDocument}>
Import document
</DropdownMenuItem>
)}
{can.update && <hr />}
{can.update && (
<DropdownMenuItem onClick={this.handleEditCollectionOpen}>
Edit
</DropdownMenuItem>
)}
{can.update && (
<DropdownMenuItem onClick={this.handleMembersModalOpen}>
Permissions
</DropdownMenuItem>
)}
{can.export && (
<DropdownMenuItem onClick={this.handleExportCollectionOpen}>
Export
</DropdownMenuItem>
)}
</>
)}
{can.delete && (
<DropdownMenuItem onClick={this.handleDeleteCollectionOpen}>
Delete
</DropdownMenuItem>
)}
<DropdownMenuItems
items={[
{
title: "New document",
visible: !!(collection && can.update),
onClick: this.onNewDocument,
},
{
title: "Import document",
visible: !!(collection && can.update),
onClick: this.onImportDocument,
},
{
type: "separator",
},
{
title: "Edit…",
visible: !!(collection && can.update),
onClick: this.handleEditCollectionOpen,
},
{
title: "Permissions…",
visible: !!(collection && can.update),
onClick: this.handleMembersModalOpen,
},
{
title: "Export…",
visible: !!(collection && can.export),
onClick: this.handleExportCollectionOpen,
},
{
title: "Delete…",
visible: !!(collection && can.delete),
onClick: this.handleDeleteCollectionOpen,
},
]}
/>
</DropdownMenu>
<Modal
title="Edit collection"

View File

@ -12,11 +12,8 @@ import DocumentDelete from "scenes/DocumentDelete";
import DocumentShare from "scenes/DocumentShare";
import DocumentTemplatize from "scenes/DocumentTemplatize";
import CollectionIcon from "components/CollectionIcon";
import {
DropdownMenu,
DropdownMenuItem,
Header,
} from "components/DropdownMenu";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import Modal from "components/Modal";
import {
documentHistoryUrl,
@ -170,7 +167,7 @@ class DocumentMenu extends React.Component<Props> {
} = this.props;
const can = policies.abilities(document.id);
const canShareDocuments = can.share && auth.team && auth.team.sharing;
const canShareDocuments = !!(can.share && auth.team && auth.team.sharing);
const canViewHistory = can.read && !can.restore;
const collection = collections.get(document.collectionId);
@ -183,146 +180,147 @@ class DocumentMenu extends React.Component<Props> {
onClose={onClose}
label={label}
>
{can.unarchive && (
<DropdownMenuItem onClick={this.handleRestore}>
Restore
</DropdownMenuItem>
)}
{can.restore &&
(collection ? (
<DropdownMenuItem onClick={this.handleRestore}>
Restore
</DropdownMenuItem>
) : (
<DropdownMenu
label={<DropdownMenuItem>Restore</DropdownMenuItem>}
style={{
<DropdownMenuItems
items={[
{
title: "Restore",
visible: !!can.unarchive,
onClick: this.handleRestore,
},
{
title: "Restore",
visible: !!(collection && can.restore),
onClick: this.handleRestore,
},
{
title: "Restore…",
visible: !collection && !!can.restore,
style: {
left: -170,
position: "relative",
top: -40,
}}
hover
>
<Header>Choose a collection</Header>
{collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
},
hover: true,
items: [
{
type: "heading",
title: "Choose a collection",
},
...collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
return (
<DropdownMenuItem
key={collection.id}
onClick={(ev) =>
this.handleRestore(ev, { collectionId: collection.id })
}
disabled={!can.update}
>
<CollectionIcon collection={collection} />
&nbsp;{collection.name}
</DropdownMenuItem>
);
})}
</DropdownMenu>
))}
{showPin &&
(document.pinned
? can.unpin && (
<DropdownMenuItem onClick={this.handleUnpin}>
Unpin
</DropdownMenuItem>
)
: can.pin && (
<DropdownMenuItem onClick={this.handlePin}>
Pin to collection
</DropdownMenuItem>
))}
{document.isStarred
? can.unstar && (
<DropdownMenuItem onClick={this.handleUnstar}>
Unstar
</DropdownMenuItem>
)
: can.star && (
<DropdownMenuItem onClick={this.handleStar}>
Star
</DropdownMenuItem>
)}
{canShareDocuments && (
<DropdownMenuItem
onClick={this.handleShareLink}
title="Create a public share link"
>
Share link
</DropdownMenuItem>
)}
{showToggleEmbeds && (
<>
{document.embedsDisabled ? (
<DropdownMenuItem onClick={document.enableEmbeds}>
Enable embeds
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={document.disableEmbeds}>
Disable embeds
</DropdownMenuItem>
)}
</>
)}
{!can.restore && <hr />}
{can.createChildDocument && (
<DropdownMenuItem
onClick={this.handleNewChild}
title="Create a nested document inside the current document"
>
New nested document
</DropdownMenuItem>
)}
{can.update && !document.isTemplate && (
<DropdownMenuItem onClick={this.handleOpenTemplateModal}>
Create template
</DropdownMenuItem>
)}
{can.unpublish && (
<DropdownMenuItem onClick={this.handleUnpublish}>
Unpublish
</DropdownMenuItem>
)}
{can.update && (
<DropdownMenuItem onClick={this.handleEdit}>Edit</DropdownMenuItem>
)}
{can.update && (
<DropdownMenuItem onClick={this.handleDuplicate}>
Duplicate
</DropdownMenuItem>
)}
{can.archive && (
<DropdownMenuItem onClick={this.handleArchive}>
Archive
</DropdownMenuItem>
)}
{can.delete && (
<DropdownMenuItem onClick={this.handleDelete}>
Delete
</DropdownMenuItem>
)}
{can.move && (
<DropdownMenuItem onClick={this.handleMove}>Move</DropdownMenuItem>
)}
<hr />
{canViewHistory && (
<>
<DropdownMenuItem onClick={this.handleDocumentHistory}>
History
</DropdownMenuItem>
</>
)}
{can.download && (
<DropdownMenuItem onClick={this.handleExport}>
Download
</DropdownMenuItem>
)}
{showPrint && (
<DropdownMenuItem onClick={window.print}>Print</DropdownMenuItem>
)}
return {
title: (
<>
<CollectionIcon collection={collection} />
&nbsp;{collection.name}
</>
),
onClick: (ev) =>
this.handleRestore(ev, { collectionId: collection.id }),
disabled: !can.update,
};
}),
],
},
{
title: "Unpin",
onClick: this.handleUnpin,
visible: !!(showPin && document.pinned && can.unpin),
},
{
title: "Pin to collection",
onClick: this.handlePin,
visible: !!(showPin && !document.pinned && can.pin),
},
{
title: "Unstar",
onClick: this.handleUnstar,
visible: document.isStarred && !!can.unstar,
},
{
title: "Star",
onClick: this.handleStar,
visible: !document.isStarred && !!can.star,
},
{
title: "Share link…",
onClick: this.handleShareLink,
visible: canShareDocuments,
},
{
title: "Enable embeds",
onClick: document.enableEmbeds,
visible: !!showToggleEmbeds && document.embedsDisabled,
},
{
title: "Disable embeds",
onClick: document.disableEmbeds,
visible: !!showToggleEmbeds && !document.embedsDisabled,
},
{
type: "separator",
},
{
title: "New nested document",
onClick: this.handleNewChild,
visible: !!can.createChildDocument,
},
{
title: "Create template…",
onClick: this.handleOpenTemplateModal,
visible: !!can.update && !document.isTemplate,
},
{
title: "Edit",
onClick: this.handleEdit,
visible: !!can.update,
},
{
title: "Duplicate",
onClick: this.handleDuplicate,
visible: !!can.update,
},
{
title: "Unpublish",
onClick: this.handleUnpublish,
visible: !!can.unpublish,
},
{
title: "Archive",
onClick: this.handleArchive,
visible: !!can.archive,
},
{
title: "Delete…",
onClick: this.handleDelete,
visible: !!can.delete,
},
{
title: "Move…",
onClick: this.handleMove,
visible: !!can.move,
},
{
type: "separator",
},
{
title: "History",
onClick: this.handleDocumentHistory,
visible: canViewHistory,
},
{
title: "Download",
onClick: this.handleExport,
visible: !!can.download,
},
{
title: "Print",
onClick: window.print,
visible: !!showPrint,
},
]}
/>
</DropdownMenu>
<Modal
title={`Delete ${this.props.document.noun}`}

View File

@ -8,8 +8,8 @@ import UiStore from "stores/UiStore";
import Group from "models/Group";
import GroupDelete from "scenes/GroupDelete";
import GroupEdit from "scenes/GroupEdit";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import Modal from "components/Modal";
type Props = {
@ -72,27 +72,29 @@ class GroupMenu extends React.Component<Props> {
onSubmit={this.handleDeleteModalClose}
/>
</Modal>
<DropdownMenu onOpen={onOpen} onClose={onClose}>
{group && (
<>
<DropdownMenuItem onClick={this.props.onMembers}>
Members
</DropdownMenuItem>
{(can.update || can.delete) && <hr />}
{can.update && (
<DropdownMenuItem onClick={this.onEdit}>Edit</DropdownMenuItem>
)}
{can.delete && (
<DropdownMenuItem onClick={this.onDelete}>
Delete
</DropdownMenuItem>
)}
</>
)}
<DropdownMenuItems
items={[
{
title: "Members…",
onClick: this.props.onMembers,
visible: !!(group && can.read),
},
{
type: "separator",
},
{
title: "Edit…",
onClick: this.onEdit,
visible: !!(group && can.update),
},
{
title: "Delete…",
onClick: this.onDelete,
visible: !!(group && can.delete),
},
]}
/>
</DropdownMenu>
</>
);

View File

@ -1,13 +1,13 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { Redirect } from "react-router-dom";
import CollectionsStore from "stores/CollectionsStore";
import Document from "models/Document";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
@ -39,20 +39,28 @@ class NewChildDocumentMenu extends React.Component<Props> {
render() {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const { label, document, collections, ...rest } = this.props;
const { label, document, collections } = this.props;
const collection = collections.get(document.collectionId);
return (
<DropdownMenu label={label || <MoreIcon />} {...rest}>
<DropdownMenuItem onClick={this.handleNewDocument}>
<span>
New document in{" "}
<strong>{collection ? collection.name : "collection"}</strong>
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={this.handleNewChild}>
New nested document
</DropdownMenuItem>
<DropdownMenu label={label}>
<DropdownMenuItems
items={[
{
title: (
<span>
New document in{" "}
<strong>{collection ? collection.name : "collection"}</strong>
</span>
),
onClick: this.handleNewDocument,
},
{
title: "New nested document",
onClick: this.handleNewChild,
},
]}
/>
</DropdownMenu>
);
}

View File

@ -10,11 +10,8 @@ import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import Button from "components/Button";
import CollectionIcon from "components/CollectionIcon";
import {
DropdownMenu,
DropdownMenuItem,
Header,
} from "components/DropdownMenu";
import { DropdownMenu, Header } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
@ -63,20 +60,18 @@ class NewDocumentMenu extends React.Component<Props> {
{...rest}
>
<Header>Choose a collection</Header>
{collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
return (
<DropdownMenuItem
key={collection.id}
onClick={() => this.handleNewDocument(collection.id)}
disabled={!can.update}
>
<CollectionIcon collection={collection} />
&nbsp;{collection.name}
</DropdownMenuItem>
);
})}
<DropdownMenuItems
items={collections.orderedData.map((collection) => ({
onClick: () => this.handleNewDocument(collection.id),
disabled: !policies.abilities(collection.id).update,
title: (
<>
<CollectionIcon collection={collection} />
&nbsp;{collection.name}
</>
),
}))}
/>
</DropdownMenu>
);
}

View File

@ -9,11 +9,8 @@ import CollectionsStore from "stores/CollectionsStore";
import PoliciesStore from "stores/PoliciesStore";
import Button from "components/Button";
import CollectionIcon from "components/CollectionIcon";
import {
DropdownMenu,
DropdownMenuItem,
Header,
} from "components/DropdownMenu";
import { DropdownMenu, Header } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
@ -53,20 +50,18 @@ class NewTemplateMenu extends React.Component<Props> {
{...rest}
>
<Header>Choose a collection</Header>
{collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
return (
<DropdownMenuItem
key={collection.id}
onClick={() => this.handleNewDocument(collection.id)}
disabled={!can.update}
>
<CollectionIcon collection={collection} />
&nbsp;{collection.name}
</DropdownMenuItem>
);
})}
<DropdownMenuItems
items={collections.orderedData.map((collection) => ({
onClick: () => this.handleNewDocument(collection.id),
disabled: !policies.abilities(collection.id).update,
title: (
<>
<CollectionIcon collection={collection} />
&nbsp;{collection.name}
</>
),
}))}
/>
</DropdownMenu>
);
}

View File

@ -4,7 +4,8 @@ import * as React from "react";
import UsersStore from "stores/UsersStore";
import User from "models/User";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
type Props = {
user: User,
@ -65,31 +66,38 @@ class UserMenu extends React.Component<Props> {
return (
<DropdownMenu>
{user.isAdmin && (
<DropdownMenuItem onClick={this.handleDemote}>
Make {user.name} a member
</DropdownMenuItem>
)}
{!user.isAdmin && !user.isSuspended && (
<DropdownMenuItem onClick={this.handlePromote}>
Make {user.name} an admin
</DropdownMenuItem>
)}
{!user.lastActiveAt && (
<DropdownMenuItem onClick={this.handleRevoke}>
Revoke invite
</DropdownMenuItem>
)}
{user.lastActiveAt &&
(user.isSuspended ? (
<DropdownMenuItem onClick={this.handleActivate}>
Activate account
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={this.handleSuspend}>
Suspend account
</DropdownMenuItem>
))}
<DropdownMenuItems
items={[
{
title: `Make ${user.name} a member…`,
onClick: this.handleDemote,
visible: user.isAdmin,
},
{
title: `Make ${user.name} an admin…`,
onClick: this.handlePromote,
visible: !user.isAdmin && !user.isSuspended,
},
{
type: "separator",
},
{
title: "Revoke invite…",
onClick: this.handleRevoke,
visible: user.isInvited,
},
{
title: "Reactivate account",
onClick: this.handleActivate,
visible: !user.isInvited && user.isSuspended,
},
{
title: "Suspend account",
onClick: this.handleSuspend,
visible: !user.isInvited && !user.isSuspended,
},
]}
/>
</DropdownMenu>
);
}

View File

@ -1,4 +1,5 @@
// @flow
import { computed } from "mobx";
import BaseModel from "./BaseModel";
class User extends BaseModel {
@ -10,6 +11,11 @@ class User extends BaseModel {
lastActiveAt: string;
isSuspended: boolean;
createdAt: string;
@computed
get isInvited(): boolean {
return !this.lastActiveAt;
}
}
export default User;

View File

@ -45,7 +45,7 @@ const MemberListItem = ({
) : (
"Never signed in"
)}
{!user.lastActiveAt && <Badge>Invited</Badge>}
{user.isInvited && <Badge>Invited</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
</>
}

View File

@ -28,7 +28,7 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
) : (
"Never signed in"
)}
{!user.lastActiveAt && <Badge>Invited</Badge>}
{user.isInvited && <Badge>Invited</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
</>
}

View File

@ -35,7 +35,7 @@ const GroupMemberListItem = ({
) : (
"Never signed in"
)}
{!user.lastActiveAt && <Badge>Invited</Badge>}
{user.isInvited && <Badge>Invited</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
</>
}

View File

@ -28,7 +28,7 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
) : (
"Never signed in"
)}
{!user.lastActiveAt && <Badge>Invited</Badge>}
{user.isInvited && <Badge>Invited</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
</>
}

View File

@ -32,7 +32,7 @@ export default class UsersStore extends BaseStore<User> {
@computed
get invited(): User[] {
return filter(this.orderedData, (user) => !user.lastActiveAt);
return filter(this.orderedData, (user) => user.isInvited);
}
@computed