diff --git a/app/components/Badge.js b/app/components/Badge.js new file mode 100644 index 00000000..d3d6f351 --- /dev/null +++ b/app/components/Badge.js @@ -0,0 +1,17 @@ +// @flow +import styled from 'styled-components'; + +const Badge = styled.span` + margin-left: 10px; + padding: 2px 6px 3px; + background-color: ${({ admin, theme }) => + admin ? theme.primary : theme.smokeDark}; + color: ${({ admin, theme }) => (admin ? theme.white : theme.text)}; + border-radius: 2px; + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + user-select: none; +`; + +export default Badge; diff --git a/app/components/Collaborators.js b/app/components/Collaborators.js index 1d1197c9..2905ce8a 100644 --- a/app/components/Collaborators.js +++ b/app/components/Collaborators.js @@ -1,5 +1,6 @@ // @flow import * as React from 'react'; +import { observable } from 'mobx'; import { observer, inject } from 'mobx-react'; import { filter } from 'lodash'; import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; @@ -8,6 +9,7 @@ import Flex from 'shared/components/Flex'; import Avatar from 'components/Avatar'; import Tooltip from 'components/Tooltip'; import Document from 'models/Document'; +import UserProfile from 'scenes/UserProfile'; import ViewsStore from 'stores/ViewsStore'; const MAX_DISPLAY = 6; @@ -19,31 +21,24 @@ type Props = { @observer class Collaborators extends React.Component { + @observable openProfileId: ?string; + componentDidMount() { this.props.views.fetchPage({ documentId: this.props.document.id }); } + handleOpenProfile = (userId: string) => { + this.openProfileId = userId; + }; + + handleCloseProfile = () => { + this.openProfileId = undefined; + }; + render() { const { document, views } = this.props; const documentViews = views.inDocument(document.id); - const { - createdAt, - updatedAt, - createdBy, - updatedBy, - collaborators, - } = document; - let tooltip; - - if (createdAt === updatedAt) { - tooltip = `${createdBy.name} published ${distanceInWordsToNow( - new Date(createdAt) - )} ago`; - } else { - tooltip = `${updatedBy.name} updated ${distanceInWordsToNow( - new Date(updatedAt) - )} ago`; - } + const { createdAt, updatedAt, updatedBy, collaborators } = document; // filter to only show views that haven't collaborated const collaboratorIds = collaborators.map(user => user.id); @@ -65,35 +60,67 @@ class Collaborators extends React.Component { {overflow > 0 && +{overflow}} {mostRecentViewers.map(({ lastViewedAt, user }) => ( - + {user.name} +
+ viewed {distanceInWordsToNow(new Date(lastViewedAt))} ago + + } placement="bottom" > - + this.handleOpenProfile(user.id)} + /> + -
+ ))} {collaborators.map(user => ( - 1 ? user.name : tooltip} + tooltip={ + + {user.name} +
+ {createdAt === updatedAt ? 'published' : 'updated'}{' '} + {updatedBy.id === user.id && + `${distanceInWordsToNow(new Date(updatedAt))} ago`} +
+ } placement="bottom" > - + this.handleOpenProfile(user.id)} + /> + -
+ ))}
); } } -const StyledTooltip = styled(Tooltip)` +const TooltipCentered = styled.div` + text-align: center; +`; + +const AvatarPile = styled(Tooltip)` margin-right: -8px; &:first-child { @@ -128,6 +155,7 @@ const More = styled.div` const Avatars = styled(Flex)` align-items: center; flex-direction: row-reverse; + cursor: pointer; `; export default inject('views')(Collaborators); diff --git a/app/components/DropdownMenu/DropdownMenu.js b/app/components/DropdownMenu/DropdownMenu.js index 1989f76b..c8fc05c8 100644 --- a/app/components/DropdownMenu/DropdownMenu.js +++ b/app/components/DropdownMenu/DropdownMenu.js @@ -93,7 +93,7 @@ const Menu = styled.div` background: ${props => props.theme.white}; border-radius: 2px; padding: 0.5em 0; - min-width: 160px; + min-width: 180px; overflow: hidden; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 4px 8px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.08); diff --git a/app/components/List/Item.js b/app/components/List/Item.js index cd275cd8..1d6a29cf 100644 --- a/app/components/List/Item.js +++ b/app/components/List/Item.js @@ -5,7 +5,7 @@ import Flex from 'shared/components/Flex'; type Props = { image?: React.Node, - title: string, + title: React.Node, subtitle?: React.Node, actions?: React.Node, }; diff --git a/app/models/User.js b/app/models/User.js index 01b45ec0..a563bd0e 100644 --- a/app/models/User.js +++ b/app/models/User.js @@ -6,7 +6,6 @@ class User extends BaseModel { id: string; name: string; email: string; - username: string; isAdmin: boolean; isSuspended: boolean; createdAt: string; diff --git a/app/scenes/Settings/components/UserListItem.js b/app/scenes/Settings/components/UserListItem.js index 18ee715c..a2a2094d 100644 --- a/app/scenes/Settings/components/UserListItem.js +++ b/app/scenes/Settings/components/UserListItem.js @@ -1,9 +1,12 @@ // @flow import * as React from 'react'; import styled from 'styled-components'; - +import { observable } from 'mobx'; +import { observer } from 'mobx-react'; import UserMenu from 'menus/UserMenu'; import Avatar from 'components/Avatar'; +import Badge from 'components/Badge'; +import UserProfile from 'scenes/UserProfile'; import ListItem from 'components/List/Item'; import Time from 'shared/components/Time'; import User from 'models/User'; @@ -13,35 +16,57 @@ type Props = { showMenu: boolean, }; -const UserListItem = ({ user, showMenu }: Props) => { - return ( - } - subtitle={ - - {user.email ? `${user.email} · ` : undefined} - Joined - } - actions={showMenu ? : undefined} - /> - ); -}; +@observer +class UserListItem extends React.Component { + @observable profileOpen: boolean = false; -const Badge = styled.span` - margin-left: 10px; - padding: 2px 6px 3px; - background-color: ${({ admin, theme }) => - admin ? theme.primary : theme.smokeDark}; - color: ${({ admin, theme }) => (admin ? theme.white : theme.text)}; - border-radius: 2px; - font-size: 11px; - font-weight: 500; - text-transform: uppercase; - user-select: none; + handleOpenProfile = () => { + this.profileOpen = true; + }; + + handleCloseProfile = () => { + this.profileOpen = false; + }; + + render() { + const { user, showMenu } = this.props; + + return ( + {user.name}} + image={ + + + + + } + subtitle={ + + {user.email ? `${user.email} · ` : undefined} + Joined + } + actions={showMenu ? : undefined} + /> + ); + } +} + +const Title = styled.span` + &:hover { + text-decoration: underline; + cursor: pointer; + } `; export default UserListItem; diff --git a/app/scenes/UserProfile.js b/app/scenes/UserProfile.js new file mode 100644 index 00000000..8e70936f --- /dev/null +++ b/app/scenes/UserProfile.js @@ -0,0 +1,87 @@ +// @flow +import * as React from 'react'; +import styled from 'styled-components'; +import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; +import { inject, observer } from 'mobx-react'; +import { Link } from 'react-router-dom'; +import Flex from 'shared/components/Flex'; +import HelpText from 'components/HelpText'; +import Modal from 'components/Modal'; +import Button from 'components/Button'; +import Avatar from 'components/Avatar'; +import Badge from 'components/Badge'; +import PaginatedDocumentList from 'components/PaginatedDocumentList'; +import Subheading from 'components/Subheading'; +import User from 'models/User'; +import DocumentsStore from 'stores/DocumentsStore'; +import AuthStore from 'stores/AuthStore'; + +type Props = { + user: User, + auth: AuthStore, + documents: DocumentsStore, + onRequestClose: () => *, +}; + +@observer +class UserProfile extends React.Component { + render() { + const { user, auth, documents, ...rest } = this.props; + if (!user) return null; + const isCurrentUser = auth.user && auth.user.id === user.id; + + return ( + + +  {user.name} + + } + {...rest} + > + + + {isCurrentUser ? 'You joined' : 'Joined'}{' '} + {distanceInWordsToNow(new Date(user.createdAt))} ago. + {user.isAdmin && ( + Admin + )} + {user.isSuspended && Suspended} + {isCurrentUser && ( + + + + )} + + Recently updated + + + + ); + } +} + +const Edit = styled.span` + position: absolute; + top: 46px; + right: 0; +`; + +const StyledBadge = styled(Badge)` + position: relative; + top: -2px; +`; + +const Meta = styled(HelpText)` + margin-top: -12px; +`; + +export default inject('documents', 'auth')(UserProfile); diff --git a/server/api/__snapshots__/users.test.js.snap b/server/api/__snapshots__/users.test.js.snap index a45a788e..30c929ce 100644 --- a/server/api/__snapshots__/users.test.js.snap +++ b/server/api/__snapshots__/users.test.js.snap @@ -10,7 +10,6 @@ Object { "isAdmin": false, "isSuspended": false, "name": "User 1", - "username": "user1", }, "ok": true, "status": 200, @@ -45,7 +44,6 @@ Object { "isAdmin": false, "isSuspended": false, "name": "User 1", - "username": "user1", }, "ok": true, "status": 200, @@ -77,15 +75,17 @@ Object { "avatarUrl": "http://example.com/avatar.png", "createdAt": "2018-01-01T00:00:00.000Z", "id": "fa952cff-fa64-4d42-a6ea-6955c9689046", + "isAdmin": true, + "isSuspended": false, "name": "Admin User", - "username": "admin", }, Object { "avatarUrl": "http://example.com/avatar.png", "createdAt": "2018-01-01T00:00:00.000Z", "id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61", + "isAdmin": false, + "isSuspended": false, "name": "User 1", - "username": "user1", }, ], "ok": true, @@ -109,7 +109,6 @@ Object { "isAdmin": false, "isSuspended": false, "name": "User 1", - "username": "user1", }, Object { "avatarUrl": "http://example.com/avatar.png", @@ -119,7 +118,6 @@ Object { "isAdmin": true, "isSuspended": false, "name": "Admin User", - "username": "admin", }, ], "ok": true, @@ -142,7 +140,6 @@ Object { "isAdmin": true, "isSuspended": false, "name": "User 1", - "username": "user1", }, "ok": true, "status": 200, @@ -177,7 +174,6 @@ Object { "isAdmin": false, "isSuspended": true, "name": "User 1", - "username": "user1", }, "ok": true, "status": 200, @@ -212,7 +208,6 @@ Object { "isAdmin": false, "isSuspended": false, "name": "New name", - "username": "user1", }, "ok": true, "status": 200, diff --git a/server/presenters/__snapshots__/user.test.js.snap b/server/presenters/__snapshots__/user.test.js.snap index b30d948f..cba56d1b 100644 --- a/server/presenters/__snapshots__/user.test.js.snap +++ b/server/presenters/__snapshots__/user.test.js.snap @@ -5,8 +5,9 @@ Object { "avatarUrl": "http://example.com/avatar.png", "createdAt": undefined, "id": "123", + "isAdmin": undefined, + "isSuspended": undefined, "name": "Test User", - "username": "testuser", } `; @@ -15,7 +16,8 @@ Object { "avatarUrl": null, "createdAt": undefined, "id": "123", + "isAdmin": undefined, + "isSuspended": undefined, "name": "Test User", - "username": "testuser", } `; diff --git a/server/presenters/user.js b/server/presenters/user.js index 82283ff3..850a4c49 100644 --- a/server/presenters/user.js +++ b/server/presenters/user.js @@ -7,11 +7,11 @@ type Options = { type UserPresentation = { id: string, - username: string, name: string, avatarUrl: ?string, email?: string, - isAdmin?: boolean, + isAdmin: boolean, + isSuspended: boolean, }; export default ( @@ -22,15 +22,14 @@ export default ( const userData = {}; userData.id = user.id; userData.createdAt = user.createdAt; - userData.username = user.username; userData.name = user.name; + userData.isAdmin = user.isAdmin; + userData.isSuspended = user.isSuspended; userData.avatarUrl = user.avatarUrl || (user.slackData ? user.slackData.image_192 : null); if (options.includeDetails) { userData.email = user.email; - userData.isAdmin = user.isAdmin; - userData.isSuspended = user.isSuspended; } return userData;