mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
[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:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+5
-5
@@ -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,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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user