feat: Improved viewers popover (#2106)

* refactoring popover

* feat: DocumentViews popover

* i18n

* fix: tab focus warnings

* test: Add tests around users.info changes

* snapshots
This commit is contained in:
Tom Moor
2021-05-05 19:35:23 -07:00
committed by GitHub
parent e984a3dcdb
commit 896ee5c20d
18 changed files with 388 additions and 191 deletions

View File

@ -17,6 +17,7 @@ type Props = {
isEditing: boolean,
isCurrentUser: boolean,
lastViewedAt: string,
profileOnClick: boolean,
t: TFunction,
};
@ -65,7 +66,11 @@ class AvatarWithPresence extends React.Component<Props> {
<AvatarWrapper isPresent={isPresent}>
<Avatar
src={user.avatarUrl}
onClick={this.handleOpenProfile}
onClick={
this.props.profileOnClick === false
? undefined
: this.handleOpenProfile
}
size={32}
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
/>

View File

@ -1,79 +1,93 @@
// @flow
import { sortBy, keyBy } from "lodash";
import { observer, inject } from "mobx-react";
import { filter } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { MAX_AVATAR_DISPLAY } from "shared/constants";
import DocumentPresenceStore from "stores/DocumentPresenceStore";
import ViewsStore from "stores/ViewsStore";
import Document from "models/Document";
import { AvatarWithPresence } from "components/Avatar";
import DocumentViews from "components/DocumentViews";
import Facepile from "components/Facepile";
import NudeButton from "components/NudeButton";
import Popover from "components/Popover";
import useStores from "hooks/useStores";
type Props = {
views: ViewsStore,
presence: DocumentPresenceStore,
type Props = {|
document: Document,
currentUserId: string,
};
|};
@observer
class Collaborators extends React.Component<Props> {
componentDidMount() {
if (!this.props.document.isDeleted) {
this.props.views.fetchPage({ documentId: this.props.document.id });
}
}
function Collaborators(props: Props) {
const { t } = useTranslation();
const { users, presence } = useStores();
const { document, currentUserId } = props;
render() {
const { document, presence, views, currentUserId } = this.props;
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
? Array.from(documentPresence.values())
: [];
const documentViews = views.inDocument(document.id);
const presentIds = documentPresence.map((p) => p.userId);
const editingIds = documentPresence
.filter((p) => p.isEditing)
.map((p) => p.userId);
// ensure currently present via websocket are always ordered first
const mostRecentViewers = sortBy(
documentViews.slice(0, MAX_AVATAR_DISPLAY),
(view) => {
return presentIds.includes(view.user.id);
}
const presentUsers = filter(users.orderedData, (user) =>
presentIds.includes(user.id)
);
const viewersKeyedByUserId = keyBy(mostRecentViewers, (v) => v.user.id);
const overflow = documentViews.length - mostRecentViewers.length;
// load any users we don't know about
React.useEffect(() => {
if (users.isFetching) {
return;
}
presentIds.forEach((userId) => {
if (!users.get(userId)) {
return users.fetch(userId);
}
});
}, [document, users, presentIds]);
const popover = usePopoverState({
gutter: 0,
placement: "bottom-end",
});
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<NudeButton width={presentUsers.length * 32} height={32} {...props}>
<FacepileHiddenOnMobile
users={mostRecentViewers.map((v) => v.user)}
overflow={overflow}
users={presentUsers}
renderAvatar={(user) => {
const isPresent = presentIds.includes(user.id);
const isEditing = editingIds.includes(user.id);
const { lastViewedAt } = viewersKeyedByUserId[user.id];
return (
<AvatarWithPresence
key={user.id}
user={user}
lastViewedAt={lastViewedAt}
isPresent={isPresent}
isEditing={isEditing}
isCurrentUser={currentUserId === user.id}
profileOnClick={false}
/>
);
}}
/>
</NudeButton>
)}
</PopoverDisclosure>
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
<DocumentViews document={document} isOpen={popover.visible} />
</Popover>
</>
);
}
}
const FacepileHiddenOnMobile = styled(Facepile)`
@ -82,4 +96,4 @@ const FacepileHiddenOnMobile = styled(Facepile)`
`};
`;
export default inject("views", "presence")(Collaborators);
export default observer(Collaborators);

View File

@ -1,9 +1,13 @@
// @flow
import { useObserver } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import Document from "models/Document";
import DocumentMeta from "components/DocumentMeta";
import DocumentViews from "components/DocumentViews";
import Popover from "components/Popover";
import useStores from "../hooks/useStores";
type Props = {|
@ -12,22 +16,41 @@ type Props = {|
to?: string,
|};
function DocumentMetaWithViews({ to, isDraft, document }: Props) {
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
const { views } = useStores();
const { t } = useTranslation();
const documentViews = useObserver(() => views.inDocument(document.id));
const totalViewers = documentViews.length;
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
const popover = usePopoverState({
gutter: 8,
placement: "bottom",
modal: true,
});
return (
<Meta document={document} to={to}>
<Meta document={document} to={to} {...rest}>
{totalViewers && !isDraft ? (
<PopoverDisclosure {...popover}>
{(props) => (
<>
&nbsp;&middot; Viewed by{" "}
&nbsp;&middot;&nbsp;
<a {...props}>
{t("Viewed by")}{" "}
{onlyYou
? "only you"
: `${totalViewers} ${totalViewers === 1 ? "person" : "people"}`}
? t("only you")
: `${totalViewers} ${
totalViewers === 1 ? t("person") : t("people")
}`}
</a>
</>
)}
</PopoverDisclosure>
) : null}
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
<DocumentViews document={document} isOpen={popover.visible} />
</Popover>
</Meta>
);
}

View File

@ -0,0 +1,82 @@
// @flow
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
import { sortBy } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Document from "models/Document";
import Avatar from "components/Avatar";
import ListItem from "components/List/Item";
import PaginatedList from "components/PaginatedList";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
isOpen?: boolean,
|};
function DocumentViews({ document, isOpen }: Props) {
const { t } = useTranslation();
const { views, presence } = useStores();
React.useEffect(() => {
views.fetchPage({ documentId: document.id });
}, [views, document.id]);
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
? Array.from(documentPresence.values())
: [];
const presentIds = documentPresence.map((p) => p.userId);
const editingIds = documentPresence
.filter((p) => p.isEditing)
.map((p) => p.userId);
// ensure currently present via websocket are always ordered first
const documentViews = views.inDocument(document.id);
const sortedViews = sortBy(documentViews, (view) =>
presentIds.includes(view.user.id)
);
const users = React.useMemo(() => sortedViews.map((v) => v.user), [
sortedViews,
]);
return (
<>
{isOpen && (
<PaginatedList
items={users}
renderItem={(item) => {
const view = documentViews.find((v) => v.user.id === item.id);
const isPresent = presentIds.includes(item.id);
const isEditing = editingIds.includes(item.id);
const subtitle = isPresent
? isEditing
? t("Currently editing")
: t("Currently viewing")
: t("Viewed {{ timeAgo }} ago", {
timeAgo: distanceInWordsToNow(
view ? new Date(view.lastViewedAt) : new Date()
),
});
return (
<ListItem
key={item.id}
title={item.name}
subtitle={subtitle}
image={<Avatar key={item.id} src={item.avatarUrl} size={32} />}
border={false}
/>
);
}}
/>
)}
</>
);
}
export default observer(DocumentViews);

View File

@ -1,29 +1,26 @@
// @flow
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import styled, { withTheme } from "styled-components";
import styled from "styled-components";
import User from "models/User";
import Avatar from "components/Avatar";
import Flex from "components/Flex";
type Props = {
type Props = {|
users: User[],
size?: number,
overflow: number,
renderAvatar: (user: User) => React.Node,
};
onClick?: (event: SyntheticEvent<>) => mixed,
renderAvatar?: (user: User) => React.Node,
|};
@observer
class Facepile extends React.Component<Props> {
render() {
const {
function Facepile({
users,
overflow,
size = 32,
renderAvatar = renderDefaultAvatar,
renderAvatar = DefaultAvatar,
...rest
} = this.props;
}: Props) {
return (
<Avatars {...rest}>
{overflow > 0 && (
@ -36,10 +33,9 @@ class Facepile extends React.Component<Props> {
))}
</Avatars>
);
}
}
function renderDefaultAvatar(user: User) {
function DefaultAvatar(user: User) {
return <Avatar user={user} src={user.avatarUrl} size={32} />;
}
@ -73,4 +69,4 @@ const Avatars = styled(Flex)`
cursor: pointer;
`;
export default inject("views", "presence")(withTheme(Facepile));
export default observer(Facepile);

View File

@ -8,13 +8,14 @@ type Props = {
title: React.Node,
subtitle?: React.Node,
actions?: React.Node,
border?: boolean,
};
const ListItem = ({ image, title, subtitle, actions }: Props) => {
const ListItem = ({ image, title, subtitle, actions, border }: Props) => {
const compact = !subtitle;
return (
<Wrapper compact={compact}>
<Wrapper compact={compact} $border={border}>
{image && <Image>{image}</Image>}
<Content align={compact ? "center" : undefined} column={!compact}>
<Heading>{title}</Heading>
@ -27,9 +28,11 @@ const ListItem = ({ image, title, subtitle, actions }: Props) => {
const Wrapper = styled.li`
display: flex;
padding: 10px 0;
padding: 8px 0;
margin: 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
border-bottom: 1px solid
${(props) =>
props.$border === false ? "transparent" : props.theme.divider};
&:last-child {
border-bottom: 0;
@ -59,7 +62,8 @@ const Content = styled(Flex)`
const Subtitle = styled.p`
margin: 0;
font-size: 14px;
color: ${(props) => props.theme.slate};
color: ${(props) => props.theme.textTertiary};
margin-top: -2px;
`;
const Actions = styled.div`

View File

@ -3,8 +3,8 @@ import * as React from "react";
import styled from "styled-components";
const Button = styled.button`
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
width: ${(props) => props.width || props.size}px;
height: ${(props) => props.height || props.size}px;
background: none;
border-radius: 4px;
line-height: 0;

34
app/components/Popover.js Normal file
View File

@ -0,0 +1,34 @@
// @flow
import * as React from "react";
import { Popover as ReakitPopover } from "reakit/Popover";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
type Props = {
children: React.Node,
width?: number,
};
function Popover({ children, width = 380, ...rest }: Props) {
return (
<ReakitPopover {...rest}>
<Contents width={width}>{children}</Contents>
</ReakitPopover>
);
}
const Contents = styled.div`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: 75% 0;
background: ${(props) => props.theme.menuBackground};
border-radius: 6px;
padding: 12px 24px;
max-height: 50vh;
overflow-y: scroll;
width: ${(props) => props.width}px;
box-shadow: ${(props) => props.theme.menuShadow};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
`;
export default Popover;

View File

@ -16,7 +16,6 @@ import Badge from "components/Badge";
import Breadcrumb, { Slash } from "components/Breadcrumb";
import Button from "components/Button";
import Collaborators from "components/Collaborators";
import Fade from "components/Fade";
import Header from "components/Header";
import Tooltip from "components/Tooltip";
import ShareButton from "./ShareButton";
@ -148,12 +147,10 @@ function DocumentHeader({
actions={
<>
{!isPublishing && isSaving && <Status>{t("Saving")}</Status>}
<Fade>
<Collaborators
document={document}
currentUserId={auth.user ? auth.user.id : undefined}
/>
</Fade>
{isEditing && !isTemplate && isNew && (
<Action>
<TemplatesMenu document={document} />

View File

@ -3,11 +3,10 @@ import { observer } from "mobx-react";
import { GlobeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { usePopoverState, Popover, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import Document from "models/Document";
import Button from "components/Button";
import Popover from "components/Popover";
import Tooltip from "components/Tooltip";
import SharePopover from "./SharePopover";
import useStores from "hooks/useStores";
@ -55,28 +54,14 @@ function ShareButton({ document }: Props) {
)}
</PopoverDisclosure>
<Popover {...popover} aria-label={t("Share")}>
<Contents>
<SharePopover
document={document}
share={share}
onSubmit={popover.hide}
/>
</Contents>
</Popover>
</>
);
}
const Contents = styled.div`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: 75% 0;
background: ${(props) => props.theme.menuBackground};
border-radius: 6px;
padding: 24px 24px 12px;
width: 380px;
box-shadow: ${(props) => props.theme.menuShadow};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
`;
export default observer(ShareButton);

View File

@ -109,7 +109,7 @@ function DocumentShare({ document, share, onSubmit }: Props) {
type="text"
label={t("Link")}
placeholder={`${t("Loading")}`}
value={share ? share.url : undefined}
value={share ? share.url : ""}
labelHidden
readOnly
/>
@ -126,7 +126,7 @@ function DocumentShare({ document, share, onSubmit }: Props) {
const Heading = styled.h2`
display: flex;
align-items: center;
margin-top: 0;
margin-top: 12px;
margin-left: -4px;
`;

View File

@ -23,6 +23,7 @@ Object {
"demote": true,
"promote": true,
"read": true,
"readDetails": true,
"suspend": true,
"update": false,
},
@ -74,39 +75,7 @@ Object {
"demote": true,
"promote": true,
"read": true,
"suspend": true,
"update": false,
},
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
},
],
"status": 200,
}
`;
exports[`#users.demote should demote an admin to viewer 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"isViewer": true,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
},
"ok": true,
"policies": Array [
Object {
"abilities": Object {
"activate": true,
"delete": true,
"demote": true,
"promote": true,
"read": true,
"readDetails": true,
"suspend": true,
"update": false,
},
@ -140,6 +109,41 @@ Object {
"demote": true,
"promote": true,
"read": true,
"readDetails": true,
"suspend": true,
"update": false,
},
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
},
],
"status": 200,
}
`;
exports[`#users.demote should demote an admin to viewer 1`] = `
Object {
"data": Object {
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
"createdAt": "2018-01-02T00:00:00.000Z",
"email": "user1@example.com",
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"isViewer": true,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
},
"ok": true,
"policies": Array [
Object {
"abilities": Object {
"activate": true,
"delete": true,
"demote": true,
"promote": true,
"read": true,
"readDetails": true,
"suspend": true,
"update": false,
},
@ -191,6 +195,7 @@ Object {
"demote": true,
"promote": false,
"read": true,
"readDetails": true,
"suspend": true,
"update": false,
},
@ -251,6 +256,7 @@ Object {
"demote": false,
"promote": false,
"read": true,
"readDetails": true,
"suspend": true,
"update": false,
},

View File

@ -10,7 +10,7 @@ import { presentUser, presentPolicies } from "../presenters";
import { Op } from "../sequelize";
import pagination from "./middlewares/pagination";
const { authorize } = policy;
const { can, authorize } = policy;
const router = new Router();
router.post("users.list", auth(), pagination(), async (ctx) => {
@ -23,10 +23,10 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
if (direction !== "ASC") direction = "DESC";
ctx.assertSort(sort, User);
const user = ctx.state.user;
const actor = ctx.state.user;
let where = {
teamId: user.teamId,
teamId: actor.teamId,
};
if (!includeSuspended) {
@ -56,10 +56,10 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
ctx.body = {
pagination: ctx.state.pagination,
data: users.map((listUser) =>
presentUser(listUser, { includeDetails: user.isAdmin })
data: users.map((user) =>
presentUser(user, { includeDetails: can(actor, "readDetails", user) })
),
policies: presentPolicies(user, users),
policies: presentPolicies(actor, users),
};
});
@ -75,11 +75,17 @@ router.post("users.count", auth(), async (ctx) => {
});
router.post("users.info", auth(), async (ctx) => {
const { user } = ctx.state;
const { id } = ctx.body;
const actor = ctx.state.user;
const user = id ? await User.findByPk(id) : actor;
authorize(actor, "read", user);
const includeDetails = can(actor, "readDetails", user);
ctx.body = {
data: presentUser(user),
policies: presentPolicies(user, [user]),
data: presentUser(user, { includeDetails }),
policies: presentPolicies(actor, [user]),
};
});
@ -128,8 +134,10 @@ router.post("users.promote", auth(), async (ctx) => {
ip: ctx.request.ip,
});
const includeDetails = can(actor, "readDetails", user);
ctx.body = {
data: presentUser(user, { includeDetails: true }),
data: presentUser(user, { includeDetails }),
policies: presentPolicies(actor, [user]),
};
});
@ -159,8 +167,10 @@ router.post("users.demote", auth(), async (ctx) => {
ip: ctx.request.ip,
});
const includeDetails = can(actor, "readDetails", user);
ctx.body = {
data: presentUser(user, { includeDetails: true }),
data: presentUser(user, { includeDetails }),
policies: presentPolicies(actor, [user]),
};
});
@ -179,8 +189,10 @@ router.post("users.suspend", auth(), async (ctx) => {
ip: ctx.request.ip,
});
const includeDetails = can(actor, "readDetails", user);
ctx.body = {
data: presentUser(user, { includeDetails: true }),
data: presentUser(user, { includeDetails }),
policies: presentPolicies(actor, [user]),
};
});
@ -205,8 +217,10 @@ router.post("users.activate", auth(), async (ctx) => {
ip: ctx.request.ip,
});
const includeDetails = can(actor, "readDetails", user);
ctx.body = {
data: presentUser(user, { includeDetails: true }),
data: presentUser(user, { includeDetails }),
policies: presentPolicies(actor, [user]),
};
});

View File

@ -87,7 +87,7 @@ describe("#users.list", () => {
});
describe("#users.info", () => {
it("should return known user", async () => {
it("should return current user with no id", async () => {
const user = await buildUser();
const res = await server.post("/api/users.info", {
body: { token: user.getJwtToken() },
@ -97,6 +97,33 @@ describe("#users.info", () => {
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(user.id);
expect(body.data.name).toEqual(user.name);
expect(body.data.email).toEqual(user.email);
});
it("should return user with permission", async () => {
const user = await buildUser();
const another = await buildUser({ teamId: user.teamId });
const res = await server.post("/api/users.info", {
body: { token: user.getJwtToken(), id: another.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(another.id);
expect(body.data.name).toEqual(another.name);
// no emails of other users
expect(body.data.email).toEqual(undefined);
});
it("should now return user without permission", async () => {
const user = await buildUser();
const another = await buildUser();
const res = await server.post("/api/users.info", {
body: { token: user.getJwtToken(), id: another.id },
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {

View File

@ -37,6 +37,12 @@ allow(User, ["activate", "suspend"], User, (actor, user) => {
throw new AdminRequiredError();
});
allow(User, "readDetails", User, (actor, user) => {
if (!user || user.teamId !== actor.teamId) return false;
if (user === actor) return true;
return actor.isAdmin;
});
allow(User, "promote", User, (actor, user) => {
if (!user || user.teamId !== actor.teamId) return false;
if (user.isAdmin || user.isSuspended) return false;

View File

@ -8,8 +8,6 @@ Object {
"isAdmin": undefined,
"isSuspended": undefined,
"isViewer": undefined,
"language": "en_US",
"lastActiveAt": undefined,
"name": "Test User",
}
`;
@ -22,8 +20,6 @@ Object {
"isAdmin": undefined,
"isSuspended": undefined,
"isViewer": undefined,
"language": "en_US",
"lastActiveAt": undefined,
"name": "Test User",
}
`;

View File

@ -20,16 +20,17 @@ export default (user: User, options: Options = {}): ?UserPresentation => {
const userData = {};
userData.id = user.id;
userData.createdAt = user.createdAt;
userData.lastActiveAt = user.lastActiveAt;
userData.name = user.name;
userData.isAdmin = user.isAdmin;
userData.isViewer = user.isViewer;
userData.isSuspended = user.isSuspended;
userData.avatarUrl = user.avatarUrl;
userData.language = user.language || process.env.DEFAULT_LANGUAGE || "en_US";
if (options.includeDetails) {
userData.lastActiveAt = user.lastActiveAt;
userData.email = user.email;
userData.language =
user.language || process.env.DEFAULT_LANGUAGE || "en_US";
}
return userData;

View File

@ -8,6 +8,7 @@
"Drafts": "Drafts",
"Templates": "Templates",
"Deleted Collection": "Deleted Collection",
"Viewers": "Viewers",
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
"Add a description": "Add a description",
"Collapse": "Collapse",
@ -29,6 +30,13 @@
"in": "in",
"nested document": "nested document",
"nested document_plural": "nested documents",
"Viewed by": "Viewed by",
"only you": "only you",
"person": "person",
"people": "people",
"Currently editing": "Currently editing",
"Currently viewing": "Currently viewing",
"Viewed {{ timeAgo }} ago": "Viewed {{ timeAgo }} ago",
"Insert column after": "Insert column after",
"Insert column before": "Insert column before",
"Insert row after": "Insert row after",
@ -382,7 +390,6 @@
"Active": "Active",
"Admins": "Admins",
"Suspended": "Suspended",
"Viewers": "Viewers",
"Everyone": "Everyone",
"No people to see here.": "No people to see here.",
"Profile saved": "Profile saved",