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 { bounceIn } from "shared/styles/animations";
type Props = {
type Props = {|
count: number,
};
|};
const Bubble = ({ count }: Props) => {
if (!count) {
return null;
}
return <Count>{count}</Count>;
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,12 +4,13 @@ import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction, Trans } from "react-i18next";
import { type Match } from "react-router-dom";
import AuthStore from "stores/AuthStore";
import PoliciesStore from "stores/PoliciesStore";
import UsersStore from "stores/UsersStore";
import Invite from "scenes/Invite";
import Bubble from "components/Bubble";
import Button from "components/Button";
import CenteredContent from "components/CenteredContent";
import Empty from "components/Empty";
@@ -27,12 +28,20 @@ type Props = {
users: UsersStore,
policies: PoliciesStore,
match: Match,
t: TFunction,
};
@observer
class People extends React.Component<Props> {
@observable inviteModalOpen: boolean = false;
componentDidMount() {
const { team } = this.props.auth;
if (team) {
this.props.users.fetchCounts(team.id);
}
}
handleInviteModalOpen = () => {
this.inviteModalOpen = true;
};
@@ -46,7 +55,7 @@ class People extends React.Component<Props> {
};
render() {
const { auth, policies, match } = this.props;
const { auth, policies, match, t } = this.props;
const { filter } = match.params;
const currentUser = auth.user;
const team = auth.team;
@@ -65,15 +74,18 @@ class People extends React.Component<Props> {
}
const can = policies.abilities(team.id);
const { counts } = this.props.users;
return (
<CenteredContent>
<PageTitle title="People" />
<h1>People</h1>
<PageTitle title={t("People")} />
<h1>{t("People")}</h1>
<HelpText>
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.
<Trans>
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.
</Trans>
</HelpText>
<Button
type="button"
@@ -84,37 +96,36 @@ class People extends React.Component<Props> {
icon={<PlusIcon />}
neutral
>
Invite people
{t("Invite people")}
</Button>
<Tabs>
<Tab to="/settings/people" exact>
Active
{t("Active")} <Bubble count={counts.active} />
</Tab>
<Tab to="/settings/people/admins" exact>
Admins
{t("Admins")} <Bubble count={counts.admins} />
</Tab>
{can.update && (
<Tab to="/settings/people/suspended" exact>
Suspended
{t("Suspended")} <Bubble count={counts.suspended} />
</Tab>
)}
<Tab to="/settings/people/all" exact>
Everyone
{t("Everyone")} <Bubble count={counts.all - counts.invited} />
</Tab>
{can.invite && (
<>
<Separator />
<Tab to="/settings/people/invited" exact>
Invited
{t("Invited")} <Bubble count={counts.invited} />
</Tab>
</>
)}
</Tabs>
<PaginatedList
items={users}
empty={<Empty>No people to see here.</Empty>}
empty={<Empty>{t("No people to see here.")}</Empty>}
fetch={this.fetchPage}
renderItem={(item) => (
<UserListItem
@@ -126,7 +137,7 @@ class People extends React.Component<Props> {
/>
<Modal
title="Invite people"
title={t("Invite people")}
onRequestClose={this.handleInviteModalClose}
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 { client } from "utils/ApiClient";
type Action = "list" | "info" | "create" | "update" | "delete";
type Action = "list" | "info" | "create" | "update" | "delete" | "count";
function modelNameFromClassName(string) {
return string.charAt(0).toLowerCase() + string.slice(1);
@@ -24,7 +24,7 @@ export default class BaseStore<T: BaseModel> {
model: Class<T>;
modelName: string;
rootStore: RootStore;
actions: Action[] = ["list", "info", "create", "update", "delete"];
actions: Action[] = ["list", "info", "create", "update", "delete", "count"];
constructor(rootStore: RootStore, model: Class<T>) {
this.rootStore = rootStore;

View File

@@ -1,13 +1,21 @@
// @flow
import invariant from "invariant";
import { filter, orderBy } from "lodash";
import { computed, action, runInAction } from "mobx";
import { observable, computed, action, runInAction } from "mobx";
import User from "models/User";
import BaseStore from "./BaseStore";
import RootStore from "./RootStore";
import { client } from "utils/ApiClient";
export default class UsersStore extends BaseStore<User> {
@observable counts: {
active: number,
admins: number,
all: number,
invited: number,
suspended: number,
} = {};
constructor(rootStore: RootStore) {
super(rootStore, User);
}
@@ -52,21 +60,25 @@ export default class UsersStore extends BaseStore<User> {
@action
promote = (user: User) => {
this.counts.admins += 1;
return this.actionOnUser("promote", user);
};
@action
demote = (user: User) => {
this.counts.admins -= 1;
return this.actionOnUser("demote", user);
};
@action
suspend = (user: User) => {
this.counts.suspended += 1;
return this.actionOnUser("suspend", user);
};
@action
activate = (user: User) => {
this.counts.suspended -= 1;
return this.actionOnUser("activate", user);
};
@@ -76,10 +88,39 @@ export default class UsersStore extends BaseStore<User> {
invariant(res && res.data, "Data should be available");
runInAction(`invite`, () => {
res.data.users.forEach(this.add);
this.counts.invited += res.data.sent.length;
this.counts.all += res.data.sent.length;
});
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 = "") => {
const memberships = filter(
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) => {
ctx.body = {
data: presentUser(ctx.state.user),

View File

@@ -2,7 +2,8 @@
import TestServer from "fetch-test-server";
import app from "../app";
import { buildUser } from "../test/factories";
import { buildTeam, buildUser } from "../test/factories";
import { flushdb, seed } from "../test/support";
const server = new TestServer(app.callback());
@@ -353,3 +354,75 @@ describe("#users.activate", () => {
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() {
return !!this.suspendedAt;
},
isInvited() {
return !this.lastActiveAt;
},
avatarUrl() {
const original = this.getDataValue("avatarUrl");
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;

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",
"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",
"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 picture updated": "Profile picture updated",
"Unable to upload new profile picture": "Unable to upload new profile picture",
@@ -323,7 +329,6 @@
"You joined": "You joined",
"Joined": "Joined",
"{{ time }} ago.": "{{ time }} ago.",
"Suspended": "Suspended",
"Edit Profile": "Edit Profile",
"{{ userName }} hasnt updated any documents yet.": "{{ userName }} hasnt updated any documents yet."
}