From a36d944e4ad52b797fce415848ff5426780db084 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 11 Sep 2018 00:15:27 +0200 Subject: [PATCH] Implement edit --- .../framework/lib/bootstrap/createContext.tsx | 11 +- .../stream/components/Comment/Comment.css | 3 - .../stream/components/Comment/Comment.tsx | 23 ++- .../{TopBar.spec.tsx => TopBarLeft.spec.tsx} | 10 +- .../Comment/{TopBar.tsx => TopBarLeft.tsx} | 6 +- .../client/stream/components/Comment/index.ts | 3 + .../stream/components/EditCommentForm.tsx | 156 ++++++++++++++++++ .../stream/containers/CommentContainer.tsx | 76 ++++++++- .../containers/EditCommentFormContainer.tsx | 139 ++++++++++++++++ .../stream/mutations/CreateCommentMutation.ts | 3 + .../stream/mutations/EditCommentMutation.ts | 56 +++++++ src/core/client/stream/mutations/index.ts | 5 + .../components/RelativeTime/RelativeTime.css | 2 - src/core/common/utils/index.ts | 1 + src/core/common/utils/isBeforeDate.spec.ts | 0 src/core/common/utils/isBeforeDate.ts | 3 + src/locales/de/framework.ftl | 2 +- src/locales/en-US/framework.ftl | 13 +- src/locales/en-US/stream.ftl | 13 +- src/locales/es/framework.ftl | 4 +- src/types/react-timeago.d.ts | 1 + 21 files changed, 494 insertions(+), 36 deletions(-) rename src/core/client/stream/components/Comment/{TopBar.spec.tsx => TopBarLeft.spec.tsx} (81%) rename src/core/client/stream/components/Comment/{TopBar.tsx => TopBarLeft.tsx} (81%) create mode 100644 src/core/client/stream/components/EditCommentForm.tsx create mode 100644 src/core/client/stream/containers/EditCommentFormContainer.tsx create mode 100644 src/core/client/stream/mutations/EditCommentMutation.ts create mode 100644 src/core/common/utils/isBeforeDate.spec.ts create mode 100644 src/core/common/utils/isBeforeDate.ts diff --git a/src/core/client/framework/lib/bootstrap/createContext.tsx b/src/core/client/framework/lib/bootstrap/createContext.tsx index 839cee453..d11b3b83c 100644 --- a/src/core/client/framework/lib/bootstrap/createContext.tsx +++ b/src/core/client/framework/lib/bootstrap/createContext.tsx @@ -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 ( + + Just now + + ); + } + return ( | Array>; footer?: ReactElement | Array>; } const Comment: StatelessComponent = props => { return (
- - {props.author && - props.author.username && {props.author.username}} - {props.createdAt} - + + + {props.author && + props.author.username && ( + {props.author.username} + )} + {props.createdAt} + +
{props.topBarRight}
+
{props.body || ""} {props.footer} diff --git a/src/core/client/stream/components/Comment/TopBar.spec.tsx b/src/core/client/stream/components/Comment/TopBarLeft.spec.tsx similarity index 81% rename from src/core/client/stream/components/Comment/TopBar.spec.tsx rename to src/core/client/stream/components/Comment/TopBarLeft.spec.tsx index 5ee691f70..8769bb3dd 100644 --- a/src/core/client/stream/components/Comment/TopBar.spec.tsx +++ b/src/core/client/stream/components/Comment/TopBarLeft.spec.tsx @@ -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 = { + const props: PropTypesOf = { children:
Hello World
, }; @@ -19,14 +19,14 @@ it("renders correctly on small screens", () => { const testRenderer = TestRenderer.create( - + ); expect(testRenderer.toJSON()).toMatchSnapshot(); }); it("renders correctly on big screens", () => { - const props: PropTypesOf = { + const props: PropTypesOf = { children:
Hello World
, }; @@ -38,7 +38,7 @@ it("renders correctly on big screens", () => { const testRenderer = TestRenderer.create( - + ); expect(testRenderer.toJSON()).toMatchSnapshot(); diff --git a/src/core/client/stream/components/Comment/TopBar.tsx b/src/core/client/stream/components/Comment/TopBarLeft.tsx similarity index 81% rename from src/core/client/stream/components/Comment/TopBar.tsx rename to src/core/client/stream/components/Comment/TopBarLeft.tsx index eed8aea32..5f3862020 100644 --- a/src/core/client/stream/components/Comment/TopBar.tsx +++ b/src/core/client/stream/components/Comment/TopBarLeft.tsx @@ -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 = props => { +const TopBarLeft: StatelessComponent = props => { const rootClassName = cn(props.className); return ( @@ -27,4 +27,4 @@ const TopBar: StatelessComponent = props => { ); }; -export default TopBar; +export default TopBarLeft; diff --git a/src/core/client/stream/components/Comment/index.ts b/src/core/client/stream/components/Comment/index.ts index abb1d79ae..56c2da651 100644 --- a/src/core/client/stream/components/Comment/index.ts +++ b/src/core/client/stream/components/Comment/index.ts @@ -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"; diff --git a/src/core/client/stream/components/EditCommentForm.tsx b/src/core/client/stream/components/EditCommentForm.tsx new file mode 100644 index 000000000..84c753b1b --- /dev/null +++ b/src/core/client/stream/components/EditCommentForm.tsx @@ -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; + onCancel?: EventHandler>; + onClose?: EventHandler>; + initialValues?: FormProps; + rteRef?: Ref; + expired?: boolean; +} + +const EditCommentForm: StatelessComponent = props => { + const inputID = `comments-editCommentForm-rte-${props.id}`; + return ( +
+ {({ handleSubmit, submitting, hasValidationErrors, pristine }) => ( + + +
+ + {props.author && + props.author.username && ( + {props.author.username} + )} + {props.createdAt} + +
+ + {({ input, meta }) => ( +
+ + + Edit comment + + + + input.onChange(html)} + value={input.value} + placeholder="Edit comment" + forwardRef={props.rteRef} + disabled={submitting || props.expired} + /> + + {meta.touched && + (meta.error || meta.submitError) && ( + + {meta.error || meta.submitError} + + )} +
+ )} +
+ {props.expired ? ( + + + Edit time has expired. You can no longer edit this comment. + Why not post another one? + + + ) : ( + + } + > + {"Edit: remaining"} + + + )} + + {props.expired ? ( + + + + ) : ( + <> + + + + + + + + )} + +
+
+ )} + + ); +}; + +export default EditCommentForm; diff --git a/src/core/client/stream/containers/CommentContainer.tsx b/src/core/client/stream/containers/CommentContainer.tsx index 21b4b767e..c3368b9d0 100644 --- a/src/core/client/stream/containers/CommentContainer.tsx +++ b/src/core/client/stream/containers/CommentContainer.tsx @@ -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 { + 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 { } }; + 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 ( + + ); + } return ( <> + + + )) || + undefined + } footer={ <> void; + asset: AssetData; + autofocus: boolean; +} + +interface State { + initialValues?: EditCommentFormProps["initialValues"]; + initialized: boolean; + expired: boolean; +} + +export class EditCommentFormContainer extends Component { + 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 ( + + ); + } +} +const enhanced = withContext(({ sessionStorage, browserInfo }) => ({ + // Disable autofocus on ios and enable for the rest. + autofocus: !browserInfo.ios, +}))( + withEditCommentMutation( + withFragmentContainer({ + 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; +export default enhanced; diff --git a/src/core/client/stream/mutations/CreateCommentMutation.ts b/src/core/client/stream/mutations/CreateCommentMutation.ts index a293a58e7..958ad9acc 100644 --- a/src/core/client/stream/mutations/CreateCommentMutation.ts +++ b/src/core/client/stream/mutations/CreateCommentMutation.ts @@ -92,6 +92,9 @@ function commit( username: me.username, }, body: input.body, + editing: { + editableUntil: new Date(Date.now() + 10000), + }, }, }, clientMutationId: (clientMutationId++).toString(), diff --git a/src/core/client/stream/mutations/EditCommentMutation.ts b/src/core/client/stream/mutations/EditCommentMutation.ts new file mode 100644 index 000000000..a3d456ed2 --- /dev/null +++ b/src/core/client/stream/mutations/EditCommentMutation.ts @@ -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(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; diff --git a/src/core/client/stream/mutations/index.ts b/src/core/client/stream/mutations/index.ts index 324fddbdc..6c920dfea 100644 --- a/src/core/client/stream/mutations/index.ts +++ b/src/core/client/stream/mutations/index.ts @@ -3,6 +3,11 @@ export { CreateCommentMutation, CreateCommentInput, } from "./CreateCommentMutation"; +export { + withEditCommentMutation, + EditCommentMutation, + EditCommentInput, +} from "./EditCommentMutation"; export { withSetNetworkStatusMutation, SetNetworkStatusMutation, diff --git a/src/core/client/ui/components/RelativeTime/RelativeTime.css b/src/core/client/ui/components/RelativeTime/RelativeTime.css index e0450679f..c3a2af639 100644 --- a/src/core/client/ui/components/RelativeTime/RelativeTime.css +++ b/src/core/client/ui/components/RelativeTime/RelativeTime.css @@ -1,4 +1,2 @@ .root { - composes: bodyCopy from "talk-ui/shared/typography.css"; - background-color: transparent; } diff --git a/src/core/common/utils/index.ts b/src/core/common/utils/index.ts index 28435047e..4d6df9fc5 100644 --- a/src/core/common/utils/index.ts +++ b/src/core/common/utils/index.ts @@ -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"; diff --git a/src/core/common/utils/isBeforeDate.spec.ts b/src/core/common/utils/isBeforeDate.spec.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/core/common/utils/isBeforeDate.ts b/src/core/common/utils/isBeforeDate.ts new file mode 100644 index 000000000..24d2a33e9 --- /dev/null +++ b/src/core/common/utils/isBeforeDate.ts @@ -0,0 +1,3 @@ +export default function isBeforeDate(date: string | number | Date) { + return new Date() < new Date(date); +} diff --git a/src/locales/de/framework.ftl b/src/locales/de/framework.ftl index 55dca1c8c..7ea668be3 100644 --- a/src/locales/de/framework.ftl +++ b/src/locales/de/framework.ftl @@ -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 -> diff --git a/src/locales/en-US/framework.ftl b/src/locales/en-US/framework.ftl index f03b2a50a..89eee969b 100644 --- a/src/locales/en-US/framework.ftl +++ b/src/locales/en-US/framework.ftl @@ -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} } diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index 29aac39c9..683ea792e 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -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: remaining +comments-editCommentForm-editTimeExpired = Edit time has expired. You can no longer edit this comment. Why not post another one? diff --git a/src/locales/es/framework.ftl b/src/locales/es/framework.ftl index a383c75bb..86b5e6cb4 100644 --- a/src/locales/es/framework.ftl +++ b/src/locales/es/framework.ftl @@ -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 -> diff --git a/src/types/react-timeago.d.ts b/src/types/react-timeago.d.ts index 431bbf88e..02161745c 100644 --- a/src/types/react-timeago.d.ts +++ b/src/types/react-timeago.d.ts @@ -36,6 +36,7 @@ declare module "react-timeago" { live?: boolean; className: string; formatter?: Formatter; + minPeriod?: number; } const TimeAgo: React.ComponentType;