[CORL 131] User Menu Dropdown (#2225)

* chore: simplify popover

* feat: add dropdown component

* feat: user menu

* chore: add comment

* chore: refactor i18n name

* fix: linting

* fix: test

* fix: address review comments

* fix: update snapshot
This commit is contained in:
Kiwi
2019-03-20 21:26:05 +01:00
committed by GitHub
parent 41db413bea
commit 7ce02620e9
45 changed files with 713 additions and 229 deletions
@@ -1,16 +1,20 @@
import React from "react";
import { createRenderer } from "react-test-renderer/shallow";
import { removeFragmentRefs } from "talk-framework/testHelpers";
import { PropTypesOf } from "talk-framework/types";
import App from "./App";
const AppN = removeFragmentRefs(App);
it("renders correctly", () => {
const props: PropTypesOf<typeof App> = {
const props: PropTypesOf<typeof AppN> = {
me: {},
children: "child",
};
const renderer = createRenderer();
renderer.render(<App {...props} />);
renderer.render(<AppN {...props} />);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
+9 -3
View File
@@ -1,16 +1,22 @@
import React, { StatelessComponent } from "react";
import { PropTypesOf } from "talk-framework/types";
import { Logo } from "talk-ui/components";
import { AppBar, Begin, Divider, End } from "talk-ui/components/AppBar";
import SignOutButtonContainer from "../containers/SignOutButtonContainer";
import UserMenuContainer from "../containers/UserMenuContainer";
import DecisionHistoryButton from "./DecisionHistoryButton";
import Navigation from "./Navigation";
import Version from "./Version";
import styles from "./App.css";
const App: StatelessComponent = ({ children }) => (
interface Props {
me: PropTypesOf<typeof UserMenuContainer>["me"];
children: React.ReactNode;
}
const App: StatelessComponent<Props> = ({ children, me }) => (
<div className={styles.root}>
<AppBar gutterBegin gutterEnd>
<Begin itemGutter="double">
@@ -23,7 +29,7 @@ const App: StatelessComponent = ({ children }) => (
<End>
<DecisionHistoryButton />
<Divider />
<SignOutButtonContainer id="navigation-signOutButton" />
<UserMenuContainer me={me} />
</End>
</AppBar>
{children}
@@ -1,56 +1,43 @@
import React from "react";
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { oncePerFrame } from "talk-common/utils";
import { BaseButton, ClickOutside, Icon, Popover } from "talk-ui/components";
import DecisionHistoryQuery from "../views/decisionHistory/queries/DecisionHistoryQuery";
import styles from "./DecisionHistoryButton.css";
class DecisionHistoryButton extends React.Component {
// Helper that prevents calling toggleVisibility more then once per frame.
// In essence this means we'll process an event only once.
// This might happen, when clicking on the button which will
// cause its onClick to happen as well as onClickOutside.
private toggleVisibilityOncePerFrame = oncePerFrame(
(toggleVisibility: () => void) => toggleVisibility()
);
const popoverID = "decision-history-popover";
public render() {
const popoverID = `decision-history-popover`;
return (
<Popover
data-testid="decisionHistory-popover"
id={popoverID}
placement="bottom-end"
description="A dialog showing a permalink to the comment"
classes={{ popover: styles.popover }}
body={({ toggleVisibility }) => {
const hide = () =>
this.toggleVisibilityOncePerFrame(toggleVisibility);
return (
<ClickOutside onClickOutside={hide}>
<div>
<DecisionHistoryQuery onClosePopover={hide} />
</div>
</ClickOutside>
);
}}
>
{({ toggleVisibility, forwardRef, visible }) => (
<BaseButton
onClick={() => this.toggleVisibilityOncePerFrame(toggleVisibility)}
aria-controls={popoverID}
ref={forwardRef}
className={styles.historyIcon}
data-testid="decisionHistory-toggle"
>
<Icon size="lg">history</Icon>
</BaseButton>
)}
</Popover>
);
}
}
const DecisionHistoryButton: StatelessComponent = () => (
<Localized id="decisionHistory-popover" attrs={{ description: true }}>
<Popover
data-testid="decisionHistory-popover"
id={popoverID}
placement="bottom-end"
description="A dialog showing the decision history"
classes={{ popover: styles.popover }}
body={({ toggleVisibility }) => (
<ClickOutside onClickOutside={toggleVisibility}>
<div>
<DecisionHistoryQuery onClosePopover={toggleVisibility} />
</div>
</ClickOutside>
)}
>
{({ toggleVisibility, ref, visible }) => (
<BaseButton
onClick={toggleVisibility}
aria-controls={popoverID}
ref={ref}
className={styles.historyIcon}
data-testid="decisionHistory-toggle"
>
<Icon size="lg">history</Icon>
</BaseButton>
)}
</Popover>
</Localized>
);
export default DecisionHistoryButton;
@@ -1,5 +0,0 @@
.root {
font-weight: var(--font-weight-bold);
font-size: calc(18rem / var(--rem-base));
letter-spacing: calc(-0.2em / 18);
}
@@ -1,21 +0,0 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Button } from "talk-ui/components";
import styles from "./SignOutButton.css";
interface Props {
id?: string;
onClick: React.EventHandler<React.MouseEvent>;
}
const SignOutButton: StatelessComponent<Props> = props => (
<Localized id="navigation-signOutButton">
<Button id={props.id} onClick={props.onClick} className={styles.root}>
Sign Out
</Button>
</Localized>
);
export default SignOutButton;
@@ -0,0 +1,11 @@
.button {
padding-top: 0;
padding-bottom: 0;
margin-left: 5px;
}
.buttonText {
font-size: calc(18rem / var(--rem-base));
letter-spacing: calc(-0.2em / 18);
margin-left: 2px;
margin-right: 1px;
}
@@ -4,14 +4,14 @@ import { createRenderer } from "react-test-renderer/shallow";
import { PropTypesOf } from "talk-framework/types";
import SignOutButton from "./SignOutButton";
import UserMenu from "./UserMenu";
it("renders correctly", () => {
const props: PropTypesOf<typeof SignOutButton> = {
id: "id",
onClick: noop,
const props: PropTypesOf<typeof UserMenu> = {
username: "Admin",
onSignOut: noop as any,
};
const renderer = createRenderer();
renderer.render(<SignOutButton {...props} />);
renderer.render(<UserMenu {...props} />);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
@@ -0,0 +1,79 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import {
Button,
ButtonIcon,
ClickOutside,
Dropdown,
DropdownButton,
DropdownDivider,
Popover,
} from "talk-ui/components";
import styles from "./UserMenu.css";
interface Props {
username: string;
onSignOut: React.EventHandler<React.MouseEvent>;
}
const UserMenu: StatelessComponent<Props> = props => (
<Localized id="userMenu-popover" attrs={{ description: true }}>
<Popover
id="userMenu"
placement="bottom-end"
description="A dialog of the user menu with related links and actions"
body={({ toggleVisibility }) => (
<ClickOutside onClickOutside={toggleVisibility}>
<Dropdown>
<Localized id="userMenu-viewLatestRelease">
<DropdownButton
href="https://github.com/coralproject/talk/releases/latest"
target="_blank"
rel="noopener"
>
View Latest Release
</DropdownButton>
</Localized>
<Localized id="userMenu-reportBug">
<DropdownButton
href="https://github.com/coralproject/talk/issues/new"
target="_blank"
rel="noopener"
>
Report a Bug or Give Feedback
</DropdownButton>
</Localized>
<DropdownDivider />
<Localized id="userMenu-signOut">
<DropdownButton onClick={props.onSignOut} blankAdornment>
Sign Out
</DropdownButton>
</Localized>
</Dropdown>
</ClickOutside>
)}
>
{({ toggleVisibility, ref, visible }) => (
<Button
className={styles.button}
onClick={toggleVisibility}
ref={ref}
variant="regular"
size="small"
>
<ButtonIcon size="lg">account_circle</ButtonIcon>
<span className={styles.buttonText}>{props.username}</span>
{
<ButtonIcon size="lg">
{visible ? "arrow_drop_up" : "arrow_drop_down"}
</ButtonIcon>
}
</Button>
)}
</Popover>
</Localized>
);
export default UserMenu;
@@ -22,8 +22,8 @@ exports[`renders correctly 1`] = `
<withPropsOnChange(End)>
<DecisionHistoryButton />
<withPropsOnChange(Divider) />
<withContext(createMutationContainer(RedirectAppContainer))
id="navigation-signOutButton"
<withContext(createMutationContainer(Relay(UserMenuContainer)))
me={Object {}}
/>
</withPropsOnChange(End)>
</withPropsOnChange(AppBar)>
@@ -1,15 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<Localized
id="navigation-signOutButton"
>
<ForwardRef(forwardRef)
className="SignOutButton-root"
id="id"
onClick={[Function]}
>
Sign Out
</ForwardRef(forwardRef)>
</Localized>
`;
@@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<Localized
attrs={
Object {
"description": true,
}
}
id="userMenu-popover"
>
<withPropsOnChange(Popover)
body={[Function]}
description="A dialog of the user menu with related links and actions"
id="userMenu"
placement="bottom-end"
>
[Function]
</withPropsOnChange(Popover)>
</Localized>
`;
@@ -1,29 +1,34 @@
import { BrowserProtocol, queryMiddleware } from "farce";
import { createFarceRouter, createRender } from "found";
import { Resolver } from "found-relay";
import React from "react";
import React, { StatelessComponent } from "react";
import { TalkContextConsumer } from "talk-framework/lib/bootstrap/TalkContext";
import routeConfig from "../routeConfig";
import NotFound from "../routes/NotFound";
import { AppContainerQueryResponse } from "talk-admin/__generated__/AppContainerQuery.graphql";
const Router = createFarceRouter({
historyProtocol: new BrowserProtocol(),
historyMiddlewares: [queryMiddleware],
routeConfig,
render: createRender({
renderError: ({ error }) => (
<div>{error.status === 404 ? <NotFound /> : "Error"}</div>
),
}),
});
import { graphql } from "talk-framework/lib/relay";
import { withRouteConfig } from "talk-framework/lib/router";
const AppContainer: StatelessComponent = () => (
<TalkContextConsumer>
{({ relayEnvironment }) => (
<Router resolver={new Resolver(relayEnvironment)} />
)}
</TalkContextConsumer>
);
import App from "../components/App";
export default AppContainer;
interface Props {
data: AppContainerQueryResponse | null;
}
class AppContainer extends React.Component<Props> {
public render() {
return (
<App me={this.props.data && this.props.data.me}>
{this.props.children}
</App>
);
}
}
const enhanced = withRouteConfig({
query: graphql`
query AppContainerQuery {
me {
...UserMenuContainer_me
}
}
`,
})(AppContainer);
export default enhanced;
@@ -0,0 +1,29 @@
import { BrowserProtocol, queryMiddleware } from "farce";
import { createFarceRouter, createRender } from "found";
import { Resolver } from "found-relay";
import React, { StatelessComponent } from "react";
import { TalkContextConsumer } from "talk-framework/lib/bootstrap/TalkContext";
import routeConfig from "../routeConfig";
import NotFound from "../routes/NotFound";
const Router = createFarceRouter({
historyProtocol: new BrowserProtocol(),
historyMiddlewares: [queryMiddleware],
routeConfig,
render: createRender({
renderError: ({ error }) => (
<div>{error.status === 404 ? <NotFound /> : "Error"}</div>
),
}),
});
const EntryContainer: StatelessComponent = () => (
<TalkContextConsumer>
{({ relayEnvironment }) => (
<Router resolver={new Resolver(relayEnvironment)} />
)}
</TalkContextConsumer>
);
export default EntryContainer;
@@ -1,20 +0,0 @@
import React from "react";
import { SignOutMutation, withSignOutMutation } from "talk-framework/mutations";
import SignOutButton from "../components/SignOutButton";
interface Props {
signOut: SignOutMutation;
id?: string;
}
class RedirectAppContainer extends React.Component<Props> {
private handleClick = () => this.props.signOut();
public render() {
return <SignOutButton id={this.props.id} onClick={this.handleClick} />;
}
}
const enhanced = withSignOutMutation(RedirectAppContainer);
export default enhanced;
@@ -0,0 +1,36 @@
import React from "react";
import { UserMenuContainer_me as MeData } from "talk-admin/__generated__/UserMenuContainer_me.graphql";
import { graphql, withFragmentContainer } from "talk-framework/lib/relay";
import { SignOutMutation, withSignOutMutation } from "talk-framework/mutations";
import UserMenu from "../components/UserMenu";
interface Props {
signOut: SignOutMutation;
me: MeData | null;
}
class UserMenuContainer extends React.Component<Props> {
private handleSignOut = () => this.props.signOut();
public render() {
return (
<UserMenu
onSignOut={this.handleSignOut}
username={(this.props.me && this.props.me.username) || ""}
/>
);
}
}
const enhanced = withSignOutMutation(
withFragmentContainer<Props>({
me: graphql`
fragment UserMenuContainer_me on User {
username
}
`,
})(UserMenuContainer)
);
export default enhanced;
+2 -2
View File
@@ -2,7 +2,7 @@ import React, { StatelessComponent } from "react";
import ReactDOM from "react-dom";
import { createManaged } from "talk-framework/lib/bootstrap";
import AppContainer from "./containers/AppContainer";
import EntryContainer from "./containers/EntryContainer";
import { initLocalState } from "./local";
import localesData from "./locales";
@@ -15,7 +15,7 @@ async function main() {
const Index: StatelessComponent = () => (
<ManagedTalkContextProvider>
<AppContainer />
<EntryContainer />
</ManagedTalkContextProvider>
);
+2 -2
View File
@@ -1,7 +1,7 @@
import { makeRouteConfig, Redirect, Route } from "found";
import React from "react";
import App from "./components/App";
import AppContainer from "./containers/AppContainer";
import AuthCheckContainer from "./containers/AuthCheckContainer";
import Community from "./routes/community/components/Community";
import ConfigureContainer from "./routes/configure/containers/ConfigureContainer";
@@ -25,7 +25,7 @@ import Stories from "./routes/stories/components/Stories";
export default makeRouteConfig(
<Route path="admin">
<Route {...AuthCheckContainer.routeConfig}>
<Route Component={App}>
<Route {...AppContainer.routeConfig}>
<Redirect from="/" to="/admin/moderate" />
<Route
path="moderate/comment/:commentID"
@@ -46,6 +46,14 @@ it("logs out", async () => {
.once()
.returns({});
const userMenu = await waitForElement(() =>
within(testRenderer.root).getByText(users[0].username, {
selector: "button",
})
);
userMenu.props.onClick();
const signOutButton = await waitForElement(() =>
within(testRenderer.root).getByText("Sign Out")
);
+2 -2
View File
@@ -4,7 +4,7 @@ import React from "react";
import TestRenderer from "react-test-renderer";
import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime";
import AppContainer from "talk-admin/containers/AppContainer";
import EntryContainer from "talk-admin/containers/EntryContainer";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import { PostMessageService } from "talk-framework/lib/postMessage";
import { RestClient } from "talk-framework/lib/rest";
@@ -54,7 +54,7 @@ export default function create(params: CreateParams) {
const testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppContainer />
<EntryContainer />
</TalkContextProvider>,
{ createNodeMock }
);
@@ -465,7 +465,7 @@ exports[`renders decision history popover button 1`] = `
className="AriaInfo-root"
id="decision-history-popover-ariainfo"
>
A dialog showing a permalink to the comment
A dialog showing the decision history
</div>
</div>
</div>