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:
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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. It’s possible that
|
||||
there are other users who have access through {team.signinMethods} but
|
||||
haven’t signed in yet.
|
||||
<Trans>
|
||||
Everyone that has signed into Outline appears here. It’s possible
|
||||
that there are other users who have access through{" "}
|
||||
{team.signinMethods} but haven’t 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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. It’s possible that there are other users who have access through {team.signinMethods} but haven’t signed in yet.": "Everyone that has signed into Outline appears here. It’s possible that there are other users who have access through {team.signinMethods} but haven’t 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 }} hasn’t updated any documents yet.": "{{ userName }} hasn’t updated any documents yet."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user