diff --git a/app/components/BreadcrumbMenu.js b/app/components/BreadcrumbMenu.js index fb372727c..fa7df3f4b 100644 --- a/app/components/BreadcrumbMenu.js +++ b/app/components/BreadcrumbMenu.js @@ -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, }; -export default class BreadcrumbMenu extends React.Component { - render() { - const { path } = this.props; - - return ( - - {path.map((item) => ( - - {item.title} - - ))} - - ); - } +export default function BreadcrumbMenu({ label, path }: Props) { + return ( + + ({ + title: item.title, + to: item.url, + }))} + /> + + ); } diff --git a/app/components/DropdownMenu/DropdownMenu.js b/app/components/DropdownMenu/DropdownMenu.js index f18ab0216..037ad7ec8 100644 --- a/app/components/DropdownMenu/DropdownMenu.js +++ b/app/components/DropdownMenu/DropdownMenu.js @@ -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 { diff --git a/app/components/DropdownMenu/DropdownMenuItems.js b/app/components/DropdownMenu/DropdownMenuItems.js new file mode 100644 index 000000000..b8f33cd21 --- /dev/null +++ b/app/components/DropdownMenu/DropdownMenuItems.js @@ -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, + 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 ( + + {item.title} + + ); + } + + if (item.href) { + return ( + + {item.title} + + ); + } + + if (item.onClick) { + return ( + + {item.title} + + ); + } + + if (item.items) { + return ( + + {item.title} + + } + hover={item.hover} + key={index} + > + + + ); + } + + if (item.type === "separator") { + return
; + } + + return null; + }); +} diff --git a/app/menus/CollectionMenu.js b/app/menus/CollectionMenu.js index c7ab1d90c..cba3e7c4a 100644 --- a/app/menus/CollectionMenu.js +++ b/app/menus/CollectionMenu.js @@ -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 { /> - {collection && ( - <> - {can.update && ( - - New document - - )} - {can.update && ( - - Import document - - )} - {can.update &&
} - {can.update && ( - - Edit… - - )} - {can.update && ( - - Permissions… - - )} - {can.export && ( - - Export… - - )} - - )} - {can.delete && ( - - Delete… - - )} +
{ } = 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 { onClose={onClose} label={label} > - {can.unarchive && ( - - Restore - - )} - {can.restore && - (collection ? ( - - Restore - - ) : ( - Restore…} - style={{ + -
Choose a collection
- {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 ( - - this.handleRestore(ev, { collectionId: collection.id }) - } - disabled={!can.update} - > - -  {collection.name} - - ); - })} -
- ))} - {showPin && - (document.pinned - ? can.unpin && ( - - Unpin - - ) - : can.pin && ( - - Pin to collection - - ))} - {document.isStarred - ? can.unstar && ( - - Unstar - - ) - : can.star && ( - - Star - - )} - {canShareDocuments && ( - - Share link… - - )} - {showToggleEmbeds && ( - <> - {document.embedsDisabled ? ( - - Enable embeds - - ) : ( - - Disable embeds - - )} - - )} - {!can.restore &&
} - - {can.createChildDocument && ( - - New nested document - - )} - {can.update && !document.isTemplate && ( - - Create template… - - )} - {can.unpublish && ( - - Unpublish - - )} - {can.update && ( - Edit - )} - {can.update && ( - - Duplicate - - )} - {can.archive && ( - - Archive - - )} - {can.delete && ( - - Delete… - - )} - {can.move && ( - Move… - )} -
- {canViewHistory && ( - <> - - History - - - )} - {can.download && ( - - Download - - )} - {showPrint && ( - Print - )} + return { + title: ( + <> + +  {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, + }, + ]} + /> { onSubmit={this.handleDeleteModalClose} /> - - {group && ( - <> - - Members… - - - {(can.update || can.delete) &&
} - - {can.update && ( - Edit… - )} - - {can.delete && ( - - Delete… - - )} - - )} +
); diff --git a/app/menus/NewChildDocumentMenu.js b/app/menus/NewChildDocumentMenu.js index f6e25d553..69f9ee425 100644 --- a/app/menus/NewChildDocumentMenu.js +++ b/app/menus/NewChildDocumentMenu.js @@ -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 { render() { if (this.redirectTo) return ; - const { label, document, collections, ...rest } = this.props; + const { label, document, collections } = this.props; const collection = collections.get(document.collectionId); return ( - } {...rest}> - - - New document in{" "} - {collection ? collection.name : "collection"} - - - - New nested document - + + + New document in{" "} + {collection ? collection.name : "collection"} + + ), + onClick: this.handleNewDocument, + }, + { + title: "New nested document", + onClick: this.handleNewChild, + }, + ]} + /> ); } diff --git a/app/menus/NewDocumentMenu.js b/app/menus/NewDocumentMenu.js index a85d2520c..7ee4b3e43 100644 --- a/app/menus/NewDocumentMenu.js +++ b/app/menus/NewDocumentMenu.js @@ -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 { {...rest} >
Choose a collection
- {collections.orderedData.map((collection) => { - const can = policies.abilities(collection.id); - - return ( - this.handleNewDocument(collection.id)} - disabled={!can.update} - > - -  {collection.name} - - ); - })} + ({ + onClick: () => this.handleNewDocument(collection.id), + disabled: !policies.abilities(collection.id).update, + title: ( + <> + +  {collection.name} + + ), + }))} + />
); } diff --git a/app/menus/NewTemplateMenu.js b/app/menus/NewTemplateMenu.js index ed3d94a82..86c99cfb6 100644 --- a/app/menus/NewTemplateMenu.js +++ b/app/menus/NewTemplateMenu.js @@ -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 { {...rest} >
Choose a collection
- {collections.orderedData.map((collection) => { - const can = policies.abilities(collection.id); - - return ( - this.handleNewDocument(collection.id)} - disabled={!can.update} - > - -  {collection.name} - - ); - })} + ({ + onClick: () => this.handleNewDocument(collection.id), + disabled: !policies.abilities(collection.id).update, + title: ( + <> + +  {collection.name} + + ), + }))} + /> ); } diff --git a/app/menus/UserMenu.js b/app/menus/UserMenu.js index 603eae0d1..cf2c2b398 100644 --- a/app/menus/UserMenu.js +++ b/app/menus/UserMenu.js @@ -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 { return ( - {user.isAdmin && ( - - Make {user.name} a member… - - )} - {!user.isAdmin && !user.isSuspended && ( - - Make {user.name} an admin… - - )} - {!user.lastActiveAt && ( - - Revoke invite… - - )} - {user.lastActiveAt && - (user.isSuspended ? ( - - Activate account - - ) : ( - - Suspend account… - - ))} + ); } diff --git a/app/models/User.js b/app/models/User.js index cc732833b..6e80f7b2c 100644 --- a/app/models/User.js +++ b/app/models/User.js @@ -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; diff --git a/app/scenes/CollectionMembers/components/MemberListItem.js b/app/scenes/CollectionMembers/components/MemberListItem.js index dd9cd4f54..227874e81 100644 --- a/app/scenes/CollectionMembers/components/MemberListItem.js +++ b/app/scenes/CollectionMembers/components/MemberListItem.js @@ -45,7 +45,7 @@ const MemberListItem = ({ ) : ( "Never signed in" )} - {!user.lastActiveAt && Invited} + {user.isInvited && Invited} {user.isAdmin && Admin} } diff --git a/app/scenes/CollectionMembers/components/UserListItem.js b/app/scenes/CollectionMembers/components/UserListItem.js index 94d0d8730..a4950547e 100644 --- a/app/scenes/CollectionMembers/components/UserListItem.js +++ b/app/scenes/CollectionMembers/components/UserListItem.js @@ -28,7 +28,7 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => { ) : ( "Never signed in" )} - {!user.lastActiveAt && Invited} + {user.isInvited && Invited} {user.isAdmin && Admin} } diff --git a/app/scenes/GroupMembers/components/GroupMemberListItem.js b/app/scenes/GroupMembers/components/GroupMemberListItem.js index f5fc97c19..be4a255bb 100644 --- a/app/scenes/GroupMembers/components/GroupMemberListItem.js +++ b/app/scenes/GroupMembers/components/GroupMemberListItem.js @@ -35,7 +35,7 @@ const GroupMemberListItem = ({ ) : ( "Never signed in" )} - {!user.lastActiveAt && Invited} + {user.isInvited && Invited} {user.isAdmin && Admin} } diff --git a/app/scenes/GroupMembers/components/UserListItem.js b/app/scenes/GroupMembers/components/UserListItem.js index 321368c2a..af63ab4cd 100644 --- a/app/scenes/GroupMembers/components/UserListItem.js +++ b/app/scenes/GroupMembers/components/UserListItem.js @@ -28,7 +28,7 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => { ) : ( "Never signed in" )} - {!user.lastActiveAt && Invited} + {user.isInvited && Invited} {user.isAdmin && Admin} } diff --git a/app/stores/UsersStore.js b/app/stores/UsersStore.js index 3022a2a37..49240a611 100644 --- a/app/stores/UsersStore.js +++ b/app/stores/UsersStore.js @@ -32,7 +32,7 @@ export default class UsersStore extends BaseStore { @computed get invited(): User[] { - return filter(this.orderedData, (user) => !user.lastActiveAt); + return filter(this.orderedData, (user) => user.isInvited); } @computed