mirror of
https://github.com/wassname/talk.git
synced 2026-07-27 11:28:12 +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>
|
||||
|
||||
+12
-26
@@ -1,7 +1,6 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React from "react";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import { oncePerFrame } from "talk-common/utils";
|
||||
import {
|
||||
Button,
|
||||
ButtonIcon,
|
||||
@@ -19,39 +18,26 @@ interface PermalinkProps {
|
||||
url: string;
|
||||
}
|
||||
|
||||
class Permalink extends React.Component<PermalinkProps> {
|
||||
// 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()
|
||||
);
|
||||
|
||||
public render() {
|
||||
const { commentID, url } = this.props;
|
||||
const popoverID = `permalink-popover-${commentID}`;
|
||||
return (
|
||||
const Permalink: StatelessComponent<PermalinkProps> = ({ commentID, url }) => {
|
||||
const popoverID = `permalink-popover-${commentID}`;
|
||||
return (
|
||||
<Localized id="comments-permalinkPopover" attrs={{ description: true }}>
|
||||
<Popover
|
||||
id={popoverID}
|
||||
placement="top-start"
|
||||
description="A dialog showing a permalink to the comment"
|
||||
classes={{ popover: styles.popover }}
|
||||
body={({ toggleVisibility }) => (
|
||||
<ClickOutside
|
||||
onClickOutside={() =>
|
||||
this.toggleVisibilityOncePerFrame(toggleVisibility)
|
||||
}
|
||||
>
|
||||
<ClickOutside onClickOutside={toggleVisibility}>
|
||||
<PermalinkPopover permalinkURL={url} />
|
||||
</ClickOutside>
|
||||
)}
|
||||
>
|
||||
{({ toggleVisibility, forwardRef, visible }) => (
|
||||
{({ toggleVisibility, ref, visible }) => (
|
||||
<Button
|
||||
onClick={() => this.toggleVisibilityOncePerFrame(toggleVisibility)}
|
||||
onClick={toggleVisibility}
|
||||
aria-controls={popoverID}
|
||||
ref={forwardRef}
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
active={visible}
|
||||
size="small"
|
||||
@@ -65,8 +51,8 @@ class Permalink extends React.Component<PermalinkProps> {
|
||||
</Button>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
}
|
||||
</Localized>
|
||||
);
|
||||
};
|
||||
|
||||
export default Permalink;
|
||||
|
||||
+16
-30
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
import { oncePerFrame } from "talk-common/utils";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import { ClickOutside, Popover } from "talk-ui/components";
|
||||
|
||||
import ReportCommentView from "../../views/reportComment";
|
||||
@@ -13,53 +13,39 @@ interface Props {
|
||||
reported: boolean;
|
||||
}
|
||||
|
||||
class ReportButtonWithPopover extends React.Component<Props> {
|
||||
// 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()
|
||||
);
|
||||
|
||||
public render() {
|
||||
const { comment, reported } = this.props;
|
||||
const popoverID = `report-popover-${comment.id}`;
|
||||
return (
|
||||
const ReportButtonWithPopover: React.StatelessComponent<Props> = ({
|
||||
comment,
|
||||
reported,
|
||||
}) => {
|
||||
const popoverID = `report-popover-${comment.id}`;
|
||||
return (
|
||||
<Localized id="comments-reportPopover" attrs={{ description: true }}>
|
||||
<Popover
|
||||
id={popoverID}
|
||||
placement="top-end"
|
||||
description="A dialog for reporting comments"
|
||||
body={({ toggleVisibility, scheduleUpdate }) => (
|
||||
<ClickOutside
|
||||
onClickOutside={() =>
|
||||
this.toggleVisibilityOncePerFrame(toggleVisibility)
|
||||
}
|
||||
>
|
||||
<ClickOutside onClickOutside={toggleVisibility}>
|
||||
<ReportCommentView
|
||||
comment={comment}
|
||||
onClose={() =>
|
||||
this.toggleVisibilityOncePerFrame(toggleVisibility)
|
||||
}
|
||||
onClose={toggleVisibility}
|
||||
onResize={scheduleUpdate}
|
||||
/>
|
||||
</ClickOutside>
|
||||
)}
|
||||
>
|
||||
{({ toggleVisibility, forwardRef, visible }) => (
|
||||
{({ toggleVisibility, ref, visible }) => (
|
||||
<ReportButton
|
||||
onClick={() =>
|
||||
!reported && this.toggleVisibilityOncePerFrame(toggleVisibility)
|
||||
}
|
||||
onClick={evt => !reported && toggleVisibility(evt)}
|
||||
aria-controls={popoverID}
|
||||
ref={forwardRef}
|
||||
ref={ref}
|
||||
active={visible}
|
||||
reported={reported}
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
}
|
||||
</Localized>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportButtonWithPopover;
|
||||
|
||||
@@ -24,6 +24,7 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
anchor?: boolean;
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
|
||||
/**
|
||||
* This prop can be used to add custom classnames.
|
||||
|
||||
@@ -9,8 +9,6 @@ import BaseButton, { BaseButtonProps } from "../BaseButton";
|
||||
|
||||
import styles from "./Button.css";
|
||||
|
||||
// This should extend from BaseButton instead but we can't because of this bug
|
||||
// TODO: add bug link.
|
||||
interface Props extends Omit<BaseButtonProps, "ref"> {
|
||||
/** If set renders an anchor tag instead */
|
||||
anchor?: boolean;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.root {
|
||||
composes: menuItem from "talk-ui/shared/typography.css";
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: calc(0.5 * var(--spacing-unit)) var(--spacing-unit);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.root:active {
|
||||
background-color: var(--palette-grey-lighter);
|
||||
}
|
||||
|
||||
.mouseHover {
|
||||
background-color: var(--palette-grey-lightest);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--palette-grey-main);
|
||||
margin-left: var(--spacing-unit);
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.blankAdornment {
|
||||
padding-right: calc(3.5 * var(--spacing-unit));
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { noop } from "lodash";
|
||||
import React from "react";
|
||||
import { createRenderer } from "react-test-renderer/shallow";
|
||||
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
|
||||
import Button from "./Button";
|
||||
|
||||
it("renders anchor button", () => {
|
||||
const props: PropTypesOf<typeof Button> = {
|
||||
href: "/moderate",
|
||||
children: "link",
|
||||
onClick: noop,
|
||||
className: "custom",
|
||||
target: "_blank",
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<Button {...props} />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders button", () => {
|
||||
const props: PropTypesOf<typeof Button> = {
|
||||
children: "link",
|
||||
onClick: noop,
|
||||
className: "custom",
|
||||
blankAdornment: true,
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<Button {...props} />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import cn from "classnames";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import { Omit } from "talk-framework/types";
|
||||
import { withStyles } from "talk-ui/hocs";
|
||||
|
||||
import BaseButton, { BaseButtonProps } from "../BaseButton";
|
||||
import Icon from "../Icon";
|
||||
|
||||
import styles from "./Button.css";
|
||||
|
||||
interface Props extends Omit<BaseButtonProps, "ref"> {
|
||||
children: React.ReactNode;
|
||||
href?: string;
|
||||
className?: string;
|
||||
onClick?: React.EventHandler<React.MouseEvent>;
|
||||
classes: typeof styles;
|
||||
/**
|
||||
* blankAdornment if true will leave some blank space after the text, so
|
||||
* that it looks nice, if mixed with other buttons which have an external link
|
||||
* icon as adornment.
|
||||
*/
|
||||
blankAdornment?: boolean;
|
||||
}
|
||||
|
||||
const Button: StatelessComponent<Props> = ({
|
||||
blankAdornment,
|
||||
className,
|
||||
href,
|
||||
onClick,
|
||||
children,
|
||||
classes,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<BaseButton
|
||||
classes={{
|
||||
root: cn(classes.root, className, {
|
||||
[classes.blankAdornment]: blankAdornment,
|
||||
}),
|
||||
mouseHover: classes.mouseHover,
|
||||
}}
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
anchor={Boolean(href)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
{rest.target === "_blank" && (
|
||||
<Icon className={classes.icon}>open_in_new</Icon>
|
||||
)}
|
||||
</BaseButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default withStyles(styles)(Button);
|
||||
@@ -0,0 +1,6 @@
|
||||
.root {
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--palette-divider);
|
||||
padding-top: 1px;
|
||||
margin: calc(0.5 * var(--spacing-unit)) 0;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import { createRenderer } from "react-test-renderer/shallow";
|
||||
|
||||
import Divider from "./Divider";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<Divider />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import cn from "classnames";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import { withStyles } from "talk-ui/hocs";
|
||||
|
||||
import styles from "./Divider.css";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
classes: typeof styles;
|
||||
}
|
||||
|
||||
const Divider: StatelessComponent<Props> = ({
|
||||
className,
|
||||
classes,
|
||||
...rest
|
||||
}) => <hr {...rest} className={cn(classes.root, className)} />;
|
||||
|
||||
export default withStyles(styles)(Divider);
|
||||
@@ -0,0 +1,3 @@
|
||||
.root {
|
||||
padding: calc(0.5 * var(--spacing-unit)) 0;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: Dropdown
|
||||
menu: UI Kit
|
||||
---
|
||||
|
||||
import { Playground, PropsTable } from "docz";
|
||||
|
||||
import {
|
||||
Dropdown,
|
||||
Divider as DropdownDivider,
|
||||
Button as DropdownButton,
|
||||
} from "./";
|
||||
import { Button, ButtonIcon } from "../Button";
|
||||
import ClickOutside from "../ClickOutside";
|
||||
import Popover from "../Popover";
|
||||
|
||||
# Dropdown
|
||||
|
||||
## Basic usage
|
||||
|
||||
<Playground>
|
||||
<Popover
|
||||
id="dropdownMenu"
|
||||
description="Dropdown Menu"
|
||||
placement="bottom-start"
|
||||
body={({ toggleVisibility }) => (
|
||||
<ClickOutside onClickOutside={toggleVisibility}>
|
||||
<Dropdown>
|
||||
<DropdownButton blankAdornment>Shortcuts</DropdownButton>
|
||||
<DropdownButton
|
||||
href="https://github.com/coralproject/talk/issues/new"
|
||||
target="_blank"
|
||||
>
|
||||
Report Bug
|
||||
</DropdownButton>
|
||||
<DropdownDivider />
|
||||
<DropdownButton blankAdornment>Sign Out</DropdownButton>
|
||||
</Dropdown>
|
||||
</ClickOutside>
|
||||
)}
|
||||
>
|
||||
{({ toggleVisibility, ref, visible }) => (
|
||||
<Button
|
||||
onClick={toggleVisibility}
|
||||
ref={ref}
|
||||
variant="filled"
|
||||
color="primary"
|
||||
>
|
||||
<span>Click me!</span>
|
||||
{
|
||||
<ButtonIcon size="md">
|
||||
{visible ? "arrow_drop_up" : "arrow_drop_down"}
|
||||
</ButtonIcon>
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
</Popover>
|
||||
</Playground>
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import { createRenderer } from "react-test-renderer/shallow";
|
||||
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
|
||||
import Button from "./Button";
|
||||
import Divider from "./Divider";
|
||||
import Dropdown from "./Dropdown";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const props: PropTypesOf<typeof Dropdown> = {
|
||||
children: (
|
||||
<>
|
||||
<Button blankAdornment>Shortcuts</Button>
|
||||
<Button
|
||||
href="https://github.com/coralproject/talk/issues/new"
|
||||
target="_blank"
|
||||
>
|
||||
Report Bug
|
||||
</Button>
|
||||
<Divider />
|
||||
<Button blankAdornment>Sign Out</Button>
|
||||
</>
|
||||
),
|
||||
className: "custom",
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<Dropdown {...props} />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import { withStyles } from "talk-ui/hocs";
|
||||
|
||||
import styles from "./Dropdown.css";
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
classes: typeof styles;
|
||||
}
|
||||
|
||||
const Dropdown: StatelessComponent<Props> = ({
|
||||
className,
|
||||
children,
|
||||
classes,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<div className={classes.root} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default withStyles(styles)(Dropdown);
|
||||
@@ -0,0 +1,38 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders anchor button 1`] = `
|
||||
<Button
|
||||
className="custom"
|
||||
classes={
|
||||
Object {
|
||||
"blankAdornment": "Button-blankAdornment",
|
||||
"icon": "Button-icon",
|
||||
"mouseHover": "Button-mouseHover",
|
||||
"root": "Button-root",
|
||||
}
|
||||
}
|
||||
href="/moderate"
|
||||
onClick={[Function]}
|
||||
target="_blank"
|
||||
>
|
||||
link
|
||||
</Button>
|
||||
`;
|
||||
|
||||
exports[`renders button 1`] = `
|
||||
<Button
|
||||
blankAdornment={true}
|
||||
className="custom"
|
||||
classes={
|
||||
Object {
|
||||
"blankAdornment": "Button-blankAdornment",
|
||||
"icon": "Button-icon",
|
||||
"mouseHover": "Button-mouseHover",
|
||||
"root": "Button-root",
|
||||
}
|
||||
}
|
||||
onClick={[Function]}
|
||||
>
|
||||
link
|
||||
</Button>
|
||||
`;
|
||||
@@ -0,0 +1,11 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<Divider
|
||||
classes={
|
||||
Object {
|
||||
"root": "Divider-root",
|
||||
}
|
||||
}
|
||||
/>
|
||||
`;
|
||||
@@ -0,0 +1,32 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<Dropdown
|
||||
className="custom"
|
||||
classes={
|
||||
Object {
|
||||
"root": "Dropdown-root",
|
||||
}
|
||||
}
|
||||
>
|
||||
<React.Fragment>
|
||||
<withPropsOnChange(Button)
|
||||
blankAdornment={true}
|
||||
>
|
||||
Shortcuts
|
||||
</withPropsOnChange(Button)>
|
||||
<withPropsOnChange(Button)
|
||||
href="https://github.com/coralproject/talk/issues/new"
|
||||
target="_blank"
|
||||
>
|
||||
Report Bug
|
||||
</withPropsOnChange(Button)>
|
||||
<withPropsOnChange(Divider) />
|
||||
<withPropsOnChange(Button)
|
||||
blankAdornment={true}
|
||||
>
|
||||
Sign Out
|
||||
</withPropsOnChange(Button)>
|
||||
</React.Fragment>
|
||||
</Dropdown>
|
||||
`;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as Dropdown } from "./Dropdown";
|
||||
export { default as Button } from "./Button";
|
||||
export { default as Divider } from "./Divider";
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
name: AriaInfo
|
||||
name: FieldSet
|
||||
menu: UI Kit
|
||||
---
|
||||
|
||||
|
||||
@@ -3,24 +3,28 @@ name: Popover
|
||||
menu: UI Kit
|
||||
---
|
||||
|
||||
import { Playground } from 'docz'
|
||||
import Popover from './Popover'
|
||||
import Button from '../Button'
|
||||
import Flex from '../Flex'
|
||||
import Typography from '../Typography'
|
||||
import ButtonIcon from '../Button/ButtonIcon'
|
||||
import { Playground } from "docz";
|
||||
import Popover from "./Popover";
|
||||
import Button from "../Button";
|
||||
import Flex from "../Flex";
|
||||
import Typography from "../Typography";
|
||||
import ButtonIcon from "../Button/ButtonIcon";
|
||||
|
||||
# Popover
|
||||
|
||||
`Popover` renders a popover dialog attached to another `Element`.
|
||||
|
||||
## Basic usage
|
||||
|
||||
<Playground>
|
||||
<Popover
|
||||
body={<Typography>This is the body</Typography>}
|
||||
>
|
||||
{({ toggleVisibility, forwardRef }) => (
|
||||
<Button onClick={toggleVisibility} forwardRef={forwardRef} variant="filled" color="primary">
|
||||
<Popover body={<Typography>This is the body</Typography>}>
|
||||
{({ toggleVisibility, ref }) => (
|
||||
<Button
|
||||
onClick={toggleVisibility}
|
||||
ref={ref}
|
||||
variant="filled"
|
||||
color="primary"
|
||||
>
|
||||
Click me!
|
||||
</Button>
|
||||
)}
|
||||
@@ -28,6 +32,7 @@ import ButtonIcon from '../Button/ButtonIcon'
|
||||
</Playground>
|
||||
|
||||
#### Example with `placement=top`
|
||||
|
||||
<Playground>
|
||||
<Popover
|
||||
placement="top"
|
||||
@@ -40,8 +45,13 @@ import ButtonIcon from '../Button/ButtonIcon'
|
||||
</Flex>
|
||||
)}
|
||||
>
|
||||
{({ toggleVisibility, forwardRef }) => (
|
||||
<Button onClick={toggleVisibility} forwardRef={forwardRef} variant="filled" color="primary">
|
||||
{({ toggleVisibility, ref }) => (
|
||||
<Button
|
||||
onClick={toggleVisibility}
|
||||
ref={ref}
|
||||
variant="filled"
|
||||
color="primary"
|
||||
>
|
||||
Click me!
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -23,14 +23,16 @@ type Placement =
|
||||
| "left-start";
|
||||
|
||||
interface BodyRenderProps {
|
||||
toggleVisibility: () => void;
|
||||
/** toggles visibility, if event is provided, the event will stop propagating. */
|
||||
toggleVisibility: (event?: React.SyntheticEvent | Event) => void;
|
||||
visible: boolean;
|
||||
scheduleUpdate: () => void;
|
||||
}
|
||||
|
||||
interface ChildrenRenderProps {
|
||||
toggleVisibility: () => void;
|
||||
forwardRef?: RefHandler;
|
||||
/** toggles visibility, if event is provided, the event will stop propagating. */
|
||||
toggleVisibility: (event?: React.SyntheticEvent | Event) => void;
|
||||
ref?: RefHandler;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
@@ -56,7 +58,10 @@ class Popover extends React.Component<PopoverProps> {
|
||||
visible: false,
|
||||
};
|
||||
|
||||
public toggleVisibility = () => {
|
||||
public toggleVisibility = (event?: React.SyntheticEvent | Event) => {
|
||||
if (event && event.stopPropagation) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
this.setState((state: State) => ({
|
||||
visible: !state.visible,
|
||||
}));
|
||||
@@ -109,7 +114,7 @@ class Popover extends React.Component<PopoverProps> {
|
||||
<Reference>
|
||||
{props =>
|
||||
children({
|
||||
forwardRef: props.ref,
|
||||
ref: props.ref,
|
||||
toggleVisibility: this.toggleVisibility,
|
||||
visible: this.state.visible,
|
||||
})
|
||||
|
||||
@@ -48,3 +48,8 @@ export { Marker, Count as MarkerCount } from "./Marker";
|
||||
export { default as Card } from "./Card";
|
||||
export { default as PasswordField } from "./PasswordField";
|
||||
export { TileSelector, Option as TileOption } from "./TileSelector";
|
||||
export {
|
||||
Dropdown,
|
||||
Divider as DropdownDivider,
|
||||
Button as DropdownButton,
|
||||
} from "./Dropdown";
|
||||
|
||||
@@ -21,11 +21,16 @@
|
||||
background: transparent;
|
||||
font-size: inherit;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0) !important;
|
||||
-webkit-appearance: none;
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:visited {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&::-moz-focus-inner {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,13 @@ navigation-moderate = Moderate
|
||||
navigation-community = Community
|
||||
navigation-stories = Stories
|
||||
navigation-configure = Configure
|
||||
navigation-signOutButton = Sign Out
|
||||
|
||||
## User Menu
|
||||
userMenu-signOut = Sign Out
|
||||
userMenu-viewLatestVersion = View Latest Release
|
||||
userMenu-reportBug = Report a Bug or Give Feedback
|
||||
userMenu-popover =
|
||||
.description = A dialog of the user menu with related links and actions
|
||||
|
||||
## Restricted
|
||||
restricted-currentlySignedInTo = Currently signed in to
|
||||
@@ -265,6 +271,8 @@ configure-advanced-permittedDomains-explanation =
|
||||
yourdomain.com, etc.
|
||||
|
||||
## Decision History
|
||||
decisionHistory-popover =
|
||||
.description = A dialog showing the decision history
|
||||
decisionHistory-youWillSeeAList =
|
||||
You will see a list of your post moderation actions here.
|
||||
decisionHistory-showMoreButton =
|
||||
|
||||
@@ -28,6 +28,8 @@ comments-postCommentForm-submit = Submit
|
||||
comments-stream-loadMore = Load More
|
||||
comments-replyList-showAll = Show All
|
||||
|
||||
comments-permalinkPopover =
|
||||
.description = A dialog showing a permalink to the comment
|
||||
comments-permalinkButton-share = Share
|
||||
comments-permalinkView-viewFullDiscussion = View Full Discussion
|
||||
comments-permalinkView-commentNotFound = Comment not found
|
||||
@@ -104,6 +106,8 @@ profile-profileQuery-storyNotFound = Story not found
|
||||
profile-commentHistory-loadMore = Load More
|
||||
|
||||
## Report Comment Popover
|
||||
comments-reportPopover =
|
||||
.description = A dialog for reporting comments
|
||||
comments-reportPopover-reportThisComment = Report This Comment
|
||||
comments-reportPopover-whyAreYouReporting = Why are you reporting this comment?
|
||||
|
||||
|
||||
Reference in New Issue
Block a user