This commit is contained in:
Chi Vinh Le
2018-09-03 22:33:34 +02:00
parent eb74162eef
commit 31bc5fa913
21 changed files with 415 additions and 89 deletions
+3 -3
View File
@@ -2162,9 +2162,9 @@
}
},
"@types/recompose": {
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/@types/recompose/-/recompose-0.26.1.tgz",
"integrity": "sha512-S5fkitL277yWCEHDzgb/3aJ4RySeqSC7L3Xo+7AlDk6+DAPpQAfF0iwgfjAqbP49JAjVCY+asPQxFPiw1+4CYg==",
"version": "0.26.4",
"resolved": "https://registry.npmjs.org/@types/recompose/-/recompose-0.26.4.tgz",
"integrity": "sha512-QOLPlsBxn/yOxSv4Au66kd8KvYZRCgZA3vV5pNZ6YTEY4GeDHNoYL+sCnbzGIcmWDYoN7PUNZSopaGhvHQperw==",
"dev": true,
"requires": {
"@types/react": "*"
+1 -1
View File
@@ -133,7 +133,7 @@
"@types/react-relay": "^1.3.9",
"@types/react-responsive": "^3.0.1",
"@types/react-test-renderer": "^16.0.1",
"@types/recompose": "^0.26.1",
"@types/recompose": "^0.26.4",
"@types/relay-runtime": "^1.3.6",
"@types/sane": "^2.0.0",
"@types/sinon": "^5.0.1",
@@ -1,12 +1,18 @@
import * as React from "react";
import {
hoistStatics,
InferableComponentEnhancer,
wrapDisplayName,
} from "recompose";
import React from "react";
import { hoistStatics, wrapDisplayName } from "recompose";
import { Omit } from "talk-ui/types";
import { TalkContext, TalkContextConsumer } from "./TalkContext";
// Injects props and removes them from the prop requirements.
// Will not pass through the injected props if they are passed in during
// render. Also adds new prop requirements from TNeedsProps.
type InferableComponentEnhancerWithProps<TInjectedProps> = <
P extends TInjectedProps
>(
component: React.ComponentType<P>
) => React.ComponentType<Omit<P, keyof TInjectedProps>>;
/**
* withContext is a HOC wrapper around `TalkContextConsumer`.
* `propsCallback` must be provided which accepts the `TalkContext`
@@ -14,7 +20,7 @@ import { TalkContext, TalkContextConsumer } from "./TalkContext";
*/
function withContext<T>(
propsCallback: (context: TalkContext) => T
): InferableComponentEnhancer<T> {
): InferableComponentEnhancerWithProps<T> {
return hoistStatics<T>(
<U extends T>(WrappedComponent: React.ComponentType<U>) => {
const Component: React.StatelessComponent<any> = props => (
@@ -27,7 +33,7 @@ function withContext<T>(
Component.displayName = wrapDisplayName(WrappedComponent, "withContext");
return Component;
}
);
) as any;
}
export default withContext;
@@ -1,5 +1,11 @@
.root {
}
.footer {
.topBar {
margin-bottom: calc(0.5 * var(--spacing-unit));
}
.footer:not(:empty) {
margin-top: var(--spacing-unit);
}
.reply {
margin-top: var(--spacing-unit);
}
@@ -1,8 +1,8 @@
import React from "react";
import { StatelessComponent } from "react";
import * as styles from "./Comment.css";
import React, { ReactElement, StatelessComponent } from "react";
import PermalinkButtonContainer from "../../containers/PermalinkButtonContainer";
import { Flex } from "talk-ui/components";
import * as styles from "./Comment.css";
import HTMLContent from "./HTMLContent";
import Timestamp from "./Timestamp";
import TopBar from "./TopBar";
@@ -16,20 +16,21 @@ export interface CommentProps {
} | null;
body: string | null;
createdAt: string;
footer?: ReactElement<any> | Array<ReactElement<any>>;
}
const Comment: StatelessComponent<CommentProps> = props => {
return (
<div role="article" className={styles.root}>
<TopBar>
<TopBar className={styles.topBar}>
{props.author &&
props.author.username && <Username>{props.author.username}</Username>}
<Timestamp>{props.createdAt}</Timestamp>
</TopBar>
<HTMLContent>{props.body || ""}</HTMLContent>
<div className={styles.footer}>
<PermalinkButtonContainer commentID={props.id} />
</div>
<Flex className={styles.footer} direction="row" itemGutter="half">
{props.footer}
</Flex>
</div>
);
};
@@ -0,0 +1,27 @@
import { Localized } from "fluent-react/compat";
import React, { EventHandler, MouseEvent, StatelessComponent } from "react";
import { Button, ButtonIcon, MatchMedia } from "talk-ui/components";
interface Props {
onClick?: EventHandler<MouseEvent<HTMLButtonElement>>;
active?: boolean;
}
const ReplyButton: StatelessComponent<Props> = props => (
<Button
onClick={props.onClick}
variant="ghost"
size="small"
active={props.active}
>
<MatchMedia gtWidth="xs">
<ButtonIcon>reply</ButtonIcon>
</MatchMedia>
<Localized id="comments-replyButton-reply">
<span>Reply</span>
</Localized>
</Button>
);
export default ReplyButton;
@@ -1,3 +0,0 @@
.root {
margin-bottom: calc(0.5 * var(--spacing-unit));
}
@@ -4,15 +4,13 @@ import { StatelessComponent } from "react";
import { Flex, MatchMedia } from "talk-ui/components";
import * as styles from "./TopBar.css";
export interface TopBarProps {
className?: string;
children: React.ReactNode;
}
const TopBar: StatelessComponent<TopBarProps> = props => {
const rootClassName = cn(styles.root, props.className);
const rootClassName = cn(props.className);
return (
<MatchMedia gtWidth="xs">
{matches => (
@@ -8,7 +8,8 @@ import CommentContainer from "../containers/CommentContainer";
import * as styles from "./PermalinkView.css";
export interface PermalinkViewProps {
comment: PropTypesOf<typeof CommentContainer>["data"] | null;
asset: PropTypesOf<typeof CommentContainer>["asset"];
comment: PropTypesOf<typeof CommentContainer>["comment"] | null;
showAllCommentsHref: string | null;
onShowAllComments: (e: MouseEvent<any>) => void;
}
@@ -16,6 +17,7 @@ export interface PermalinkViewProps {
const PermalinkView: StatelessComponent<PermalinkViewProps> = ({
showAllCommentsHref,
comment,
asset,
onShowAllComments,
}) => {
return (
@@ -42,7 +44,7 @@ const PermalinkView: StatelessComponent<PermalinkViewProps> = ({
<Typography>Comment not found</Typography>
</Localized>
)}
{comment && <CommentContainer data={comment} />}
{comment && <CommentContainer comment={comment} asset={asset} />}
</div>
);
};
@@ -1,17 +1,6 @@
.root {
}
.textarea {
composes: bodyCopy from "talk-ui/shared/typography.css";
display: block;
height: 150px;
width: 100%;
box-sizing: border-box;
padding: var(--spacing-unit);
border-radius: var(--round-corners);
resize: vertical;
}
.poweredBy {
margin-top: calc(-0.5 * var(--spacing-unit));
}
@@ -0,0 +1,99 @@
import { FormState } from "final-form";
import { Localized } from "fluent-react/compat";
import React, { EventHandler, MouseEvent, StatelessComponent } from "react";
import { Field, Form, FormSpy } from "react-final-form";
import { OnSubmit } from "talk-framework/lib/form";
import { required } from "talk-framework/lib/validation";
import {
AriaInfo,
Button,
Flex,
HorizontalGutter,
Typography,
} from "talk-ui/components";
import RTE from "./RTE";
interface FormProps {
body: string;
}
export interface ReplyCommentFormProps {
className?: string;
onSubmit: OnSubmit<FormProps>;
onCancel?: EventHandler<MouseEvent<any>>;
onChange?: (state: FormState) => void;
initialValues?: FormProps;
}
const ReplyCommentForm: StatelessComponent<ReplyCommentFormProps> = props => (
<Form onSubmit={props.onSubmit} initialValues={props.initialValues}>
{({ handleSubmit, submitting }) => (
<form
className={props.className}
autoComplete="off"
onSubmit={handleSubmit}
id="comments-replyCommentForm-form"
>
<FormSpy onChange={props.onChange} />
<HorizontalGutter>
<Field name="body" validate={required}>
{({ input, meta }) => (
<div>
<Localized id="comments-replyCommentForm-rteLabel">
<AriaInfo
component="label"
htmlFor="comments-replyCommentForm-field"
>
Write a reply
</AriaInfo>
</Localized>
<Localized
id="comments-replyCommentForm-rte"
attrs={{ placeholder: true }}
>
<RTE
inputId="comments-replyCommentForm-field"
onChange={({ html }) => input.onChange(html)}
value={input.value}
placeholder="Write a reply"
/>
</Localized>
{meta.touched &&
(meta.error || meta.submitError) && (
<Typography align="right" color="error" gutterBottom>
{meta.error || meta.submitError}
</Typography>
)}
</div>
)}
</Field>
<Flex direction="row" justifyContent="flex-end" itemGutter="half">
<Localized id="comments-replyCommentForm-cancel">
<Button
variant="outlined"
disabled={submitting}
onClick={props.onCancel}
>
Cancel
</Button>
</Localized>
<Localized id="comments-replyCommentForm-submit">
<Button
color="primary"
variant="filled"
disabled={submitting}
type="submit"
>
Submit
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
);
export default ReplyCommentForm;
@@ -9,9 +9,12 @@ import CommentContainer from "../containers/CommentContainer";
import Indent from "./Indent";
export interface ReplyListProps {
commentID: string;
asset: PropTypesOf<typeof CommentContainer>["asset"];
comment: {
id: string;
};
comments: ReadonlyArray<
{ id: string } & PropTypesOf<typeof CommentContainer>["data"]
{ id: string } & PropTypesOf<typeof CommentContainer>["comment"]
>;
onShowAll: () => void;
hasMore: boolean;
@@ -22,17 +25,21 @@ const ReplyList: StatelessComponent<ReplyListProps> = props => {
return (
<Indent>
<HorizontalGutter
id={`talk-comments-replyList-log--${props.commentID}`}
id={`talk-comments-replyList-log--${props.comment.id}`}
role="log"
>
{props.comments.map(comment => (
<CommentContainer key={comment.id} data={comment} />
<CommentContainer
key={comment.id}
comment={comment}
asset={props.asset}
/>
))}
{props.hasMore && (
<Localized id="comments-replyList-showAll">
<Button
id={`talk-comments-replyList-showAll--${props.commentID}`}
aria-controls={`talk-comments-replyList-log--${props.commentID}`}
id={`talk-comments-replyList-showAll--${props.comment.id}`}
aria-controls={`talk-comments-replyList-log--${props.comment.id}`}
onClick={props.onShowAll}
disabled={props.disableShowAll}
variant="outlined"
+9 -6
View File
@@ -13,10 +13,13 @@ import PostCommentFormFake from "./PostCommentFormFake";
import * as styles from "./Stream.css";
export interface StreamProps {
assetID: string;
isClosed?: boolean;
asset: {
id: string;
isClosed?: boolean;
} & PropTypesOf<typeof CommentContainer>["asset"] &
PropTypesOf<typeof ReplyListContainer>["asset"];
comments: ReadonlyArray<
{ id: string } & PropTypesOf<typeof CommentContainer>["data"] &
{ id: string } & PropTypesOf<typeof CommentContainer>["comment"] &
PropTypesOf<typeof ReplyListContainer>["comment"]
>;
onLoadMore?: () => void;
@@ -31,7 +34,7 @@ const Stream: StatelessComponent<StreamProps> = props => {
<HorizontalGutter size="half">
<UserBoxContainer user={props.user} />
{props.user ? (
<PostCommentFormContainer assetID={props.assetID} />
<PostCommentFormContainer assetID={props.asset.id} />
) : (
<PostCommentFormFake />
)}
@@ -43,8 +46,8 @@ const Stream: StatelessComponent<StreamProps> = props => {
>
{props.comments.map(comment => (
<HorizontalGutter key={comment.id}>
<CommentContainer data={comment} />
<ReplyListContainer comment={comment} />
<CommentContainer comment={comment} asset={props.asset} />
<ReplyListContainer comment={comment} asset={props.asset} />
</HorizontalGutter>
))}
{props.hasMore && (
@@ -1,37 +1,81 @@
import React, { StatelessComponent } from "react";
import React, { Component } from "react";
import { graphql } from "react-relay";
import withFragmentContainer from "talk-framework/lib/relay/withFragmentContainer";
import { PropTypesOf } from "talk-framework/types";
import { CommentContainer as Data } from "talk-stream/__generated__/CommentContainer.graphql";
import { CommentContainer_asset as AssetData } from "talk-stream/__generated__/CommentContainer_asset.graphql";
import { CommentContainer_comment as CommentData } from "talk-stream/__generated__/CommentContainer_comment.graphql";
import Comment from "../components/Comment";
import ReplyButton from "../components/Comment/ReplyButton";
import ReplyCommentFormContainer from ".//ReplyCommentFormContainer";
import PermalinkButtonContainer from "./PermalinkButtonContainer";
interface InnerProps {
data: Data;
comment: CommentData;
asset: AssetData;
}
// tslint:disable-next-line:no-unused-expression
graphql`
fragment CommentContainer_comment on Comment {
id
author {
username
}
body
createdAt
}
`;
interface State {
showReplyDialog: boolean;
}
export const CommentContainer: StatelessComponent<InnerProps> = props => {
const { data, ...rest } = props;
return <Comment {...rest} {...props.data} />;
};
export class CommentContainer extends Component<InnerProps, State> {
public state = {
showReplyDialog: false,
};
private toggleReplyDialog = () => {
this.setState(state => ({
showReplyDialog: !state.showReplyDialog,
}));
};
public render() {
const { comment, asset, ...rest } = this.props;
const { showReplyDialog } = this.state;
return (
<>
<Comment
{...rest}
{...comment}
footer={
<>
<ReplyButton
onClick={this.toggleReplyDialog}
active={showReplyDialog}
/>
<PermalinkButtonContainer commentID={comment.id} />
</>
}
/>
{showReplyDialog && (
<ReplyCommentFormContainer
comment={comment}
asset={asset}
onCancel={this.toggleReplyDialog}
/>
)}
</>
);
}
}
const enhanced = withFragmentContainer<InnerProps>({
data: graphql`
fragment CommentContainer on Comment {
...CommentContainer_comment @relay(mask: false)
asset: graphql`
fragment CommentContainer_asset on Asset {
...ReplyCommentFormContainer_asset
}
`,
comment: graphql`
fragment CommentContainer_comment on Comment {
id
author {
username
}
body
createdAt
...ReplyCommentFormContainer_comment
}
`,
})(CommentContainer);
@@ -5,6 +5,7 @@ import { graphql } from "react-relay";
import { withContext } from "talk-framework/lib/bootstrap";
import { withFragmentContainer } from "talk-framework/lib/relay";
import { buildURL, parseURL } from "talk-framework/utils";
import { PermalinkViewContainer_asset as AssetData } from "talk-stream/__generated__/PermalinkViewContainer_asset.graphql";
import { PermalinkViewContainer_comment as CommentData } from "talk-stream/__generated__/PermalinkViewContainer_comment.graphql";
import {
SetCommentIDMutation,
@@ -15,6 +16,7 @@ import PermalinkView from "../components/PermalinkView";
interface PermalinkViewContainerProps {
comment: CommentData | null;
asset: AssetData;
setCommentID: SetCommentIDMutation;
pym: PymChild | undefined;
}
@@ -37,9 +39,10 @@ class PermalinkViewContainer extends React.Component<
return buildURL({ ...urlParts, search });
}
public render() {
const { comment } = this.props;
const { comment, asset } = this.props;
return (
<PermalinkView
asset={asset}
comment={comment}
showAllCommentsHref={this.getShowAllCommentsHref()}
onShowAllComments={this.showAllComments}
@@ -53,9 +56,14 @@ const enhanced = withContext(ctx => ({
}))(
withSetCommentIDMutation(
withFragmentContainer<PermalinkViewContainerProps>({
asset: graphql`
fragment PermalinkViewContainer_asset on Asset {
...CommentContainer_asset
}
`,
comment: graphql`
fragment PermalinkViewContainer_comment on Comment {
...CommentContainer
...CommentContainer_comment
}
`,
})(PermalinkViewContainer)
@@ -0,0 +1,114 @@
import React, { Component, EventHandler, MouseEvent } from "react";
import { graphql } from "react-relay";
import { withContext } from "talk-framework/lib/bootstrap";
import { BadUserInputError } from "talk-framework/lib/errors";
import { withFragmentContainer } from "talk-framework/lib/relay";
import { PymStorage } from "talk-framework/lib/storage";
import { PropTypesOf } from "talk-framework/types";
import { ReplyCommentFormContainer_asset as AssetData } from "talk-stream/__generated__/ReplyCommentFormContainer_asset.graphql";
import { ReplyCommentFormContainer_comment as CommentData } from "talk-stream/__generated__/ReplyCommentFormContainer_comment.graphql";
import ReplyCommentForm, {
ReplyCommentFormProps,
} from "../components/ReplyCommentForm";
import { CreateCommentMutation, withCreateCommentMutation } from "../mutations";
interface InnerProps {
createComment: CreateCommentMutation;
pymSessionStorage: PymStorage;
comment: CommentData;
asset: AssetData;
onCancel?: EventHandler<MouseEvent<any>>;
}
interface State {
initialValues?: ReplyCommentFormProps["initialValues"];
initialized: boolean;
}
const contextKey = "postCommentFormBody";
export class ReplyCommentFormContainer extends Component<InnerProps, State> {
public state: State = { initialized: false };
constructor(props: InnerProps) {
super(props);
this.init();
}
private async init() {
const body = await this.props.pymSessionStorage.getItem(contextKey);
if (body) {
this.setState({
initialValues: {
body,
},
});
}
this.setState({
initialized: true,
});
}
private handleOnSubmit: ReplyCommentFormProps["onSubmit"] = async (
input,
form
) => {
try {
await this.props.createComment({
assetID: this.props.asset.id,
...input,
});
form.reset({});
} catch (error) {
if (error instanceof BadUserInputError) {
return error.invalidArgsLocalized;
}
// tslint:disable-next-line:no-console
console.error(error);
}
return undefined;
};
private handleOnChange: ReplyCommentFormProps["onChange"] = state => {
if (state.values.body) {
this.props.pymSessionStorage.setItem(contextKey, state.values.body);
} else {
this.props.pymSessionStorage.removeItem(contextKey);
}
};
public render() {
if (!this.state.initialized) {
return null;
}
return (
<ReplyCommentForm
onSubmit={this.handleOnSubmit}
onChange={this.handleOnChange}
initialValues={this.state.initialValues}
/>
);
}
}
const enhanced = withContext(({ pymSessionStorage }) => ({
pymSessionStorage,
}))(
withCreateCommentMutation(
withFragmentContainer<InnerProps>({
asset: graphql`
fragment ReplyCommentFormContainer_asset on Asset {
id
}
`,
comment: graphql`
fragment ReplyCommentFormContainer_comment on Comment {
id
}
`,
})(ReplyCommentFormContainer)
)
);
export type PostCommentFormContainerProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -3,7 +3,8 @@ import { graphql, RelayPaginationProp } from "react-relay";
import { withPaginationContainer } from "talk-framework/lib/relay";
import { PropTypesOf } from "talk-framework/types";
import { ReplyListContainer_comment as Data } from "talk-stream/__generated__/ReplyListContainer_comment.graphql";
import { ReplyListContainer_asset as AssetData } from "talk-stream/__generated__/ReplyListContainer_asset.graphql";
import { ReplyListContainer_comment as CommentData } from "talk-stream/__generated__/ReplyListContainer_comment.graphql";
import {
COMMENT_SORT,
ReplyListContainerPaginationQueryVariables,
@@ -12,7 +13,8 @@ import {
import ReplyList from "../components/ReplyList";
export interface InnerProps {
comment: Data;
asset: AssetData;
comment: CommentData;
relay: RelayPaginationProp;
}
@@ -31,8 +33,9 @@ export class ReplyListContainer extends React.Component<InnerProps> {
const comments = this.props.comment.replies.edges.map(edge => edge.node);
return (
<ReplyList
commentID={this.props.comment.id}
comment={this.props.comment}
comments={comments}
asset={this.props.asset}
onShowAll={this.showAll}
hasMore={this.props.relay.hasMore()}
disableShowAll={this.state.disableShowAll}
@@ -72,6 +75,11 @@ const enhanced = withPaginationContainer<
FragmentVariables
>(
{
asset: graphql`
fragment ReplyListContainer_asset on Asset {
...CommentContainer_asset
}
`,
comment: graphql`
fragment ReplyListContainer_comment on Comment
@argumentDefinitions(
@@ -85,7 +93,7 @@ const enhanced = withPaginationContainer<
edges {
node {
id
...CommentContainer
...CommentContainer_comment
}
}
}
@@ -27,8 +27,7 @@ export class StreamContainer extends React.Component<InnerProps> {
const comments = this.props.asset.comments.edges.map(edge => edge.node);
return (
<Stream
assetID={this.props.asset.id}
isClosed={this.props.asset.isClosed}
asset={this.props.asset}
comments={comments}
onLoadMore={this.loadMore}
hasMore={this.props.relay.hasMore()}
@@ -83,11 +82,13 @@ const enhanced = withPaginationContainer<
edges {
node {
id
...CommentContainer
...CommentContainer_comment
...ReplyListContainer_comment
}
}
}
...CommentContainer_asset
...ReplyListContainer_asset
}
`,
user: graphql`
@@ -1,3 +1,4 @@
import { Localized } from "fluent-react/compat";
import * as React from "react";
import { StatelessComponent } from "react";
import { ReadyState } from "react-relay";
@@ -25,23 +26,36 @@ export const render = ({
return <div>{error.message}</div>;
}
if (props) {
return <PermalinkViewContainer comment={props.comment} />;
if (!props.asset) {
return (
<Localized id="comments-permalinkViewQuery-assetNotFound">
<div>Asset not found</div>
</Localized>
);
}
return (
<PermalinkViewContainer comment={props.comment} asset={props.asset} />
);
}
return <Spinner />;
};
const PermalinkViewQuery: StatelessComponent<InnerProps> = ({
local: { commentID },
local: { commentID, assetID },
}) => (
<QueryRenderer<QueryTypes>
query={graphql`
query PermalinkViewQuery($commentID: ID!) {
query PermalinkViewQuery($commentID: ID!, $assetID: ID!) {
asset(id: $assetID) {
...PermalinkViewContainer_asset
}
comment(id: $commentID) {
...PermalinkViewContainer_comment
}
}
`}
variables={{
assetID: assetID!,
commentID: commentID!,
}}
render={render}
@@ -51,6 +65,8 @@ const PermalinkViewQuery: StatelessComponent<InnerProps> = ({
const enhanced = withLocalStateContainer(
graphql`
fragment PermalinkViewQueryLocal on Local {
assetID
authRevision
commentID
}
`
@@ -43,15 +43,15 @@ const StreamQuery: StatelessComponent<InnerProps> = ({
<QueryRenderer<QueryTypes>
query={graphql`
query StreamQuery($assetID: ID!, $authRevision: Int!) {
asset(id: $assetID) {
...StreamContainer_asset
}
# authRevision is increment every time auth state has changed.
# This is basically a cache invalidation and causes relay
# to automatically update this query.
me(clientAuthRevision: $authRevision) {
...StreamContainer_user
}
asset(id: $assetID) {
...StreamContainer_asset
}
}
`}
variables={{
+1 -1
View File
@@ -87,7 +87,7 @@ const enhanced = withFragmentContainer<{ data: Data }>({
fragment PermalinkViewContainerQuery on Query
@argumentDefinitions(commentID: { type: "ID!" }) {
comment(id: $commentID) {
...CommentContainer
...CommentContainer_comment
}
}
`,