feat: add total users to people management screen (#1878)

* feat: add total users to pagination

* move this.total in runInAction callback

* add total counts + counts to people tabs

* progress: use raw pg query

* progress: add test

* fix: SQL interpolation

* Styling and translation of People page

Co-authored-by: Tim <timothychang94@gmail.com>
This commit is contained in:
Tom Moor
2021-02-09 20:13:09 -08:00
committed by GitHub
parent 097359bf7c
commit df472ac391
12 changed files with 215 additions and 31 deletions

View File

@@ -3,11 +3,15 @@ import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import { bounceIn } from "shared/styles/animations"; import { bounceIn } from "shared/styles/animations";
type Props = { type Props = {|
count: number, count: number,
}; |};
const Bubble = ({ count }: Props) => { const Bubble = ({ count }: Props) => {
if (!count) {
return null;
}
return <Count>{count}</Count>; return <Count>{count}</Count>;
}; };

View File

@@ -16,11 +16,11 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components"; import styled from "styled-components";
import CollectionNew from "scenes/CollectionNew"; import CollectionNew from "scenes/CollectionNew";
import Invite from "scenes/Invite"; import Invite from "scenes/Invite";
import Bubble from "components/Bubble";
import Flex from "components/Flex"; import Flex from "components/Flex";
import Modal from "components/Modal"; import Modal from "components/Modal";
import Scrollable from "components/Scrollable"; import Scrollable from "components/Scrollable";
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
import Bubble from "./components/Bubble";
import Collections from "./components/Collections"; import Collections from "./components/Collections";
import HeaderBlock from "./components/HeaderBlock"; import HeaderBlock from "./components/HeaderBlock";
import Section from "./components/Section"; import Section from "./components/Section";
@@ -118,9 +118,7 @@ function MainSidebar() {
label={ label={
<Drafts align="center"> <Drafts align="center">
{t("Drafts")} {t("Drafts")}
{documents.totalDrafts > 0 && ( <Bubble count={documents.totalDrafts} />
<Bubble count={documents.totalDrafts} />
)}
</Drafts> </Drafts>
} }
active={ active={

View File

@@ -10,8 +10,8 @@ type Props = {
const StyledNavLink = styled(NavLink)` const StyledNavLink = styled(NavLink)`
position: relative; position: relative;
display: inline-flex;
display: inline-block; align-items: center;
font-weight: 500; font-weight: 500;
font-size: 14px; font-size: 14px;
color: ${(props) => props.theme.textTertiary}; color: ${(props) => props.theme.textTertiary};

View File

@@ -8,6 +8,7 @@ const Tabs = styled.nav`
margin-bottom: 12px; margin-bottom: 12px;
overflow-y: auto; overflow-y: auto;
white-space: nowrap; white-space: nowrap;
transition: opacity 250ms ease-out;
`; `;
export const Separator = styled.span` export const Separator = styled.span`

View File

@@ -64,6 +64,10 @@ function CollectionMenu({
[history, collection.id] [history, collection.id]
); );
const stopPropagation = React.useCallback((ev: SyntheticEvent<>) => {
ev.stopPropagation();
}, []);
const handleImportDocument = React.useCallback( const handleImportDocument = React.useCallback(
(ev: SyntheticEvent<>) => { (ev: SyntheticEvent<>) => {
ev.preventDefault(); ev.preventDefault();
@@ -107,7 +111,7 @@ function CollectionMenu({
type="file" type="file"
ref={file} ref={file}
onChange={handleFilePicked} onChange={handleFilePicked}
onClick={(ev) => ev.stopPropagation()} onClick={stopPropagation}
accept={documents.importFileTypes.join(", ")} accept={documents.importFileTypes.join(", ")}
tabIndex="-1" tabIndex="-1"
/> />

View File

@@ -4,12 +4,13 @@ import { observable } from "mobx";
import { observer, inject } from "mobx-react"; import { observer, inject } from "mobx-react";
import { PlusIcon } from "outline-icons"; import { PlusIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { withTranslation, type TFunction, Trans } from "react-i18next";
import { type Match } from "react-router-dom"; import { type Match } from "react-router-dom";
import AuthStore from "stores/AuthStore"; import AuthStore from "stores/AuthStore";
import PoliciesStore from "stores/PoliciesStore"; import PoliciesStore from "stores/PoliciesStore";
import UsersStore from "stores/UsersStore"; import UsersStore from "stores/UsersStore";
import Invite from "scenes/Invite"; import Invite from "scenes/Invite";
import Bubble from "components/Bubble";
import Button from "components/Button"; import Button from "components/Button";
import CenteredContent from "components/CenteredContent"; import CenteredContent from "components/CenteredContent";
import Empty from "components/Empty"; import Empty from "components/Empty";
@@ -27,12 +28,20 @@ type Props = {
users: UsersStore, users: UsersStore,
policies: PoliciesStore, policies: PoliciesStore,
match: Match, match: Match,
t: TFunction,
}; };
@observer @observer
class People extends React.Component<Props> { class People extends React.Component<Props> {
@observable inviteModalOpen: boolean = false; @observable inviteModalOpen: boolean = false;
componentDidMount() {
const { team } = this.props.auth;
if (team) {
this.props.users.fetchCounts(team.id);
}
}
handleInviteModalOpen = () => { handleInviteModalOpen = () => {
this.inviteModalOpen = true; this.inviteModalOpen = true;
}; };
@@ -46,7 +55,7 @@ class People extends React.Component<Props> {
}; };
render() { render() {
const { auth, policies, match } = this.props; const { auth, policies, match, t } = this.props;
const { filter } = match.params; const { filter } = match.params;
const currentUser = auth.user; const currentUser = auth.user;
const team = auth.team; const team = auth.team;
@@ -65,15 +74,18 @@ class People extends React.Component<Props> {
} }
const can = policies.abilities(team.id); const can = policies.abilities(team.id);
const { counts } = this.props.users;
return ( return (
<CenteredContent> <CenteredContent>
<PageTitle title="People" /> <PageTitle title={t("People")} />
<h1>People</h1> <h1>{t("People")}</h1>
<HelpText> <HelpText>
Everyone that has signed into Outline appears here. Its possible that <Trans>
there are other users who have access through {team.signinMethods} but Everyone that has signed into Outline appears here. Its possible
havent signed in yet. that there are other users who have access through{" "}
{team.signinMethods} but havent signed in yet.
</Trans>
</HelpText> </HelpText>
<Button <Button
type="button" type="button"
@@ -84,37 +96,36 @@ class People extends React.Component<Props> {
icon={<PlusIcon />} icon={<PlusIcon />}
neutral neutral
> >
Invite people {t("Invite people")}
</Button> </Button>
<Tabs> <Tabs>
<Tab to="/settings/people" exact> <Tab to="/settings/people" exact>
Active {t("Active")} <Bubble count={counts.active} />
</Tab> </Tab>
<Tab to="/settings/people/admins" exact> <Tab to="/settings/people/admins" exact>
Admins {t("Admins")} <Bubble count={counts.admins} />
</Tab> </Tab>
{can.update && ( {can.update && (
<Tab to="/settings/people/suspended" exact> <Tab to="/settings/people/suspended" exact>
Suspended {t("Suspended")} <Bubble count={counts.suspended} />
</Tab> </Tab>
)} )}
<Tab to="/settings/people/all" exact> <Tab to="/settings/people/all" exact>
Everyone {t("Everyone")} <Bubble count={counts.all - counts.invited} />
</Tab> </Tab>
{can.invite && ( {can.invite && (
<> <>
<Separator /> <Separator />
<Tab to="/settings/people/invited" exact> <Tab to="/settings/people/invited" exact>
Invited {t("Invited")} <Bubble count={counts.invited} />
</Tab> </Tab>
</> </>
)} )}
</Tabs> </Tabs>
<PaginatedList <PaginatedList
items={users} items={users}
empty={<Empty>No people to see here.</Empty>} empty={<Empty>{t("No people to see here.")}</Empty>}
fetch={this.fetchPage} fetch={this.fetchPage}
renderItem={(item) => ( renderItem={(item) => (
<UserListItem <UserListItem
@@ -126,7 +137,7 @@ class People extends React.Component<Props> {
/> />
<Modal <Modal
title="Invite people" title={t("Invite people")}
onRequestClose={this.handleInviteModalClose} onRequestClose={this.handleInviteModalClose}
isOpen={this.inviteModalOpen} isOpen={this.inviteModalOpen}
> >
@@ -137,4 +148,8 @@ class People extends React.Component<Props> {
} }
} }
export default inject("auth", "users", "policies")(People); export default inject(
"auth",
"users",
"policies"
)(withTranslation()<People>(People));

View File

@@ -7,7 +7,7 @@ import BaseModel from "../models/BaseModel";
import type { PaginationParams } from "types"; import type { PaginationParams } from "types";
import { client } from "utils/ApiClient"; import { client } from "utils/ApiClient";
type Action = "list" | "info" | "create" | "update" | "delete"; type Action = "list" | "info" | "create" | "update" | "delete" | "count";
function modelNameFromClassName(string) { function modelNameFromClassName(string) {
return string.charAt(0).toLowerCase() + string.slice(1); return string.charAt(0).toLowerCase() + string.slice(1);
@@ -24,7 +24,7 @@ export default class BaseStore<T: BaseModel> {
model: Class<T>; model: Class<T>;
modelName: string; modelName: string;
rootStore: RootStore; rootStore: RootStore;
actions: Action[] = ["list", "info", "create", "update", "delete"]; actions: Action[] = ["list", "info", "create", "update", "delete", "count"];
constructor(rootStore: RootStore, model: Class<T>) { constructor(rootStore: RootStore, model: Class<T>) {
this.rootStore = rootStore; this.rootStore = rootStore;

View File

@@ -1,13 +1,21 @@
// @flow // @flow
import invariant from "invariant"; import invariant from "invariant";
import { filter, orderBy } from "lodash"; import { filter, orderBy } from "lodash";
import { computed, action, runInAction } from "mobx"; import { observable, computed, action, runInAction } from "mobx";
import User from "models/User"; import User from "models/User";
import BaseStore from "./BaseStore"; import BaseStore from "./BaseStore";
import RootStore from "./RootStore"; import RootStore from "./RootStore";
import { client } from "utils/ApiClient"; import { client } from "utils/ApiClient";
export default class UsersStore extends BaseStore<User> { export default class UsersStore extends BaseStore<User> {
@observable counts: {
active: number,
admins: number,
all: number,
invited: number,
suspended: number,
} = {};
constructor(rootStore: RootStore) { constructor(rootStore: RootStore) {
super(rootStore, User); super(rootStore, User);
} }
@@ -52,21 +60,25 @@ export default class UsersStore extends BaseStore<User> {
@action @action
promote = (user: User) => { promote = (user: User) => {
this.counts.admins += 1;
return this.actionOnUser("promote", user); return this.actionOnUser("promote", user);
}; };
@action @action
demote = (user: User) => { demote = (user: User) => {
this.counts.admins -= 1;
return this.actionOnUser("demote", user); return this.actionOnUser("demote", user);
}; };
@action @action
suspend = (user: User) => { suspend = (user: User) => {
this.counts.suspended += 1;
return this.actionOnUser("suspend", user); return this.actionOnUser("suspend", user);
}; };
@action @action
activate = (user: User) => { activate = (user: User) => {
this.counts.suspended -= 1;
return this.actionOnUser("activate", user); return this.actionOnUser("activate", user);
}; };
@@ -76,10 +88,39 @@ export default class UsersStore extends BaseStore<User> {
invariant(res && res.data, "Data should be available"); invariant(res && res.data, "Data should be available");
runInAction(`invite`, () => { runInAction(`invite`, () => {
res.data.users.forEach(this.add); res.data.users.forEach(this.add);
this.counts.invited += res.data.sent.length;
this.counts.all += res.data.sent.length;
}); });
return res.data; return res.data;
}; };
@action
fetchCounts = async (teamId: string): Promise<*> => {
const res = await client.post(`/users.count`, { teamId });
invariant(res && res.data, "Data should be available");
this.counts = res.data.counts;
return res.data;
};
@action
async delete(user: User, options: Object = {}) {
super.delete(user, options);
if (!user.isSuspended && user.lastActiveAt) {
this.counts.active -= 1;
}
if (user.isInvited) {
this.counts.invited -= 1;
}
if (user.isAdmin) {
this.counts.admins -= 1;
}
if (user.isSuspended) {
this.counts.suspended -= 1;
}
this.counts.all -= 1;
}
notInCollection = (collectionId: string, query: string = "") => { notInCollection = (collectionId: string, query: string = "") => {
const memberships = filter( const memberships = filter(
this.rootStore.memberships.orderedData, this.rootStore.memberships.orderedData,

View File

@@ -55,6 +55,17 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
}; };
}); });
router.post("users.count", auth(), async (ctx) => {
const { user } = ctx.state;
const counts = await User.getCounts(user.teamId);
ctx.body = {
data: {
counts,
},
};
});
router.post("users.info", auth(), async (ctx) => { router.post("users.info", auth(), async (ctx) => {
ctx.body = { ctx.body = {
data: presentUser(ctx.state.user), data: presentUser(ctx.state.user),

View File

@@ -2,7 +2,8 @@
import TestServer from "fetch-test-server"; import TestServer from "fetch-test-server";
import app from "../app"; import app from "../app";
import { buildUser } from "../test/factories"; import { buildTeam, buildUser } from "../test/factories";
import { flushdb, seed } from "../test/support"; import { flushdb, seed } from "../test/support";
const server = new TestServer(app.callback()); const server = new TestServer(app.callback());
@@ -353,3 +354,75 @@ describe("#users.activate", () => {
expect(body).toMatchSnapshot(); expect(body).toMatchSnapshot();
}); });
}); });
describe("#users.count", () => {
it("should count active users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(1);
expect(body.data.counts.admins).toEqual(0);
expect(body.data.counts.invited).toEqual(0);
expect(body.data.counts.suspended).toEqual(0);
expect(body.data.counts.active).toEqual(1);
});
it("should count admin users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id, isAdmin: true });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(1);
expect(body.data.counts.admins).toEqual(1);
expect(body.data.counts.invited).toEqual(0);
expect(body.data.counts.suspended).toEqual(0);
expect(body.data.counts.active).toEqual(1);
});
it("should count suspended users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
await buildUser({ teamId: team.id, suspendedAt: new Date() });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(2);
expect(body.data.counts.admins).toEqual(0);
expect(body.data.counts.invited).toEqual(0);
expect(body.data.counts.suspended).toEqual(1);
expect(body.data.counts.active).toEqual(1);
});
it("should count invited users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id, lastActiveAt: null });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(1);
expect(body.data.counts.admins).toEqual(0);
expect(body.data.counts.invited).toEqual(1);
expect(body.data.counts.suspended).toEqual(0);
expect(body.data.counts.active).toEqual(0);
});
it("should require authentication", async () => {
const res = await server.post("/api/users.count");
expect(res.status).toEqual(401);
});
});

View File

@@ -51,6 +51,9 @@ const User = sequelize.define(
isSuspended() { isSuspended() {
return !!this.suspendedAt; return !!this.suspendedAt;
}, },
isInvited() {
return !this.lastActiveAt;
},
avatarUrl() { avatarUrl() {
const original = this.getDataValue("avatarUrl"); const original = this.getDataValue("avatarUrl");
if (original) { if (original) {
@@ -267,4 +270,33 @@ User.afterCreate(async (user, options) => {
]); ]);
}); });
User.getCounts = async function (teamId: string) {
const countSql = `
SELECT
COUNT(CASE WHEN "suspendedAt" IS NOT NULL THEN 1 END) as "suspendedCount",
COUNT(CASE WHEN "isAdmin" = true THEN 1 END) as "adminCount",
COUNT(CASE WHEN "lastActiveAt" IS NULL THEN 1 END) as "invitedCount",
COUNT(CASE WHEN "suspendedAt" IS NULL AND "lastActiveAt" IS NOT NULL THEN 1 END) as "activeCount",
COUNT(*) as count
FROM users
WHERE "deletedAt" IS NULL
AND "teamId" = :teamId
`;
const results = await sequelize.query(countSql, {
type: sequelize.QueryTypes.SELECT,
replacements: {
teamId,
},
});
const counts = results[0];
return {
active: parseInt(counts.activeCount),
admins: parseInt(counts.adminCount),
all: parseInt(counts.count),
invited: parseInt(counts.invitedCount),
suspended: parseInt(counts.suspendedCount),
};
};
export default User; export default User;

View File

@@ -306,6 +306,12 @@
"Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base": "Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base", "Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base": "Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base",
"No documents found for your search filters. <1></1>Create a new document?": "No documents found for your search filters. <1></1>Create a new document?", "No documents found for your search filters. <1></1>Create a new document?": "No documents found for your search filters. <1></1>Create a new document?",
"Clear filters": "Clear filters", "Clear filters": "Clear filters",
"Everyone that has signed into Outline appears here. Its possible that there are other users who have access through {team.signinMethods} but havent signed in yet.": "Everyone that has signed into Outline appears here. Its possible that there are other users who have access through {team.signinMethods} but havent signed in yet.",
"Active": "Active",
"Admins": "Admins",
"Suspended": "Suspended",
"Everyone": "Everyone",
"No people to see here.": "No people to see here.",
"Profile saved": "Profile saved", "Profile saved": "Profile saved",
"Profile picture updated": "Profile picture updated", "Profile picture updated": "Profile picture updated",
"Unable to upload new profile picture": "Unable to upload new profile picture", "Unable to upload new profile picture": "Unable to upload new profile picture",
@@ -323,7 +329,6 @@
"You joined": "You joined", "You joined": "You joined",
"Joined": "Joined", "Joined": "Joined",
"{{ time }} ago.": "{{ time }} ago.", "{{ time }} ago.": "{{ time }} ago.",
"Suspended": "Suspended",
"Edit Profile": "Edit Profile", "Edit Profile": "Edit Profile",
"{{ userName }} hasnt updated any documents yet.": "{{ userName }} hasnt updated any documents yet." "{{ userName }} hasnt updated any documents yet.": "{{ userName }} hasnt updated any documents yet."
} }