[CORL-139, CORL-140] Community (#2239)

* feat: Add table ui component

* feat: community user table

* feat: filters and role change

* fix: add some comments

* fix: user viewer

* fix: snapshots

* test: add tests

* fix: better popover experience

* fix: test

* chore: use enum

* feat: prevent server side setting your own role

* fix: cleanup
This commit is contained in:
Kiwi
2019-03-22 20:13:11 +00:00
committed by Wyatt Johnson
parent e70f6f5c7f
commit 538e1fca9d
76 changed files with 2016 additions and 179 deletions
@@ -49,10 +49,12 @@ it("redirect to redirectPath when already logged in", async () => {
initLocalState: localRecord => {
localRecord.setValue(true, "loggedIn");
localRecord.setValue(createAccessToken(), "accessToken");
localRecord.setValue("/admin/community", "redirectPath");
localRecord.setValue("/admin/moderate/pending", "redirectPath");
},
});
await wait(() =>
expect(window.location.toString()).toBe("http://localhost/admin/community")
expect(window.location.toString()).toBe(
"http://localhost/admin/moderate/pending"
)
);
});
@@ -0,0 +1,388 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders community 1`] = `
<div
className="MainLayout-root Community-root"
data-testid="community-container"
>
<div
className="HorizontalGutter-root HorizontalGutter-double"
>
<div
className="Flex-root Flex-flex Flex-doubleItemGutter"
>
<fieldset
className="FieldSet-root"
>
<legend
className="Typography-root Typography-bodyCopyBold Typography-colorTextPrimary UserTableFilter-legend"
>
Search
</legend>
<div
className="TextField-root"
>
<input
aria-label="Search by username or email address"
className="TextField-input UserTableFilter-textField TextField-colorRegular"
placeholder="Search by username or email address..."
type="text"
/>
</div>
</fieldset>
<fieldset
className="FieldSet-root"
>
<legend
className="Typography-root Typography-bodyCopyBold Typography-colorTextPrimary UserTableFilter-legend"
>
Show Me
</legend>
<span
className="SelectField-root"
>
<select
aria-label="Search by role"
className="SelectField-select"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
value=""
>
<option
value=""
>
Everyone
</option>
<optgroup
label="Audience"
>
<option
value="COMMENTER"
>
Commenters
</option>
</optgroup>
<optgroup
label="Organization"
>
<option
value="ADMIN"
>
Admins
</option>
<option
value="MODERATOR"
>
Moderators
</option>
<option
value="STAFF"
>
Staff
</option>
</optgroup>
</select>
<span
aria-hidden={true}
className="SelectField-afterWrapper"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
>
expand_more
</span>
</span>
</span>
</fieldset>
</div>
<div
className="HorizontalGutter-root HorizontalGutter-double"
>
<table
className="Table-root Table-fullWidth"
>
<thead
className="TableHead-root"
>
<tr
className="TableRow-root"
>
<th
className="TableCell-root UserTable-usernameColumn TableCell-header"
>
Username
</th>
<th
className="TableCell-root UserTable-emailColumn TableCell-header"
>
Email
</th>
<th
className="TableCell-root UserTable-memberSinceColumn TableCell-header"
>
Member Since
</th>
<th
className="TableCell-root UserTable-roleColumn TableCell-header"
>
Role
</th>
</tr>
</thead>
<tbody
className="TableBody-root"
>
<tr
className="TableRow-root TableRow-body"
>
<td
className="TableCell-root TableCell-body"
>
Markus
</td>
<td
className="TableCell-root TableCell-body"
>
<a
className="TextLink-root"
href="mailto:markus@test.com"
>
markus@test.com
</a>
</td>
<td
className="TableCell-root TableCell-body"
>
07/06/18
</td>
<td
className="TableCell-root TableCell-body"
>
<span
className="RoleText-root"
>
Admin
</span>
</td>
</tr>
<tr
className="TableRow-root TableRow-body"
>
<td
className="TableCell-root TableCell-body"
>
Lukas
</td>
<td
className="TableCell-root TableCell-body"
>
<a
className="TextLink-root"
href="mailto:lukas@test.com"
>
lukas@test.com
</a>
</td>
<td
className="TableCell-root TableCell-body"
>
07/06/18
</td>
<td
className="TableCell-root TableCell-body"
>
<div
className="Popover-root"
>
<button
aria-label="Change role"
className="BaseButton-root Button-root RoleChange-button Button-sizeSmall Button-colorRegular Button-variantRegular"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span
className="RoleText-root"
>
Moderator
</span>
<span
aria-hidden="true"
className="Icon-root Icon-lg ButtonIcon-root"
>
arrow_drop_down
</span>
</button>
<div
aria-hidden={true}
aria-labelledby="userMenu-ariainfo"
id="userMenu"
role="popup"
>
<div
className="AriaInfo-root"
id="userMenu-ariainfo"
>
A dropdown to change the user role
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
`;
exports[`renders empty community 1`] = `
<div
className="MainLayout-root Community-root"
data-testid="community-container"
>
<div
className="HorizontalGutter-root HorizontalGutter-double"
>
<div
className="Flex-root Flex-flex Flex-doubleItemGutter"
>
<fieldset
className="FieldSet-root"
>
<legend
className="Typography-root Typography-bodyCopyBold Typography-colorTextPrimary UserTableFilter-legend"
>
Search
</legend>
<div
className="TextField-root"
>
<input
aria-label="Search by username or email address"
className="TextField-input UserTableFilter-textField TextField-colorRegular"
placeholder="Search by username or email address..."
type="text"
/>
</div>
</fieldset>
<fieldset
className="FieldSet-root"
>
<legend
className="Typography-root Typography-bodyCopyBold Typography-colorTextPrimary UserTableFilter-legend"
>
Show Me
</legend>
<span
className="SelectField-root"
>
<select
aria-label="Search by role"
className="SelectField-select"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
value=""
>
<option
value=""
>
Everyone
</option>
<optgroup
label="Audience"
>
<option
value="COMMENTER"
>
Commenters
</option>
</optgroup>
<optgroup
label="Organization"
>
<option
value="ADMIN"
>
Admins
</option>
<option
value="MODERATOR"
>
Moderators
</option>
<option
value="STAFF"
>
Staff
</option>
</optgroup>
</select>
<span
aria-hidden={true}
className="SelectField-afterWrapper"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
>
expand_more
</span>
</span>
</span>
</fieldset>
</div>
<div
className="HorizontalGutter-root HorizontalGutter-double"
>
<table
className="Table-root Table-fullWidth"
>
<thead
className="TableHead-root"
>
<tr
className="TableRow-root"
>
<th
className="TableCell-root UserTable-usernameColumn TableCell-header"
>
Username
</th>
<th
className="TableCell-root UserTable-emailColumn TableCell-header"
>
Email
</th>
<th
className="TableCell-root UserTable-memberSinceColumn TableCell-header"
>
Member Since
</th>
<th
className="TableCell-root UserTable-roleColumn TableCell-header"
>
Role
</th>
</tr>
</thead>
<tbody
className="TableBody-root"
/>
</table>
<p
className="Typography-root Typography-bodyCopyBold Typography-colorTextPrimary Typography-alignCenter EmptyMessage-root"
>
We could not find anyone in your community matching your criteria.
</p>
</div>
</div>
</div>
`;
@@ -0,0 +1,190 @@
import { get, merge } from "lodash";
import TestRenderer from "react-test-renderer";
import sinon from "sinon";
import {
createSinonStub,
replaceHistoryLocation,
waitForElement,
waitUntilThrow,
within,
} from "talk-framework/testHelpers";
import create from "../create";
import {
communityUsers,
emptyCommunityUsers,
settings,
users,
} from "../fixtures";
beforeEach(async () => {
replaceHistoryLocation("http://localhost/admin/community");
});
const createTestRenderer = async (resolver: any = {}) => {
const resolvers = {
...resolver,
Query: {
settings: sinon
.stub()
.returns(merge({}, settings, get(resolver, "Query.settings"))),
users: sinon.stub().callsFake((_, data) => {
expectAndFail(data.role).toBeFalsy();
return communityUsers;
}),
viewer: sinon.stub().returns(users[0]),
...resolver.Query,
},
};
const { testRenderer } = create({
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
initLocalState: localRecord => {
localRecord.setValue(true, "loggedIn");
},
});
const container = await waitForElement(() =>
within(testRenderer.root).getByTestID("community-container")
);
return { testRenderer, container };
};
it("renders community", async () => {
const { container } = await createTestRenderer();
expect(within(container).toJSON()).toMatchSnapshot();
});
it("renders empty community", async () => {
const { container } = await createTestRenderer({
Query: {
users: sinon.stub().returns(emptyCommunityUsers),
},
});
expect(within(container).toJSON()).toMatchSnapshot();
});
it("filter by role", async () => {
const { container } = await createTestRenderer({
Query: {
users: createSinonStub(
s => s.onFirstCall().returns(communityUsers),
s =>
s.onSecondCall().callsFake((_, data) => {
expectAndFail(data.role).toBe("COMMENTER");
return emptyCommunityUsers;
})
),
},
});
const selectField = within(container).getByLabelText("Search by role");
const commentersOption = within(selectField).getByText("Commenters");
TestRenderer.act(() => {
selectField.props.onChange({
target: { value: commentersOption.props.value.toString() },
});
// TODO: Fix act warnings until await Promise.resolve();
// or whatever comes out at https://github.com/facebook/react/issues/14769
});
await waitForElement(() =>
within(container).getByText("We could not find anyone", { exact: false })
);
});
it("can't change viewer role", async () => {
const viewer = users[0];
const { container } = await createTestRenderer();
const viewerRow = within(container).getByText(viewer.username, {
selector: "tr",
});
expect(() => within(viewerRow).getByLabelText("Change role")).toThrow();
});
it("change user role", async () => {
const user = users[1];
const updateUserRole = sinon.stub().callsFake((_: any, data: any) => {
expectAndFail(data.input).toMatchObject({
userID: user.id,
role: "STAFF",
});
const userRecord = merge({}, user, { role: data.input.role });
return {
user: userRecord,
clientMutationId: data.input.clientMutationId,
};
});
const { container } = await createTestRenderer({
Mutation: { updateUserRole },
});
const userRow = within(container).getByText(user.username, {
selector: "tr",
});
TestRenderer.act(() => {
within(userRow)
.getByLabelText("Change role")
.props.onClick();
});
const popup = within(userRow).getByLabelText(
"A dropdown to change the user role"
);
TestRenderer.act(() => {
within(popup)
.getByText("Staff")
.props.onClick();
});
within(userRow).getByText("Staff");
expect(updateUserRole.called).toBe(true);
});
it("can't change role as a moderator", async () => {
const viewer = users[1];
const { container } = await createTestRenderer({
Query: {
viewer: sinon.stub().returns(viewer),
},
});
expect(() => within(container).getByLabelText("Change role")).toThrow();
});
it("load more", async () => {
const { container } = await createTestRenderer({
Query: {
users: createSinonStub(
s =>
s.onFirstCall().returns({
edges: [
{ node: users[0], cursor: users[0].createdAt },
{ node: users[1], cursor: users[1].createdAt },
],
pageInfo: { endCursor: users[1].createdAt, hasNextPage: true },
}),
s =>
s.onSecondCall().returns({
edges: [{ node: users[2], cursor: users[2].createdAt }],
pageInfo: { endCursor: users[2].createdAt, hasNextPage: false },
})
),
},
});
const loadMore = within(container).getByText("Load More");
TestRenderer.act(() => {
loadMore.props.onClick();
});
// Wait for load more to disappear.
await waitUntilThrow(() => within(container).getByText("Load More"));
// Make sure third user was added.
within(container).getByText(users[2].username);
});
+12 -8
View File
@@ -1,7 +1,7 @@
import { EventEmitter2 } from "eventemitter2";
import { IResolvers } from "graphql-tools";
import React from "react";
import TestRenderer from "react-test-renderer";
import TestRenderer, { ReactTestRenderer } from "react-test-renderer";
import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime";
import EntryContainer from "talk-admin/containers/EntryContainer";
@@ -41,6 +41,7 @@ export default function create(params: CreateParams) {
const context: TalkContext = {
relayEnvironment: environment,
locales: ["en-US"],
localeBundles: [createFluentBundle()],
localStorage: createPromisifiedStorage(),
sessionStorage: createPromisifiedStorage(),
@@ -52,12 +53,15 @@ export default function create(params: CreateParams) {
clearSession: () => Promise.resolve(),
};
const testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<EntryContainer />
</TalkContextProvider>,
{ createNodeMock }
);
let testRenderer: ReactTestRenderer;
TestRenderer.act(() => {
testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<EntryContainer />
</TalkContextProvider>,
{ createNodeMock }
);
});
return { context, testRenderer };
return { context, testRenderer: testRenderer! };
}
+21 -3
View File
@@ -246,27 +246,32 @@ export const moderationActions = [
},
];
export const baseUser = {
profiles: [{ __typename: "LocalProfile" }],
createdAt: "2018-07-06T18:24:00.000Z",
};
export const users = [
{
...baseUser,
id: "user-0",
username: "Markus",
email: "markus@test.com",
role: "ADMIN",
profiles: [{ __typename: "LocalProfile" }],
},
{
...baseUser,
id: "user-1",
username: "Lukas",
email: "lukas@test.com",
role: "MODERATOR",
profiles: [{ __typename: "LocalProfile" }],
},
{
...baseUser,
id: "user-2",
username: "Isabelle",
email: "isabelle@test.com",
role: "COMMENTER",
profiles: [{ __typename: "LocalProfile" }],
},
];
@@ -372,3 +377,16 @@ export const emptyRejectedComments = {
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
};
export const communityUsers = {
edges: [
{ node: users[0], cursor: users[0].createdAt },
{ node: users[1], cursor: users[1].createdAt },
],
pageInfo: { endCursor: null, hasNextPage: false },
};
export const emptyCommunityUsers = {
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
};