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:
@ -17,6 +17,7 @@ type Props = {
|
|||||||
isEditing: boolean,
|
isEditing: boolean,
|
||||||
isCurrentUser: boolean,
|
isCurrentUser: boolean,
|
||||||
lastViewedAt: string,
|
lastViewedAt: string,
|
||||||
|
profileOnClick: boolean,
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -65,7 +66,11 @@ class AvatarWithPresence extends React.Component<Props> {
|
|||||||
<AvatarWrapper isPresent={isPresent}>
|
<AvatarWrapper isPresent={isPresent}>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={user.avatarUrl}
|
src={user.avatarUrl}
|
||||||
onClick={this.handleOpenProfile}
|
onClick={
|
||||||
|
this.props.profileOnClick === false
|
||||||
|
? undefined
|
||||||
|
: this.handleOpenProfile
|
||||||
|
}
|
||||||
size={32}
|
size={32}
|
||||||
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
|
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
|
||||||
/>
|
/>
|
||||||
|
@ -1,79 +1,93 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { sortBy, keyBy } from "lodash";
|
import { filter } from "lodash";
|
||||||
import { observer, inject } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
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 Document from "models/Document";
|
||||||
import { AvatarWithPresence } from "components/Avatar";
|
import { AvatarWithPresence } from "components/Avatar";
|
||||||
|
import DocumentViews from "components/DocumentViews";
|
||||||
import Facepile from "components/Facepile";
|
import Facepile from "components/Facepile";
|
||||||
|
import NudeButton from "components/NudeButton";
|
||||||
|
import Popover from "components/Popover";
|
||||||
|
import useStores from "hooks/useStores";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
views: ViewsStore,
|
|
||||||
presence: DocumentPresenceStore,
|
|
||||||
document: Document,
|
document: Document,
|
||||||
currentUserId: string,
|
currentUserId: string,
|
||||||
};
|
|};
|
||||||
|
|
||||||
@observer
|
function Collaborators(props: Props) {
|
||||||
class Collaborators extends React.Component<Props> {
|
const { t } = useTranslation();
|
||||||
componentDidMount() {
|
const { users, presence } = useStores();
|
||||||
if (!this.props.document.isDeleted) {
|
const { document, currentUserId } = props;
|
||||||
this.props.views.fetchPage({ documentId: this.props.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 presentUsers = filter(users.orderedData, (user) =>
|
||||||
|
presentIds.includes(user.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
// load any users we don't know about
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (users.isFetching) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
presentIds.forEach((userId) => {
|
||||||
const { document, presence, views, currentUserId } = this.props;
|
if (!users.get(userId)) {
|
||||||
let documentPresence = presence.get(document.id);
|
return users.fetch(userId);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
);
|
});
|
||||||
|
}, [document, users, presentIds]);
|
||||||
|
|
||||||
const viewersKeyedByUserId = keyBy(mostRecentViewers, (v) => v.user.id);
|
const popover = usePopoverState({
|
||||||
const overflow = documentViews.length - mostRecentViewers.length;
|
gutter: 0,
|
||||||
|
placement: "bottom-end",
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FacepileHiddenOnMobile
|
<>
|
||||||
users={mostRecentViewers.map((v) => v.user)}
|
<PopoverDisclosure {...popover}>
|
||||||
overflow={overflow}
|
{(props) => (
|
||||||
renderAvatar={(user) => {
|
<NudeButton width={presentUsers.length * 32} height={32} {...props}>
|
||||||
const isPresent = presentIds.includes(user.id);
|
<FacepileHiddenOnMobile
|
||||||
const isEditing = editingIds.includes(user.id);
|
users={presentUsers}
|
||||||
const { lastViewedAt } = viewersKeyedByUserId[user.id];
|
renderAvatar={(user) => {
|
||||||
|
const isPresent = presentIds.includes(user.id);
|
||||||
|
const isEditing = editingIds.includes(user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AvatarWithPresence
|
<AvatarWithPresence
|
||||||
key={user.id}
|
key={user.id}
|
||||||
user={user}
|
user={user}
|
||||||
lastViewedAt={lastViewedAt}
|
isPresent={isPresent}
|
||||||
isPresent={isPresent}
|
isEditing={isEditing}
|
||||||
isEditing={isEditing}
|
isCurrentUser={currentUserId === user.id}
|
||||||
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)`
|
const FacepileHiddenOnMobile = styled(Facepile)`
|
||||||
@ -82,4 +96,4 @@ const FacepileHiddenOnMobile = styled(Facepile)`
|
|||||||
`};
|
`};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default inject("views", "presence")(Collaborators);
|
export default observer(Collaborators);
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { useObserver } from "mobx-react";
|
import { useObserver } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import Document from "models/Document";
|
import Document from "models/Document";
|
||||||
import DocumentMeta from "components/DocumentMeta";
|
import DocumentMeta from "components/DocumentMeta";
|
||||||
|
import DocumentViews from "components/DocumentViews";
|
||||||
|
import Popover from "components/Popover";
|
||||||
import useStores from "../hooks/useStores";
|
import useStores from "../hooks/useStores";
|
||||||
|
|
||||||
type Props = {|
|
type Props = {|
|
||||||
@ -12,22 +16,41 @@ type Props = {|
|
|||||||
to?: string,
|
to?: string,
|
||||||
|};
|
|};
|
||||||
|
|
||||||
function DocumentMetaWithViews({ to, isDraft, document }: Props) {
|
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
|
||||||
const { views } = useStores();
|
const { views } = useStores();
|
||||||
|
const { t } = useTranslation();
|
||||||
const documentViews = useObserver(() => views.inDocument(document.id));
|
const documentViews = useObserver(() => views.inDocument(document.id));
|
||||||
const totalViewers = documentViews.length;
|
const totalViewers = documentViews.length;
|
||||||
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
|
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
|
||||||
|
|
||||||
|
const popover = usePopoverState({
|
||||||
|
gutter: 8,
|
||||||
|
placement: "bottom",
|
||||||
|
modal: true,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Meta document={document} to={to}>
|
<Meta document={document} to={to} {...rest}>
|
||||||
{totalViewers && !isDraft ? (
|
{totalViewers && !isDraft ? (
|
||||||
<>
|
<PopoverDisclosure {...popover}>
|
||||||
· Viewed by{" "}
|
{(props) => (
|
||||||
{onlyYou
|
<>
|
||||||
? "only you"
|
·
|
||||||
: `${totalViewers} ${totalViewers === 1 ? "person" : "people"}`}
|
<a {...props}>
|
||||||
</>
|
{t("Viewed by")}{" "}
|
||||||
|
{onlyYou
|
||||||
|
? t("only you")
|
||||||
|
: `${totalViewers} ${
|
||||||
|
totalViewers === 1 ? t("person") : t("people")
|
||||||
|
}`}
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PopoverDisclosure>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
|
||||||
|
<DocumentViews document={document} isOpen={popover.visible} />
|
||||||
|
</Popover>
|
||||||
</Meta>
|
</Meta>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
82
app/components/DocumentViews.js
Normal file
82
app/components/DocumentViews.js
Normal 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);
|
@ -1,45 +1,41 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { observer, inject } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import styled, { withTheme } from "styled-components";
|
import styled from "styled-components";
|
||||||
import User from "models/User";
|
import User from "models/User";
|
||||||
import Avatar from "components/Avatar";
|
import Avatar from "components/Avatar";
|
||||||
import Flex from "components/Flex";
|
import Flex from "components/Flex";
|
||||||
|
|
||||||
type Props = {
|
type Props = {|
|
||||||
users: User[],
|
users: User[],
|
||||||
size?: number,
|
size?: number,
|
||||||
overflow: number,
|
overflow: number,
|
||||||
renderAvatar: (user: User) => React.Node,
|
onClick?: (event: SyntheticEvent<>) => mixed,
|
||||||
};
|
renderAvatar?: (user: User) => React.Node,
|
||||||
|
|};
|
||||||
|
|
||||||
@observer
|
function Facepile({
|
||||||
class Facepile extends React.Component<Props> {
|
users,
|
||||||
render() {
|
overflow,
|
||||||
const {
|
size = 32,
|
||||||
users,
|
renderAvatar = DefaultAvatar,
|
||||||
overflow,
|
...rest
|
||||||
size = 32,
|
}: Props) {
|
||||||
renderAvatar = renderDefaultAvatar,
|
return (
|
||||||
...rest
|
<Avatars {...rest}>
|
||||||
} = this.props;
|
{overflow > 0 && (
|
||||||
|
<More size={size}>
|
||||||
return (
|
<span>+{overflow}</span>
|
||||||
<Avatars {...rest}>
|
</More>
|
||||||
{overflow > 0 && (
|
)}
|
||||||
<More size={size}>
|
{users.map((user) => (
|
||||||
<span>+{overflow}</span>
|
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
|
||||||
</More>
|
))}
|
||||||
)}
|
</Avatars>
|
||||||
{users.map((user) => (
|
);
|
||||||
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
|
|
||||||
))}
|
|
||||||
</Avatars>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDefaultAvatar(user: User) {
|
function DefaultAvatar(user: User) {
|
||||||
return <Avatar user={user} src={user.avatarUrl} size={32} />;
|
return <Avatar user={user} src={user.avatarUrl} size={32} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,4 +69,4 @@ const Avatars = styled(Flex)`
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default inject("views", "presence")(withTheme(Facepile));
|
export default observer(Facepile);
|
||||||
|
@ -8,13 +8,14 @@ type Props = {
|
|||||||
title: React.Node,
|
title: React.Node,
|
||||||
subtitle?: React.Node,
|
subtitle?: React.Node,
|
||||||
actions?: 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;
|
const compact = !subtitle;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper compact={compact}>
|
<Wrapper compact={compact} $border={border}>
|
||||||
{image && <Image>{image}</Image>}
|
{image && <Image>{image}</Image>}
|
||||||
<Content align={compact ? "center" : undefined} column={!compact}>
|
<Content align={compact ? "center" : undefined} column={!compact}>
|
||||||
<Heading>{title}</Heading>
|
<Heading>{title}</Heading>
|
||||||
@ -27,9 +28,11 @@ const ListItem = ({ image, title, subtitle, actions }: Props) => {
|
|||||||
|
|
||||||
const Wrapper = styled.li`
|
const Wrapper = styled.li`
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 10px 0;
|
padding: 8px 0;
|
||||||
margin: 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 {
|
&:last-child {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
@ -59,7 +62,8 @@ const Content = styled(Flex)`
|
|||||||
const Subtitle = styled.p`
|
const Subtitle = styled.p`
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: ${(props) => props.theme.slate};
|
color: ${(props) => props.theme.textTertiary};
|
||||||
|
margin-top: -2px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Actions = styled.div`
|
const Actions = styled.div`
|
||||||
|
@ -3,8 +3,8 @@ import * as React from "react";
|
|||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
|
||||||
const Button = styled.button`
|
const Button = styled.button`
|
||||||
width: ${(props) => props.size}px;
|
width: ${(props) => props.width || props.size}px;
|
||||||
height: ${(props) => props.size}px;
|
height: ${(props) => props.height || props.size}px;
|
||||||
background: none;
|
background: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
|
34
app/components/Popover.js
Normal file
34
app/components/Popover.js
Normal 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;
|
@ -16,7 +16,6 @@ import Badge from "components/Badge";
|
|||||||
import Breadcrumb, { Slash } from "components/Breadcrumb";
|
import Breadcrumb, { Slash } from "components/Breadcrumb";
|
||||||
import Button from "components/Button";
|
import Button from "components/Button";
|
||||||
import Collaborators from "components/Collaborators";
|
import Collaborators from "components/Collaborators";
|
||||||
import Fade from "components/Fade";
|
|
||||||
import Header from "components/Header";
|
import Header from "components/Header";
|
||||||
import Tooltip from "components/Tooltip";
|
import Tooltip from "components/Tooltip";
|
||||||
import ShareButton from "./ShareButton";
|
import ShareButton from "./ShareButton";
|
||||||
@ -148,12 +147,10 @@ function DocumentHeader({
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{!isPublishing && isSaving && <Status>{t("Saving")}…</Status>}
|
{!isPublishing && isSaving && <Status>{t("Saving")}…</Status>}
|
||||||
<Fade>
|
<Collaborators
|
||||||
<Collaborators
|
document={document}
|
||||||
document={document}
|
currentUserId={auth.user ? auth.user.id : undefined}
|
||||||
currentUserId={auth.user ? auth.user.id : undefined}
|
/>
|
||||||
/>
|
|
||||||
</Fade>
|
|
||||||
{isEditing && !isTemplate && isNew && (
|
{isEditing && !isTemplate && isNew && (
|
||||||
<Action>
|
<Action>
|
||||||
<TemplatesMenu document={document} />
|
<TemplatesMenu document={document} />
|
||||||
|
@ -3,11 +3,10 @@ import { observer } from "mobx-react";
|
|||||||
import { GlobeIcon } from "outline-icons";
|
import { GlobeIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation, Trans } from "react-i18next";
|
import { useTranslation, Trans } from "react-i18next";
|
||||||
import { usePopoverState, Popover, PopoverDisclosure } from "reakit/Popover";
|
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||||
import styled from "styled-components";
|
|
||||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
|
||||||
import Document from "models/Document";
|
import Document from "models/Document";
|
||||||
import Button from "components/Button";
|
import Button from "components/Button";
|
||||||
|
import Popover from "components/Popover";
|
||||||
import Tooltip from "components/Tooltip";
|
import Tooltip from "components/Tooltip";
|
||||||
import SharePopover from "./SharePopover";
|
import SharePopover from "./SharePopover";
|
||||||
import useStores from "hooks/useStores";
|
import useStores from "hooks/useStores";
|
||||||
@ -55,28 +54,14 @@ function ShareButton({ document }: Props) {
|
|||||||
)}
|
)}
|
||||||
</PopoverDisclosure>
|
</PopoverDisclosure>
|
||||||
<Popover {...popover} aria-label={t("Share")}>
|
<Popover {...popover} aria-label={t("Share")}>
|
||||||
<Contents>
|
<SharePopover
|
||||||
<SharePopover
|
document={document}
|
||||||
document={document}
|
share={share}
|
||||||
share={share}
|
onSubmit={popover.hide}
|
||||||
onSubmit={popover.hide}
|
/>
|
||||||
/>
|
|
||||||
</Contents>
|
|
||||||
</Popover>
|
</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);
|
export default observer(ShareButton);
|
||||||
|
@ -109,7 +109,7 @@ function DocumentShare({ document, share, onSubmit }: Props) {
|
|||||||
type="text"
|
type="text"
|
||||||
label={t("Link")}
|
label={t("Link")}
|
||||||
placeholder={`${t("Loading")}…`}
|
placeholder={`${t("Loading")}…`}
|
||||||
value={share ? share.url : undefined}
|
value={share ? share.url : ""}
|
||||||
labelHidden
|
labelHidden
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
@ -126,7 +126,7 @@ function DocumentShare({ document, share, onSubmit }: Props) {
|
|||||||
const Heading = styled.h2`
|
const Heading = styled.h2`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 0;
|
margin-top: 12px;
|
||||||
margin-left: -4px;
|
margin-left: -4px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@ -23,6 +23,7 @@ Object {
|
|||||||
"demote": true,
|
"demote": true,
|
||||||
"promote": true,
|
"promote": true,
|
||||||
"read": true,
|
"read": true,
|
||||||
|
"readDetails": true,
|
||||||
"suspend": true,
|
"suspend": true,
|
||||||
"update": false,
|
"update": false,
|
||||||
},
|
},
|
||||||
@ -74,39 +75,7 @@ Object {
|
|||||||
"demote": true,
|
"demote": true,
|
||||||
"promote": true,
|
"promote": true,
|
||||||
"read": true,
|
"read": true,
|
||||||
"suspend": true,
|
"readDetails": 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,
|
|
||||||
"suspend": true,
|
"suspend": true,
|
||||||
"update": false,
|
"update": false,
|
||||||
},
|
},
|
||||||
@ -140,6 +109,41 @@ Object {
|
|||||||
"demote": true,
|
"demote": true,
|
||||||
"promote": true,
|
"promote": true,
|
||||||
"read": 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,
|
"suspend": true,
|
||||||
"update": false,
|
"update": false,
|
||||||
},
|
},
|
||||||
@ -191,6 +195,7 @@ Object {
|
|||||||
"demote": true,
|
"demote": true,
|
||||||
"promote": false,
|
"promote": false,
|
||||||
"read": true,
|
"read": true,
|
||||||
|
"readDetails": true,
|
||||||
"suspend": true,
|
"suspend": true,
|
||||||
"update": false,
|
"update": false,
|
||||||
},
|
},
|
||||||
@ -251,6 +256,7 @@ Object {
|
|||||||
"demote": false,
|
"demote": false,
|
||||||
"promote": false,
|
"promote": false,
|
||||||
"read": true,
|
"read": true,
|
||||||
|
"readDetails": true,
|
||||||
"suspend": true,
|
"suspend": true,
|
||||||
"update": false,
|
"update": false,
|
||||||
},
|
},
|
||||||
|
@ -10,7 +10,7 @@ import { presentUser, presentPolicies } from "../presenters";
|
|||||||
import { Op } from "../sequelize";
|
import { Op } from "../sequelize";
|
||||||
import pagination from "./middlewares/pagination";
|
import pagination from "./middlewares/pagination";
|
||||||
|
|
||||||
const { authorize } = policy;
|
const { can, authorize } = policy;
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
router.post("users.list", auth(), pagination(), async (ctx) => {
|
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";
|
if (direction !== "ASC") direction = "DESC";
|
||||||
ctx.assertSort(sort, User);
|
ctx.assertSort(sort, User);
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const actor = ctx.state.user;
|
||||||
|
|
||||||
let where = {
|
let where = {
|
||||||
teamId: user.teamId,
|
teamId: actor.teamId,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!includeSuspended) {
|
if (!includeSuspended) {
|
||||||
@ -56,10 +56,10 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
|
|||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
pagination: ctx.state.pagination,
|
pagination: ctx.state.pagination,
|
||||||
data: users.map((listUser) =>
|
data: users.map((user) =>
|
||||||
presentUser(listUser, { includeDetails: user.isAdmin })
|
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) => {
|
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 = {
|
ctx.body = {
|
||||||
data: presentUser(user),
|
data: presentUser(user, { includeDetails }),
|
||||||
policies: presentPolicies(user, [user]),
|
policies: presentPolicies(actor, [user]),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -128,8 +134,10 @@ router.post("users.promote", auth(), async (ctx) => {
|
|||||||
ip: ctx.request.ip,
|
ip: ctx.request.ip,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const includeDetails = can(actor, "readDetails", user);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: presentUser(user, { includeDetails: true }),
|
data: presentUser(user, { includeDetails }),
|
||||||
policies: presentPolicies(actor, [user]),
|
policies: presentPolicies(actor, [user]),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -159,8 +167,10 @@ router.post("users.demote", auth(), async (ctx) => {
|
|||||||
ip: ctx.request.ip,
|
ip: ctx.request.ip,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const includeDetails = can(actor, "readDetails", user);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: presentUser(user, { includeDetails: true }),
|
data: presentUser(user, { includeDetails }),
|
||||||
policies: presentPolicies(actor, [user]),
|
policies: presentPolicies(actor, [user]),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -179,8 +189,10 @@ router.post("users.suspend", auth(), async (ctx) => {
|
|||||||
ip: ctx.request.ip,
|
ip: ctx.request.ip,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const includeDetails = can(actor, "readDetails", user);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: presentUser(user, { includeDetails: true }),
|
data: presentUser(user, { includeDetails }),
|
||||||
policies: presentPolicies(actor, [user]),
|
policies: presentPolicies(actor, [user]),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -205,8 +217,10 @@ router.post("users.activate", auth(), async (ctx) => {
|
|||||||
ip: ctx.request.ip,
|
ip: ctx.request.ip,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const includeDetails = can(actor, "readDetails", user);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: presentUser(user, { includeDetails: true }),
|
data: presentUser(user, { includeDetails }),
|
||||||
policies: presentPolicies(actor, [user]),
|
policies: presentPolicies(actor, [user]),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -87,7 +87,7 @@ describe("#users.list", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("#users.info", () => {
|
describe("#users.info", () => {
|
||||||
it("should return known user", async () => {
|
it("should return current user with no id", async () => {
|
||||||
const user = await buildUser();
|
const user = await buildUser();
|
||||||
const res = await server.post("/api/users.info", {
|
const res = await server.post("/api/users.info", {
|
||||||
body: { token: user.getJwtToken() },
|
body: { token: user.getJwtToken() },
|
||||||
@ -97,6 +97,33 @@ describe("#users.info", () => {
|
|||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.data.id).toEqual(user.id);
|
expect(body.data.id).toEqual(user.id);
|
||||||
expect(body.data.name).toEqual(user.name);
|
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 () => {
|
it("should require authentication", async () => {
|
||||||
|
@ -37,6 +37,12 @@ allow(User, ["activate", "suspend"], User, (actor, user) => {
|
|||||||
throw new AdminRequiredError();
|
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) => {
|
allow(User, "promote", User, (actor, user) => {
|
||||||
if (!user || user.teamId !== actor.teamId) return false;
|
if (!user || user.teamId !== actor.teamId) return false;
|
||||||
if (user.isAdmin || user.isSuspended) return false;
|
if (user.isAdmin || user.isSuspended) return false;
|
||||||
|
@ -8,8 +8,6 @@ Object {
|
|||||||
"isAdmin": undefined,
|
"isAdmin": undefined,
|
||||||
"isSuspended": undefined,
|
"isSuspended": undefined,
|
||||||
"isViewer": undefined,
|
"isViewer": undefined,
|
||||||
"language": "en_US",
|
|
||||||
"lastActiveAt": undefined,
|
|
||||||
"name": "Test User",
|
"name": "Test User",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@ -22,8 +20,6 @@ Object {
|
|||||||
"isAdmin": undefined,
|
"isAdmin": undefined,
|
||||||
"isSuspended": undefined,
|
"isSuspended": undefined,
|
||||||
"isViewer": undefined,
|
"isViewer": undefined,
|
||||||
"language": "en_US",
|
|
||||||
"lastActiveAt": undefined,
|
|
||||||
"name": "Test User",
|
"name": "Test User",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
@ -20,16 +20,17 @@ export default (user: User, options: Options = {}): ?UserPresentation => {
|
|||||||
const userData = {};
|
const userData = {};
|
||||||
userData.id = user.id;
|
userData.id = user.id;
|
||||||
userData.createdAt = user.createdAt;
|
userData.createdAt = user.createdAt;
|
||||||
userData.lastActiveAt = user.lastActiveAt;
|
|
||||||
userData.name = user.name;
|
userData.name = user.name;
|
||||||
userData.isAdmin = user.isAdmin;
|
userData.isAdmin = user.isAdmin;
|
||||||
userData.isViewer = user.isViewer;
|
userData.isViewer = user.isViewer;
|
||||||
userData.isSuspended = user.isSuspended;
|
userData.isSuspended = user.isSuspended;
|
||||||
userData.avatarUrl = user.avatarUrl;
|
userData.avatarUrl = user.avatarUrl;
|
||||||
userData.language = user.language || process.env.DEFAULT_LANGUAGE || "en_US";
|
|
||||||
|
|
||||||
if (options.includeDetails) {
|
if (options.includeDetails) {
|
||||||
|
userData.lastActiveAt = user.lastActiveAt;
|
||||||
userData.email = user.email;
|
userData.email = user.email;
|
||||||
|
userData.language =
|
||||||
|
user.language || process.env.DEFAULT_LANGUAGE || "en_US";
|
||||||
}
|
}
|
||||||
|
|
||||||
return userData;
|
return userData;
|
||||||
|
@ -8,6 +8,7 @@
|
|||||||
"Drafts": "Drafts",
|
"Drafts": "Drafts",
|
||||||
"Templates": "Templates",
|
"Templates": "Templates",
|
||||||
"Deleted Collection": "Deleted Collection",
|
"Deleted Collection": "Deleted Collection",
|
||||||
|
"Viewers": "Viewers",
|
||||||
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
|
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
|
||||||
"Add a description": "Add a description",
|
"Add a description": "Add a description",
|
||||||
"Collapse": "Collapse",
|
"Collapse": "Collapse",
|
||||||
@ -29,6 +30,13 @@
|
|||||||
"in": "in",
|
"in": "in",
|
||||||
"nested document": "nested document",
|
"nested document": "nested document",
|
||||||
"nested document_plural": "nested documents",
|
"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 after": "Insert column after",
|
||||||
"Insert column before": "Insert column before",
|
"Insert column before": "Insert column before",
|
||||||
"Insert row after": "Insert row after",
|
"Insert row after": "Insert row after",
|
||||||
@ -382,7 +390,6 @@
|
|||||||
"Active": "Active",
|
"Active": "Active",
|
||||||
"Admins": "Admins",
|
"Admins": "Admins",
|
||||||
"Suspended": "Suspended",
|
"Suspended": "Suspended",
|
||||||
"Viewers": "Viewers",
|
|
||||||
"Everyone": "Everyone",
|
"Everyone": "Everyone",
|
||||||
"No people to see here.": "No people to see here.",
|
"No people to see here.": "No people to see here.",
|
||||||
"Profile saved": "Profile saved",
|
"Profile saved": "Profile saved",
|
||||||
|
Reference in New Issue
Block a user