Implement edit

This commit is contained in:
Chi Vinh Le
2018-09-11 00:15:27 +02:00
parent 195d61d104
commit a36d944e4a
21 changed files with 494 additions and 36 deletions
@@ -47,7 +47,16 @@ interface CreateContextArguments {
*/
export const timeagoFormatter: Formatter = (value, unit, suffix) => {
// We use 'in' instead of 'from now' for language consistency
const ourSuffix = suffix === "from now" ? "in" : suffix;
const ourSuffix = suffix === "from now" ? "noSuffix" : suffix;
if (unit === "second" && suffix === "ago") {
return (
<Localized id="framework-timeago-just-now">
<span>Just now</span>
</Localized>
);
}
return (
<Localized
id="framework-timeago"
@@ -6,6 +6,3 @@
.footer:not(:empty) {
margin-top: var(--spacing-unit);
}
.reply {
margin-top: var(--spacing-unit);
}
@@ -5,28 +5,37 @@ import { Flex } from "talk-ui/components";
import * as styles from "./Comment.css";
import HTMLContent from "./HTMLContent";
import Timestamp from "./Timestamp";
import TopBar from "./TopBar";
import TopBarLeft from "./TopBarLeft";
import Username from "./Username";
export interface CommentProps {
id: string;
className?: string;
author: {
username: string | null;
} | null;
body: string | null;
createdAt: string;
topBarRight?: ReactElement<any> | Array<ReactElement<any>>;
footer?: ReactElement<any> | Array<ReactElement<any>>;
}
const Comment: StatelessComponent<CommentProps> = props => {
return (
<div role="article" className={styles.root}>
<TopBar className={styles.topBar}>
{props.author &&
props.author.username && <Username>{props.author.username}</Username>}
<Timestamp>{props.createdAt}</Timestamp>
</TopBar>
<Flex
className={styles.topBar}
direction="row"
justifyContent="space-between"
>
<TopBarLeft>
{props.author &&
props.author.username && (
<Username>{props.author.username}</Username>
)}
<Timestamp>{props.createdAt}</Timestamp>
</TopBarLeft>
<div>{props.topBarRight}</div>
</Flex>
<HTMLContent>{props.body || ""}</HTMLContent>
<Flex className={styles.footer} direction="row" itemGutter="half">
{props.footer}
@@ -4,10 +4,10 @@ import TestRenderer from "react-test-renderer";
import { PropTypesOf } from "talk-framework/types";
import { UIContext, UIContextProps } from "talk-ui/components";
import TopBar from "./TopBar";
import TopBarLeft from "./TopBarLeft";
it("renders correctly on small screens", () => {
const props: PropTypesOf<typeof TopBar> = {
const props: PropTypesOf<typeof TopBarLeft> = {
children: <div>Hello World</div>,
};
@@ -19,14 +19,14 @@ it("renders correctly on small screens", () => {
const testRenderer = TestRenderer.create(
<UIContext.Provider value={context}>
<TopBar {...props} />
<TopBarLeft {...props} />
</UIContext.Provider>
);
expect(testRenderer.toJSON()).toMatchSnapshot();
});
it("renders correctly on big screens", () => {
const props: PropTypesOf<typeof TopBar> = {
const props: PropTypesOf<typeof TopBarLeft> = {
children: <div>Hello World</div>,
};
@@ -38,7 +38,7 @@ it("renders correctly on big screens", () => {
const testRenderer = TestRenderer.create(
<UIContext.Provider value={context}>
<TopBar {...props} />
<TopBarLeft {...props} />
</UIContext.Provider>
);
expect(testRenderer.toJSON()).toMatchSnapshot();
@@ -4,12 +4,12 @@ import { StatelessComponent } from "react";
import { Flex, MatchMedia } from "talk-ui/components";
export interface TopBarProps {
export interface TopBarLeftProps {
className?: string;
children: React.ReactNode;
}
const TopBar: StatelessComponent<TopBarProps> = props => {
const TopBarLeft: StatelessComponent<TopBarLeftProps> = props => {
const rootClassName = cn(props.className);
return (
<MatchMedia gtWidth="xs">
@@ -27,4 +27,4 @@ const TopBar: StatelessComponent<TopBarProps> = props => {
);
};
export default TopBar;
export default TopBarLeft;
@@ -1 +1,4 @@
export { default, default as IndentedComment } from "./IndentedComment";
export { default as TopBarLeft } from "./TopBarLeft";
export { default as Username } from "./Username";
export { default as Timestamp } from "./Timestamp";
@@ -0,0 +1,156 @@
import { CoralRTE } from "@coralproject/rte";
import { Localized } from "fluent-react/compat";
import React, {
EventHandler,
MouseEvent,
Ref,
StatelessComponent,
} from "react";
import { Field, Form } from "react-final-form";
import { OnSubmit } from "talk-framework/lib/form";
import { required } from "talk-framework/lib/validation";
import {
AriaInfo,
Button,
Flex,
HorizontalGutter,
RelativeTime,
Typography,
ValidationMessage,
} from "talk-ui/components";
import { Timestamp, TopBarLeft, Username } from "./Comment";
import RTE from "./RTE";
interface FormProps {
body: string;
}
export interface EditCommentFormProps {
id: string;
className?: string;
author: {
username: string | null;
} | null;
createdAt: string;
editableUntil: string;
onSubmit: OnSubmit<FormProps>;
onCancel?: EventHandler<MouseEvent<any>>;
onClose?: EventHandler<MouseEvent<any>>;
initialValues?: FormProps;
rteRef?: Ref<CoralRTE>;
expired?: boolean;
}
const EditCommentForm: StatelessComponent<EditCommentFormProps> = props => {
const inputID = `comments-editCommentForm-rte-${props.id}`;
return (
<Form onSubmit={props.onSubmit} initialValues={props.initialValues}>
{({ handleSubmit, submitting, hasValidationErrors, pristine }) => (
<form
className={props.className}
autoComplete="off"
onSubmit={handleSubmit}
id={`comments-editCommentForm-form-${props.id}`}
>
<HorizontalGutter>
<div>
<TopBarLeft>
{props.author &&
props.author.username && (
<Username>{props.author.username}</Username>
)}
<Timestamp>{props.createdAt}</Timestamp>
</TopBarLeft>
</div>
<Field name="body" validate={required}>
{({ input, meta }) => (
<div>
<Localized id="comments-editCommentForm-rteLabel">
<AriaInfo component="label" htmlFor={inputID}>
Edit comment
</AriaInfo>
</Localized>
<Localized
id="comments-editCommentForm-rte"
attrs={{ placeholder: true }}
>
<RTE
inputId={inputID}
onChange={({ html }) => input.onChange(html)}
value={input.value}
placeholder="Edit comment"
forwardRef={props.rteRef}
disabled={submitting || props.expired}
/>
</Localized>
{meta.touched &&
(meta.error || meta.submitError) && (
<Typography align="right" color="error" gutterBottom>
{meta.error || meta.submitError}
</Typography>
)}
</div>
)}
</Field>
{props.expired ? (
<Localized id="comments-editCommentForm-editTimeExpired">
<ValidationMessage fullWidth>
Edit time has expired. You can no longer edit this comment.
Why not post another one?
</ValidationMessage>
</Localized>
) : (
<ValidationMessage fullWidth>
<Localized
id="comments-editCommentForm-editRemainingTime"
time={<RelativeTime date={props.editableUntil} live />}
>
<span>{"Edit: <time></time> remaining"}</span>
</Localized>
</ValidationMessage>
)}
<Flex direction="row" justifyContent="flex-end" itemGutter="half">
{props.expired ? (
<Localized id="comments-editCommentForm-close">
<Button
variant="outlined"
disabled={submitting}
onClick={props.onClose}
>
Close
</Button>
</Localized>
) : (
<>
<Localized id="comments-editCommentForm-cancel">
<Button
variant="outlined"
disabled={submitting}
onClick={props.onCancel}
>
Cancel
</Button>
</Localized>
<Localized id="comments-editCommentForm-saveChanges">
<Button
color="primary"
variant="filled"
disabled={submitting || hasValidationErrors || pristine}
type="submit"
>
Save Changes
</Button>
</Localized>
</>
)}
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
);
};
export default EditCommentForm;
@@ -1,6 +1,8 @@
import { Localized } from "fluent-react/compat";
import React, { Component } from "react";
import { graphql } from "react-relay";
import { isBeforeDate } from "talk-common/utils";
import withFragmentContainer from "talk-framework/lib/relay/withFragmentContainer";
import { PropTypesOf } from "talk-framework/types";
import { CommentContainer_asset as AssetData } from "talk-stream/__generated__/CommentContainer_asset.graphql";
@@ -11,10 +13,12 @@ import {
withShowAuthPopupMutation,
} from "talk-stream/mutations";
import { Button } from "talk-ui/components";
import Comment from "../components/Comment";
import ReplyButton from "../components/Comment/ReplyButton";
import ReplyCommentFormContainer from ".//ReplyCommentFormContainer";
import EditCommentFormContainer from "./EditCommentFormContainer";
import PermalinkButtonContainer from "./PermalinkButtonContainer";
import ReplyCommentFormContainer from "./ReplyCommentFormContainer";
interface InnerProps {
me: MeData | null;
@@ -26,13 +30,28 @@ interface InnerProps {
interface State {
showReplyDialog: boolean;
showEditDialog: boolean;
editable: boolean;
}
export class CommentContainer extends Component<InnerProps, State> {
private uneditableTimer: any;
public state = {
showReplyDialog: false,
showEditDialog: false,
editable: isBeforeDate(this.props.comment.editing.editableUntil),
};
constructor(props: InnerProps) {
super(props);
this.uneditableTimer = this.updateWhenNotEditable();
}
public componentWillUnmount() {
clearTimeout(this.uneditableTimer);
}
private openReplyDialog = () => {
if (this.props.me) {
this.setState(state => ({
@@ -43,20 +62,68 @@ export class CommentContainer extends Component<InnerProps, State> {
}
};
private openEditDialog = () => {
if (this.props.me) {
this.setState(state => ({
showEditDialog: true,
}));
} else {
this.props.showAuthPopup({ view: "SIGN_IN" });
}
};
private closeEditDialog = () => {
this.setState(state => ({
showEditDialog: false,
}));
};
private closeReplyDialog = () => {
this.setState(state => ({
showReplyDialog: false,
}));
};
private updateWhenNotEditable() {
const ms =
new Date(this.props.comment.editing.editableUntil).getTime() - Date.now();
if (ms > 0) {
return setTimeout(() => this.setState({ editable: false }), ms);
}
return;
}
public render() {
const { comment, asset, ...rest } = this.props;
const { showReplyDialog } = this.state;
const { showReplyDialog, showEditDialog, editable } = this.state;
if (showEditDialog) {
return (
<EditCommentFormContainer
asset={asset}
comment={comment}
onClose={this.closeEditDialog}
/>
);
}
return (
<>
<Comment
{...rest}
{...comment}
topBarRight={
(editable && (
<Localized id="comments-commentContainer-editButton">
<Button
color="primary"
variant="underlined"
onClick={this.openEditDialog}
>
Edit
</Button>
</Localized>
)) ||
undefined
}
footer={
<>
<ReplyButton
@@ -90,6 +157,7 @@ const enhanced = withShowAuthPopupMutation(
asset: graphql`
fragment CommentContainer_asset on Asset {
...ReplyCommentFormContainer_asset
...EditCommentFormContainer_asset
}
`,
comment: graphql`
@@ -100,7 +168,11 @@ const enhanced = withShowAuthPopupMutation(
}
body
createdAt
editing {
editableUntil
}
...ReplyCommentFormContainer_comment
...EditCommentFormContainer_comment
}
`,
})(CommentContainer)
@@ -0,0 +1,139 @@
import { CoralRTE } from "@coralproject/rte";
import React, { Component } from "react";
import { graphql } from "react-relay";
import { isBeforeDate } from "talk-common/utils";
import { withContext } from "talk-framework/lib/bootstrap";
import { BadUserInputError } from "talk-framework/lib/errors";
import { withFragmentContainer } from "talk-framework/lib/relay";
import { PropTypesOf } from "talk-framework/types";
import { EditCommentFormContainer_asset as AssetData } from "talk-stream/__generated__/EditCommentFormContainer_asset.graphql";
import { EditCommentFormContainer_comment as CommentData } from "talk-stream/__generated__/EditCommentFormContainer_comment.graphql";
import EditCommentForm, {
EditCommentFormProps,
} from "../components/EditCommentForm";
import { EditCommentMutation, withEditCommentMutation } from "../mutations";
interface InnerProps {
editComment: EditCommentMutation;
comment: CommentData;
onClose?: () => void;
asset: AssetData;
autofocus: boolean;
}
interface State {
initialValues?: EditCommentFormProps["initialValues"];
initialized: boolean;
expired: boolean;
}
export class EditCommentFormContainer extends Component<InnerProps, State> {
private expiredTimer: any;
public state: State = {
initialized: false,
expired: !isBeforeDate(this.props.comment.editing.editableUntil),
};
constructor(props: InnerProps) {
super(props);
this.expiredTimer = this.updateWhenExpired();
}
public componentWillUnmount() {
clearTimeout(this.expiredTimer);
}
private updateWhenExpired() {
const ms =
new Date(this.props.comment.editing.editableUntil).getTime() - Date.now();
if (ms > 0) {
return setTimeout(() => this.setState({ expired: true }), ms);
}
return;
}
private handleRTERef = (rte: CoralRTE | null) => {
if (rte && this.props.autofocus) {
rte.focus();
}
};
private handleOnCancelOrClose = () => {
if (this.props.onClose) {
this.props.onClose();
}
};
private handleOnSubmit: EditCommentFormProps["onSubmit"] = async (
input,
form
) => {
try {
await this.props.editComment({
assetID: this.props.asset.id,
commentID: this.props.comment.id,
body: input.body,
});
if (this.props.onClose) {
this.props.onClose();
}
} catch (error) {
if (error instanceof BadUserInputError) {
return error.invalidArgsLocalized;
}
// tslint:disable-next-line:no-console
console.error(error);
}
return undefined;
};
public render() {
return (
<EditCommentForm
id={this.props.comment.id}
onSubmit={this.handleOnSubmit}
initialValues={{ body: this.props.comment.body || "" }}
onCancel={this.handleOnCancelOrClose}
onClose={this.handleOnCancelOrClose}
rteRef={this.handleRTERef}
author={this.props.comment.author}
createdAt={this.props.comment.createdAt}
editableUntil={this.props.comment.editing.editableUntil}
expired={this.state.expired}
/>
);
}
}
const enhanced = withContext(({ sessionStorage, browserInfo }) => ({
// Disable autofocus on ios and enable for the rest.
autofocus: !browserInfo.ios,
}))(
withEditCommentMutation(
withFragmentContainer<InnerProps>({
asset: graphql`
fragment EditCommentFormContainer_asset on Asset {
id
}
`,
comment: graphql`
fragment EditCommentFormContainer_comment on Comment {
id
body
createdAt
author {
username
}
editing {
editableUntil
}
}
`,
})(EditCommentFormContainer)
)
);
export type PostCommentFormContainerProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -92,6 +92,9 @@ function commit(
username: me.username,
},
body: input.body,
editing: {
editableUntil: new Date(Date.now() + 10000),
},
},
},
clientMutationId: (clientMutationId++).toString(),
@@ -0,0 +1,56 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutationContainer,
} from "talk-framework/lib/relay";
import { Omit } from "talk-framework/types";
import { EditCommentMutation as MutationTypes } from "talk-stream/__generated__/EditCommentMutation.graphql";
export type EditCommentInput = Omit<
MutationTypes["variables"]["input"],
"clientMutationId"
>;
const mutation = graphql`
mutation EditCommentMutation($input: EditCommentInput!) {
editComment(input: $input) {
comment {
body
}
clientMutationId
}
}
`;
let clientMutationId = 0;
function commit(environment: Environment, input: EditCommentInput) {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
editComment: {
id: input.commentID,
body: input.body,
clientMutationId: (clientMutationId++).toString(),
},
} as any, // TODO: (cvle) generated types should contain one for the optimistic response.
});
}
export const withEditCommentMutation = createMutationContainer(
"editComment",
commit
);
export type EditCommentMutation = (
input: EditCommentInput
) => Promise<MutationTypes["response"]["editComment"]>;
@@ -3,6 +3,11 @@ export {
CreateCommentMutation,
CreateCommentInput,
} from "./CreateCommentMutation";
export {
withEditCommentMutation,
EditCommentMutation,
EditCommentInput,
} from "./EditCommentMutation";
export {
withSetNetworkStatusMutation,
SetNetworkStatusMutation,
@@ -1,4 +1,2 @@
.root {
composes: bodyCopy from "talk-ui/shared/typography.css";
background-color: transparent;
}
+1
View File
@@ -2,3 +2,4 @@ export { default as timeout } from "./timeout";
export { default as animationFrame } from "./animationFrame";
export { default as pascalCase } from "./pascalCase";
export { default as oncePerFrame } from "./oncePerFrame";
export { default as isBeforeDate } from "./isBeforeDate";
+3
View File
@@ -0,0 +1,3 @@
export default function isBeforeDate(date: string | number | Date) {
return new Date() < new Date(date);
}
+1 -1
View File
@@ -6,10 +6,10 @@
framework-validation-required = Dies ist ein Pflichtpfeld.
framework-timeago-just-now = Gerade eben
framework-timeago =
{ $suffix ->
[ago] vor
[in] in
}
{ $value }
{ $unit ->
+5 -8
View File
@@ -13,6 +13,8 @@ framework-validation-invalidEmail = Please enter a valid email address.
framework-validation-passwordTooShort = Password must contain at least {$minLength} characters.
framework-validation-passwordsDoNotMatch = Passwords do not match. Try again.
framework-timeago-just-now = Just now
framework-timeago-time =
{ $value }
{ $unit ->
@@ -48,12 +50,7 @@ framework-timeago-time =
}
framework-timeago =
{ $value ->
[0] now
*[other]
{ $suffix ->
[ago] {framework-timeago-time} ago
[in] in {framework-timeago-time}
*[other] unknown suffix
}
{ $suffix ->
[ago] {framework-timeago-time} ago
[noSuffix] {framework-timeago-time}
}
+12 -1
View File
@@ -53,4 +53,15 @@ comments-replyCommentForm-submit = Submit
comments-replyCommentForm-cancel = Cancel
comments-replyCommentForm-rteLabel = Write a reply
comments-replyCommentForm-rte =
.placeholder = { comments-postCommentForm-rteLabel }
.placeholder = { comments-replyCommentForm-rteLabel }
comments-commentContainer-editButton = Edit
comments-editCommentForm-saveChanges = Save Changes
comments-editCommentForm-cancel = Cancel
comments-editCommentForm-close = Close
comments-editCommentForm-rteLabel = Edit comment
comments-editCommentForm-rte =
.placeholder = { comments-editCommentForm-rteLabel }
comments-editCommentForm-editRemainingTime = Edit: <time></time> remaining
comments-editCommentForm-editTimeExpired = Edit time has expired. You can no longer edit this comment. Why not post another one?
+1 -3
View File
@@ -2,14 +2,12 @@
### All keys must start with `framework` because this file is shared
### among different targets.
framework-timeago-just-now = Hace poco
framework-timeago =
{ $value ->
[0] ahora
*[other]
{ $suffix ->
[ago] hace
[in] en
*[other] unknown suffix
}
{ $value }
{ $unit ->
+1
View File
@@ -36,6 +36,7 @@ declare module "react-timeago" {
live?: boolean;
className: string;
formatter?: Formatter;
minPeriod?: number;
}
const TimeAgo: React.ComponentType<TimeAgoProps>;