diff --git a/src/core/client/admin/test/auth/__snapshots__/addEmailAddress.spec.tsx.snap b/src/core/client/admin/test/auth/__snapshots__/addEmailAddress.spec.tsx.snap index 2f7243e8f..82d39c349 100644 --- a/src/core/client/admin/test/auth/__snapshots__/addEmailAddress.spec.tsx.snap +++ b/src/core/client/admin/test/auth/__snapshots__/addEmailAddress.spec.tsx.snap @@ -65,7 +65,7 @@ exports[`accepts valid email 1`] = ` > @@ -208,7 +208,7 @@ exports[`checks for invalid email 1`] = ` > @@ -245,7 +245,7 @@ exports[`checks for invalid email 1`] = ` > @@ -422,7 +422,7 @@ exports[`shows error when submitting empty form 1`] = ` > @@ -459,7 +459,7 @@ exports[`shows error when submitting empty form 1`] = ` > diff --git a/src/core/client/admin/test/auth/__snapshots__/createPassword.spec.tsx.snap b/src/core/client/admin/test/auth/__snapshots__/createPassword.spec.tsx.snap index a83a3c568..44e2e7b7a 100644 --- a/src/core/client/admin/test/auth/__snapshots__/createPassword.spec.tsx.snap +++ b/src/core/client/admin/test/auth/__snapshots__/createPassword.spec.tsx.snap @@ -26,7 +26,7 @@ we require users to create a password.

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

- Password must contain at least ⁨8⁩ characters. + Password must contain at least 8 characters.
@@ -145,7 +145,7 @@ we require users to create a password.

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

@@ -329,7 +329,7 @@ GraphQL request (4:3)

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

- Username must contain at least ⁨3⁩ characters. + Username must contain at least 3 characters.
@@ -267,7 +267,7 @@ exports[`shows error when submitting empty form 1`] = ` > diff --git a/src/core/client/admin/test/auth/__snapshots__/restricted.spec.tsx.snap b/src/core/client/admin/test/auth/__snapshots__/restricted.spec.tsx.snap index 7f3484586..a25455f8e 100644 --- a/src/core/client/admin/test/auth/__snapshots__/restricted.spec.tsx.snap +++ b/src/core/client/admin/test/auth/__snapshots__/restricted.spec.tsx.snap @@ -83,7 +83,7 @@ exports[`show restricted screen for commenters 1`] = ` > diff --git a/src/core/client/admin/test/auth/__snapshots__/signInWithEmail.spec.tsx.snap b/src/core/client/admin/test/auth/__snapshots__/signInWithEmail.spec.tsx.snap index 2725bfb1c..8f622d720 100644 --- a/src/core/client/admin/test/auth/__snapshots__/signInWithEmail.spec.tsx.snap +++ b/src/core/client/admin/test/auth/__snapshots__/signInWithEmail.spec.tsx.snap @@ -36,7 +36,7 @@ exports[`accepts correct password 1`] = ` > @@ -99,7 +99,7 @@ exports[`accepts correct password 1`] = ` > @@ -189,7 +189,7 @@ exports[`accepts valid email 1`] = ` > @@ -210,7 +210,7 @@ exports[`accepts valid email 1`] = ` > @@ -258,7 +258,7 @@ exports[`checks for invalid email 1`] = ` > @@ -313,7 +313,7 @@ exports[`checks for invalid email 1`] = ` > @@ -334,7 +334,7 @@ exports[`checks for invalid email 1`] = ` > @@ -509,7 +509,7 @@ exports[`renders sign in form 1`] = ` > @@ -565,7 +565,7 @@ exports[`shows error when submitting empty form 1`] = ` > @@ -620,7 +620,7 @@ exports[`shows error when submitting empty form 1`] = ` > @@ -641,7 +641,7 @@ exports[`shows error when submitting empty form 1`] = ` > @@ -744,7 +744,7 @@ exports[`shows server error 1`] = ` > diff --git a/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap b/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap index a6b806088..53dcc8cf0 100644 --- a/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap +++ b/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap @@ -1289,7 +1289,7 @@ needs to be displayed, e.g. “Log in with <Facebook>”. > @@ -1328,7 +1328,7 @@ needs to be displayed, e.g. “Log in with <Facebook>”. > @@ -1367,7 +1367,7 @@ needs to be displayed, e.g. “Log in with <Facebook>”. > @@ -1430,7 +1430,7 @@ the remaining fields. You may also enter the information manually. > @@ -1469,7 +1469,7 @@ the remaining fields. You may also enter the information manually. > @@ -1508,7 +1508,7 @@ the remaining fields. You may also enter the information manually. > @@ -1547,7 +1547,7 @@ the remaining fields. You may also enter the information manually. > @@ -1728,11 +1728,11 @@ exports[`regenerate sso key 1`] = ` className="Typography-root Typography-bodyCopy Typography-colorTextPrimary SSOKeyField-keyGenerated" > KEY GENERATED AT: -⁨11/12/2018, 11:26 PM⁩ +11/12/2018, 11:26 PM

@@ -2621,11 +2621,11 @@ integration to register for a new account. className="Typography-root Typography-bodyCopy Typography-colorTextPrimary SSOKeyField-keyGenerated" > KEY GENERATED AT: -⁨Invalid Date⁩ +Invalid Date

diff --git a/src/core/client/admin/test/configure/__snapshots__/moderation.spec.tsx.snap b/src/core/client/admin/test/configure/__snapshots__/moderation.spec.tsx.snap index 6cc3c2d3e..011de24d9 100644 --- a/src/core/client/admin/test/configure/__snapshots__/moderation.spec.tsx.snap +++ b/src/core/client/admin/test/configure/__snapshots__/moderation.spec.tsx.snap @@ -202,7 +202,7 @@ the className="Typography-root Typography-detail Typography-colorTextSecondary" > This value can be set a percentage between 0 and 100. This number represents the likelihood that a -comment is toxic, according to Perspective API. By default the treshold is set to ⁨80⁩. +comment is toxic, according to Perspective API. By default the treshold is set to 80.

- By default the endpoint is set to ⁨https://commentanalyzer.googleapis.com/v1alpha1⁩. You may override this here. + By default the endpoint is set to https://commentanalyzer.googleapis.com/v1alpha1. You may override this here.

{ expect( within(generalContainer).queryAllByText( - "Please enter a whole number greater than ⁨0⁩" + "Please enter a whole number greater than 0" ).length ).toBe(2); diff --git a/src/core/client/admin/test/configure/moderation.spec.tsx b/src/core/client/admin/test/configure/moderation.spec.tsx index 664c32c6d..c08d04684 100644 --- a/src/core/client/admin/test/configure/moderation.spec.tsx +++ b/src/core/client/admin/test/configure/moderation.spec.tsx @@ -240,7 +240,7 @@ it("change perspective settings", async () => { ).toBe(1); expect( within(perspectiveContainer).queryAllByText( - "Please enter a whole number between ⁨0⁩ and ⁨100⁩." + "Please enter a whole number between 0 and 100." ).length ).toBe(1); diff --git a/src/core/client/admin/test/decisionHistory/__snapshots__/decisionHistory.spec.tsx.snap b/src/core/client/admin/test/decisionHistory/__snapshots__/decisionHistory.spec.tsx.snap index 95ca7d345..0e369f295 100644 --- a/src/core/client/admin/test/decisionHistory/__snapshots__/decisionHistory.spec.tsx.snap +++ b/src/core/client/admin/test/decisionHistory/__snapshots__/decisionHistory.spec.tsx.snap @@ -39,7 +39,7 @@ exports[`loads more 1`] = ` > @@ -103,7 +103,7 @@ exports[`loads more 1`] = ` > @@ -167,7 +167,7 @@ exports[`loads more 1`] = ` > @@ -296,7 +296,7 @@ exports[`render popover content 1`] = ` > @@ -360,7 +360,7 @@ exports[`render popover content 1`] = ` > diff --git a/src/core/client/admin/test/moderate/__snapshots__/moderate.spec.tsx.snap b/src/core/client/admin/test/moderate/__snapshots__/moderate.spec.tsx.snap index 9c278c4b4..72a0150bc 100644 --- a/src/core/client/admin/test/moderate/__snapshots__/moderate.spec.tsx.snap +++ b/src/core/client/admin/test/moderate/__snapshots__/moderate.spec.tsx.snap @@ -252,7 +252,7 @@ exports[`rejected queue accepts comment in rejected queue: dangling 1`] = ` > @@ -271,7 +271,7 @@ exports[`rejected queue accepts comment in rejected queue: dangling 1`] = ` > @@ -405,7 +405,7 @@ exports[`rejected queue renders rejected queue with comments 1`] = ` > @@ -424,7 +424,7 @@ exports[`rejected queue renders rejected queue with comments 1`] = ` > @@ -545,7 +545,7 @@ exports[`rejected queue renders rejected queue with comments 1`] = ` > @@ -564,7 +564,7 @@ exports[`rejected queue renders rejected queue with comments 1`] = ` > @@ -705,7 +705,7 @@ exports[`rejected queue renders rejected queue with comments and load more 1`] = > @@ -724,7 +724,7 @@ exports[`rejected queue renders rejected queue with comments and load more 1`] = > @@ -861,7 +861,7 @@ exports[`reported queue accepts comment in reported queue: dangling 1`] = ` > @@ -880,7 +880,7 @@ exports[`reported queue accepts comment in reported queue: dangling 1`] = ` > @@ -1017,7 +1017,7 @@ exports[`reported queue rejects comment in reported queue: dangling 1`] = ` > @@ -1036,7 +1036,7 @@ exports[`reported queue rejects comment in reported queue: dangling 1`] = ` > @@ -1185,7 +1185,7 @@ exports[`reported queue renders reported queue with comments 1`] = ` > @@ -1204,7 +1204,7 @@ exports[`reported queue renders reported queue with comments 1`] = ` > @@ -1325,7 +1325,7 @@ exports[`reported queue renders reported queue with comments 1`] = ` > @@ -1344,7 +1344,7 @@ exports[`reported queue renders reported queue with comments 1`] = ` > @@ -1489,7 +1489,7 @@ exports[`reported queue renders reported queue with comments and load more 1`] = > @@ -1508,7 +1508,7 @@ exports[`reported queue renders reported queue with comments and load more 1`] = > @@ -1632,7 +1632,7 @@ exports[`single comment view accepts single comment 1`] = ` > @@ -1651,7 +1651,7 @@ exports[`single comment view accepts single comment 1`] = ` > @@ -1775,7 +1775,7 @@ exports[`single comment view rejects single comment 1`] = ` > @@ -1794,7 +1794,7 @@ exports[`single comment view rejects single comment 1`] = ` > @@ -1953,7 +1953,7 @@ exports[`single comment view renders single comment view 1`] = ` > @@ -1972,7 +1972,7 @@ exports[`single comment view renders single comment view 1`] = ` > diff --git a/src/core/client/auth/test/__snapshots__/addEmailAddress.spec.tsx.snap b/src/core/client/auth/test/__snapshots__/addEmailAddress.spec.tsx.snap index 8544f85fb..32558026c 100644 --- a/src/core/client/auth/test/__snapshots__/addEmailAddress.spec.tsx.snap +++ b/src/core/client/auth/test/__snapshots__/addEmailAddress.spec.tsx.snap @@ -134,7 +134,7 @@ Your email address will be used to: > @@ -415,7 +415,7 @@ Your email address will be used to: > @@ -452,7 +452,7 @@ Your email address will be used to: > @@ -755,7 +755,7 @@ Your email address will be used to: > @@ -792,7 +792,7 @@ Your email address will be used to: > diff --git a/src/core/client/auth/test/__snapshots__/createPassword.spec.tsx.snap b/src/core/client/auth/test/__snapshots__/createPassword.spec.tsx.snap index 0080f5fb3..4fc29c659 100644 --- a/src/core/client/auth/test/__snapshots__/createPassword.spec.tsx.snap +++ b/src/core/client/auth/test/__snapshots__/createPassword.spec.tsx.snap @@ -26,7 +26,7 @@ we require users to create a password.

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

- Password must contain at least ⁨8⁩ characters. + Password must contain at least 8 characters.
@@ -135,7 +135,7 @@ we require users to create a password.

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

@@ -317,7 +317,7 @@ GraphQL request (4:3)

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

- Username must contain at least ⁨3⁩ characters. + Username must contain at least 3 characters.
@@ -255,7 +255,7 @@ exports[`shows error when submitting empty form 1`] = ` > diff --git a/src/core/client/auth/test/__snapshots__/signIn.spec.tsx.snap b/src/core/client/auth/test/__snapshots__/signIn.spec.tsx.snap index 1c3e77e38..da6841000 100644 --- a/src/core/client/auth/test/__snapshots__/signIn.spec.tsx.snap +++ b/src/core/client/auth/test/__snapshots__/signIn.spec.tsx.snap @@ -36,7 +36,7 @@ exports[`accepts correct password 1`] = ` > @@ -117,7 +117,7 @@ exports[`accepts correct password 1`] = ` > @@ -207,7 +207,7 @@ exports[`accepts valid email 1`] = ` > @@ -246,7 +246,7 @@ exports[`accepts valid email 1`] = ` > @@ -369,7 +369,7 @@ exports[`auth configuration renders all auth enabled 1`] = ` > @@ -457,7 +457,7 @@ exports[`auth configuration renders all auth enabled 1`] = ` type="button" > - Sign in with ⁨OIDC⁩ + Sign in with OIDC
@@ -569,7 +569,7 @@ exports[`checks for invalid email 1`] = ` > @@ -624,7 +624,7 @@ exports[`checks for invalid email 1`] = ` > @@ -663,7 +663,7 @@ exports[`checks for invalid email 1`] = ` > @@ -832,7 +832,7 @@ exports[`renders sign in view 1`] = ` > @@ -1012,7 +1012,7 @@ exports[`renders sign in view with error 1`] = ` > @@ -1066,7 +1066,7 @@ exports[`shows error when submitting empty form 1`] = ` > @@ -1121,7 +1121,7 @@ exports[`shows error when submitting empty form 1`] = ` > @@ -1160,7 +1160,7 @@ exports[`shows error when submitting empty form 1`] = ` > @@ -1281,7 +1281,7 @@ exports[`shows server error 1`] = ` > @@ -1397,7 +1397,7 @@ exports[`submits form successfully 1`] = ` > diff --git a/src/core/client/auth/test/__snapshots__/signUp.spec.tsx.snap b/src/core/client/auth/test/__snapshots__/signUp.spec.tsx.snap index e0806e48d..255761818 100644 --- a/src/core/client/auth/test/__snapshots__/signUp.spec.tsx.snap +++ b/src/core/client/auth/test/__snapshots__/signUp.spec.tsx.snap @@ -43,7 +43,7 @@ exports[`accepts correct password 1`] = ` > @@ -85,7 +85,7 @@ exports[`accepts correct password 1`] = ` > @@ -106,7 +106,7 @@ exports[`accepts correct password 1`] = `

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

@@ -242,7 +242,7 @@ exports[`accepts valid email 1`] = ` > @@ -263,7 +263,7 @@ exports[`accepts valid email 1`] = `

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

@@ -323,7 +323,7 @@ exports[`accepts valid email 1`] = ` > @@ -383,7 +383,7 @@ exports[`accepts valid username 1`] = ` > @@ -433,7 +433,7 @@ exports[`accepts valid username 1`] = `

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

@@ -493,7 +493,7 @@ exports[`accepts valid username 1`] = ` > @@ -590,7 +590,7 @@ exports[`auth configuration renders all auth enabled 1`] = `

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

@@ -725,7 +725,7 @@ exports[`auth configuration renders all auth enabled 1`] = ` type="button" > - Sign up with ⁨OIDC⁩ + Sign up with OIDC
@@ -844,7 +844,7 @@ exports[`checks for invalid characters in username 1`] = ` > @@ -886,7 +886,7 @@ exports[`checks for invalid characters in username 1`] = ` > @@ -907,7 +907,7 @@ exports[`checks for invalid characters in username 1`] = `

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

@@ -967,7 +967,7 @@ exports[`checks for invalid characters in username 1`] = ` > @@ -1027,7 +1027,7 @@ exports[`checks for invalid email 1`] = ` > @@ -1069,7 +1069,7 @@ exports[`checks for invalid email 1`] = ` > @@ -1090,7 +1090,7 @@ exports[`checks for invalid email 1`] = `

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

@@ -1150,7 +1150,7 @@ exports[`checks for invalid email 1`] = ` > @@ -1210,7 +1210,7 @@ exports[`checks for too long username 1`] = ` > @@ -1252,12 +1252,12 @@ exports[`checks for too long username 1`] = ` > - Usernames cannot be longer than ⁨30⁩ characters. + Usernames cannot be longer than 30 characters.
@@ -1273,7 +1273,7 @@ exports[`checks for too long username 1`] = `

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

@@ -1333,7 +1333,7 @@ exports[`checks for too long username 1`] = ` > @@ -1393,7 +1393,7 @@ exports[`checks for too short password 1`] = ` > @@ -1435,7 +1435,7 @@ exports[`checks for too short password 1`] = ` > @@ -1456,7 +1456,7 @@ exports[`checks for too short password 1`] = `

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

- Password must contain at least ⁨8⁩ characters. + Password must contain at least 8 characters.
@@ -1516,7 +1516,7 @@ exports[`checks for too short password 1`] = ` > @@ -1576,7 +1576,7 @@ exports[`checks for too short username 1`] = ` > @@ -1618,12 +1618,12 @@ exports[`checks for too short username 1`] = ` > - Username must contain at least ⁨3⁩ characters. + Username must contain at least 3 characters.
@@ -1639,7 +1639,7 @@ exports[`checks for too short username 1`] = `

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

@@ -1699,7 +1699,7 @@ exports[`checks for too short username 1`] = ` > @@ -1842,7 +1842,7 @@ exports[`renders sign up form 1`] = `

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

@@ -1951,7 +1951,7 @@ exports[`shows error when submitting empty form 1`] = ` > @@ -1993,7 +1993,7 @@ exports[`shows error when submitting empty form 1`] = ` > @@ -2014,7 +2014,7 @@ exports[`shows error when submitting empty form 1`] = `

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

@@ -2074,7 +2074,7 @@ exports[`shows error when submitting empty form 1`] = ` > @@ -2176,7 +2176,7 @@ exports[`shows server error 1`] = `

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

@@ -2320,7 +2320,7 @@ exports[`submits form successfully 1`] = `

- Must be at least ⁨8⁩ characters + Must be at least 8 characters

diff --git a/src/core/client/framework/lib/validation.tsx b/src/core/client/framework/lib/validation.tsx index 233481943..f2b55236c 100644 --- a/src/core/client/framework/lib/validation.tsx +++ b/src/core/client/framework/lib/validation.tsx @@ -88,18 +88,24 @@ export const validateURL = createValidator( /** * validateMinLength is a Validator that checks that the field has a min length of characters */ -export const validateMinLength = (minLength: number) => +export const validateMinLength = ( + minLength: number, + getLength: (v: any) => number = v => v.length +) => createValidator( - v => !v || v.length >= minLength, + v => !v || getLength(v) >= minLength, VALIDATION_TOO_SHORT(minLength) ); /** * validateMaxLength is a Validator that checks that the field has max length of characters */ -export const validateMaxLength = (maxLength: number) => +export const validateMaxLength = ( + maxLength: number, + getLength: (v: any) => number = v => v.length +) => createValidator( - v => !v || v.length <= maxLength, + v => !v || getLength(v) <= maxLength, VALIDATION_TOO_LONG(maxLength) ); diff --git a/src/core/client/framework/testHelpers/createFluentBundle.ts b/src/core/client/framework/testHelpers/createFluentBundle.ts index 436a20fc7..1edc6d701 100644 --- a/src/core/client/framework/testHelpers/createFluentBundle.ts +++ b/src/core/client/framework/testHelpers/createFluentBundle.ts @@ -38,7 +38,10 @@ function createFluentBundle( target: string, pathToLocale: string ): FluentBundle { - const bundle = new FluentBundle("en-US", { functions }); + // `useIsolating: false` will remove bidi characterse. + // See https://github.com/projectfluent/fluent.js/commit/41e5445d2e399f090306c5b9a084bcce5111bbd3 + // And https://www.w3.org/International/questions/qa-bidi-unicode-controls + const bundle = new FluentBundle("en-US", { functions, useIsolating: false }); const files = fs.readdirSync(pathToLocale); const prefixes = commonPrefixes.concat(target); files.forEach(f => { diff --git a/src/core/client/framework/testHelpers/matchText.ts b/src/core/client/framework/testHelpers/matchText.ts index 10718ffec..d46658da5 100644 --- a/src/core/client/framework/testHelpers/matchText.ts +++ b/src/core/client/framework/testHelpers/matchText.ts @@ -25,6 +25,7 @@ export default function matchText( a = a.replace(/\s+/g, " "); b = b.replace(/\s+/g, " "); } + if (options.exact || options.exact === undefined) { return a === b; } diff --git a/src/core/client/stream/fetches/RefreshSettingsQuery.ts b/src/core/client/stream/fetches/RefreshSettingsQuery.ts new file mode 100644 index 000000000..25473b3a7 --- /dev/null +++ b/src/core/client/stream/fetches/RefreshSettingsQuery.ts @@ -0,0 +1,31 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { createFetchContainer, fetchQuery } from "talk-framework/lib/relay"; +import { RefreshSettingsQuery as QueryTypes } from "talk-stream/__generated__/RefreshSettingsQuery.graphql"; + +const query = graphql` + query RefreshSettingsQuery { + settings { + ...StreamContainer_settings + } + } +`; + +function fetch(environment: Environment) { + return fetchQuery( + environment, + query, + {}, + { force: true } + ); +} + +export const withRefreshSettingsFetch = createFetchContainer( + "refreshSettings", + fetch +); + +export type RefreshSettingsFetch = () => Promise< + QueryTypes["response"]["settings"] +>; diff --git a/src/core/client/stream/fetches/index.ts b/src/core/client/stream/fetches/index.ts new file mode 100644 index 000000000..dfa5308c4 --- /dev/null +++ b/src/core/client/stream/fetches/index.ts @@ -0,0 +1,4 @@ +export { + withRefreshSettingsFetch, + RefreshSettingsFetch, +} from "./RefreshSettingsQuery"; diff --git a/src/core/client/stream/tabs/comments/components/EditCommentForm.tsx b/src/core/client/stream/tabs/comments/components/EditCommentForm.tsx index 245ccfba2..92f8c2e11 100644 --- a/src/core/client/stream/tabs/comments/components/EditCommentForm.tsx +++ b/src/core/client/stream/tabs/comments/components/EditCommentForm.tsx @@ -9,7 +9,6 @@ import React, { import { Field, Form } from "react-final-form"; import { OnSubmit } from "talk-framework/lib/form"; -import { required } from "talk-framework/lib/validation"; import Timestamp from "talk-stream/components/Timestamp"; import { AriaInfo, @@ -23,6 +22,8 @@ import { ValidationMessage, } from "talk-ui/components"; +import RemainingCharactersContainer from "../containers/RemainingCharactersContainer"; +import { cleanupRTEEmptyHTML, getCommentBodyValidators } from "../helpers"; import { TopBarLeft, Username } from "./Comment"; import RTE from "./RTE"; @@ -44,19 +45,15 @@ export interface EditCommentFormProps { initialValues?: FormProps; rteRef?: Ref; expired?: boolean; + min: number | null; + max: number | null; } const EditCommentForm: StatelessComponent = props => { const inputID = `comments-editCommentForm-rte-${props.id}`; return (
- {({ - handleSubmit, - submitting, - hasValidationErrors, - pristine, - submitError, - }) => ( + {({ handleSubmit, submitting, pristine, submitError }) => ( = props => { {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} - + <> + + + + Edit comment + + + + + input.onChange(cleanupRTEEmptyHTML(html)) + } + value={input.value} + placeholder="Edit comment" + forwardRef={props.rteRef} + disabled={submitting || props.expired} + /> + + {props.expired ? ( + + + Edit time has expired. You can no longer edit this + comment. Why not post another one? + + + ) : ( + <> + + alarm + + } + > + {"Edit: remaining"} + + + {meta.touched && + (meta.error || + (meta.submitError && + !meta.dirtySinceLastSubmit)) && ( + + {meta.error || meta.submitError} + + )} + {submitError && ( + + {submitError} + + )} + {props.max && ( + + )} + )} - {submitError && ( - - {submitError} - - )} - - )} - - {props.expired ? ( - - - Edit time has expired. You can no longer edit this comment. - Why not post another one? - - - ) : ( - - alarm - } - > - {"Edit: remaining"} - - - )} - - {props.expired ? ( - - - - ) : ( - - {matches => ( - <> - + {props.expired ? ( + - - - - - )} - + ) : ( + + {matches => ( + <> + + + + + + + + )} + + )} + + )} - + )} diff --git a/src/core/client/stream/tabs/comments/components/PostCommentForm.tsx b/src/core/client/stream/tabs/comments/components/PostCommentForm.tsx index 7dbd34e50..805b1f8af 100644 --- a/src/core/client/stream/tabs/comments/components/PostCommentForm.tsx +++ b/src/core/client/stream/tabs/comments/components/PostCommentForm.tsx @@ -1,16 +1,16 @@ -import { FormState } from "final-form"; +import { FormApi, FormState } from "final-form"; import { Localized } from "fluent-react/compat"; import React, { StatelessComponent } from "react"; import { Field, Form, FormSpy } from "react-final-form"; +import ValidationMessage from "talk-admin/routes/configure/components/ValidationMessage"; import { OnSubmit } from "talk-framework/lib/form"; -import { required } from "talk-framework/lib/validation"; import { AriaInfo, Button, Flex, HorizontalGutter } from "talk-ui/components"; -import PoweredBy from "./PoweredBy"; +import RemainingCharactersContainer from "../containers/RemainingCharactersContainer"; +import { cleanupRTEEmptyHTML, getCommentBodyValidators } from "../helpers"; import RTE from "./RTE"; -import ValidationMessage from "talk-admin/routes/configure/components/ValidationMessage"; import styles from "./PostCommentForm.css"; interface FormProps { @@ -19,73 +19,88 @@ interface FormProps { export interface PostCommentFormProps { onSubmit: OnSubmit; - onChange?: (state: FormState) => void; + onChange?: (state: FormState, form: FormApi) => void; initialValues?: FormProps; + min: number | null; + max: number | null; } const PostCommentForm: StatelessComponent = props => (
- {({ handleSubmit, submitting, hasValidationErrors, submitError }) => ( + {({ handleSubmit, submitting, submitError, form }) => ( - + props.onChange && props.onChange(state, form)} + /> - + {({ input, meta }) => ( - - - + + + + Post a comment + + + - Post a comment - - - - input.onChange(html)} - value={input.value} - placeholder="Post a comment" - disabled={submitting} - /> - - {meta.touched && - (meta.error || meta.submitError) && ( + + input.onChange(cleanupRTEEmptyHTML(html)) + } + value={input.value} + placeholder="Post a comment" + disabled={submitting} + /> + + {meta.touched && + (meta.error || + (meta.submitError && !meta.dirtySinceLastSubmit)) && ( + + {meta.error || meta.submitError} + + )} + {submitError && ( - {meta.error || meta.submitError} + {submitError} )} - {submitError && ( - {submitError} - )} - + {props.max && ( + + )} + + + + + + + )} - - - - - - )} diff --git a/src/core/client/stream/tabs/comments/components/RemainingCharacters.tsx b/src/core/client/stream/tabs/comments/components/RemainingCharacters.tsx new file mode 100644 index 000000000..d2452e102 --- /dev/null +++ b/src/core/client/stream/tabs/comments/components/RemainingCharacters.tsx @@ -0,0 +1,34 @@ +import cn from "classnames"; +import { Localized } from "fluent-react/compat"; +import React, { StatelessComponent } from "react"; +import { Flex, Icon, Typography } from "talk-ui/components"; + +interface Props { + className?: string; + remaining: number; +} + +const RemainingCharacters: StatelessComponent = props => { + const belowZero = props.remaining < 0; + return ( + + {belowZero && warning} + + + {"X characters remaining"} + + + + ); +}; + +export default RemainingCharacters; diff --git a/src/core/client/stream/tabs/comments/components/ReplyCommentForm.tsx b/src/core/client/stream/tabs/comments/components/ReplyCommentForm.tsx index c3159bbd9..5dac5bea7 100644 --- a/src/core/client/stream/tabs/comments/components/ReplyCommentForm.tsx +++ b/src/core/client/stream/tabs/comments/components/ReplyCommentForm.tsx @@ -1,5 +1,5 @@ import { CoralRTE } from "@coralproject/rte"; -import { FormState } from "final-form"; +import { FormApi, FormState } from "final-form"; import { Localized } from "fluent-react/compat"; import React, { EventHandler, @@ -10,7 +10,6 @@ import 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, @@ -20,6 +19,8 @@ import { ValidationMessage, } from "talk-ui/components"; +import RemainingCharactersContainer from "../containers/RemainingCharactersContainer"; +import { cleanupRTEEmptyHTML, getCommentBodyValidators } from "../helpers"; import ReplyTo from "./ReplyTo"; import RTE from "./RTE"; @@ -32,96 +33,115 @@ export interface ReplyCommentFormProps { className?: string; onSubmit: OnSubmit; onCancel?: EventHandler>; - onChange?: (state: FormState) => void; + onChange?: (state: FormState, form: FormApi) => void; initialValues?: FormProps; rteRef?: Ref; parentUsername: string | null; + min: number | null; + max: number | null; } const ReplyCommentForm: StatelessComponent = props => { const inputID = `comments-replyCommentForm-rte-${props.id}`; return (
- {({ handleSubmit, submitting, hasValidationErrors, submitError }) => ( + {({ handleSubmit, submitting, form, submitError }) => ( - + props.onChange && props.onChange(state, form)} + /> - + {({ input, meta }) => ( - -
- - - Write a reply - - - {props.parentUsername && ( - - )} - - input.onChange(html)} - value={input.value} - placeholder="Write a reply" - forwardRef={props.rteRef} - disabled={submitting} - /> - -
- {meta.touched && - (meta.error || meta.submitError) && ( + <> + +
+ + + Write a reply + + + {props.parentUsername && ( + + )} + + + input.onChange(cleanupRTEEmptyHTML(html)) + } + value={input.value} + placeholder="Write a reply" + forwardRef={props.rteRef} + disabled={submitting} + /> + +
+ {meta.touched && + (meta.error || + (meta.submitError && !meta.dirtySinceLastSubmit)) && ( + + {meta.error || meta.submitError} + + )} + {submitError && ( - {meta.error || meta.submitError} + {submitError} )} - {submitError && ( - - {submitError} - - )} -
+ {props.max && ( + + )} +
+ + + {matches => ( + + + + + + + + + )} + + )}
- - {matches => ( - - - - - - - - - )} -
)} diff --git a/src/core/client/stream/tabs/comments/components/Stream.tsx b/src/core/client/stream/tabs/comments/components/Stream.tsx index 9ca0ce5c4..b91f1e924 100644 --- a/src/core/client/stream/tabs/comments/components/Stream.tsx +++ b/src/core/client/stream/tabs/comments/components/Stream.tsx @@ -24,7 +24,8 @@ export interface StreamProps { settings: PropTypesOf["settings"] & PropTypesOf["settings"] & PropTypesOf["settings"] & - PropTypesOf["settings"]; + PropTypesOf["settings"] & + PropTypesOf["settings"]; comments: ReadonlyArray< { id: string } & PropTypesOf["comment"] & PropTypesOf["comment"] @@ -49,7 +50,10 @@ const Stream: StatelessComponent = props => { {props.me ? ( - + ) : ( )} diff --git a/src/core/client/stream/tabs/comments/components/__snapshots__/Stream.spec.tsx.snap b/src/core/client/stream/tabs/comments/components/__snapshots__/Stream.spec.tsx.snap index ceb610671..e3d35fbb0 100644 --- a/src/core/client/stream/tabs/comments/components/__snapshots__/Stream.spec.tsx.snap +++ b/src/core/client/stream/tabs/comments/components/__snapshots__/Stream.spec.tsx.snap @@ -472,7 +472,15 @@ exports[`when use is logged in renders correctly 1`] = ` } } /> - diff --git a/src/core/client/stream/tabs/comments/containers/CommentContainer.tsx b/src/core/client/stream/tabs/comments/containers/CommentContainer.tsx index fdefb8130..0f1b4ce17 100644 --- a/src/core/client/stream/tabs/comments/containers/CommentContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/CommentContainer.tsx @@ -151,6 +151,7 @@ export class CommentContainer extends Component { return (
@@ -231,6 +232,7 @@ export class CommentContainer extends Component { /> {showReplyDialog && ( void; autofocus: boolean; + refreshSettings: RefreshSettingsFetch; } interface State { @@ -83,6 +91,9 @@ export class EditCommentFormContainer extends Component { } } catch (error) { if (error instanceof InvalidRequestError) { + if (shouldTriggerSettingsRefresh(error.code)) { + await this.props.refreshSettings(); + } return error.invalidArgs; } // tslint:disable-next-line:no-console @@ -104,6 +115,16 @@ export class EditCommentFormContainer extends Component { createdAt={this.props.comment.createdAt} editableUntil={this.props.comment.editing.editableUntil} expired={this.state.expired} + min={ + (this.props.settings.charCount.enabled && + this.props.settings.charCount.min) || + null + } + max={ + (this.props.settings.charCount.enabled && + this.props.settings.charCount.max) || + null + } /> ); } @@ -112,22 +133,33 @@ const enhanced = withContext(({ sessionStorage, browserInfo }) => ({ // Disable autofocus on ios and enable for the rest. autofocus: !browserInfo.ios, }))( - withEditCommentMutation( - withFragmentContainer({ - comment: graphql` - fragment EditCommentFormContainer_comment on Comment { - id - body - createdAt - author { - username + withRefreshSettingsFetch( + withEditCommentMutation( + withFragmentContainer({ + comment: graphql` + fragment EditCommentFormContainer_comment on Comment { + id + body + createdAt + author { + username + } + editing { + editableUntil + } } - editing { - editableUntil + `, + settings: graphql` + fragment EditCommentFormContainer_settings on Settings { + charCount { + enabled + min + max + } } - } - `, - })(EditCommentFormContainer) + `, + })(EditCommentFormContainer) + ) ) ); export type PostCommentFormContainerProps = PropTypesOf; diff --git a/src/core/client/stream/tabs/comments/containers/PostCommentFormContainer.spec.tsx b/src/core/client/stream/tabs/comments/containers/PostCommentFormContainer.spec.tsx index 432151618..367e249ae 100644 --- a/src/core/client/stream/tabs/comments/containers/PostCommentFormContainer.spec.tsx +++ b/src/core/client/stream/tabs/comments/containers/PostCommentFormContainer.spec.tsx @@ -5,52 +5,62 @@ import sinon from "sinon"; import { timeout } from "talk-common/utils"; import { createPromisifiedStorage } from "talk-framework/lib/storage"; +import { removeFragmentRefs } from "talk-framework/testHelpers"; import { PropTypesOf } from "talk-framework/types"; + import { PostCommentFormContainer } from "./PostCommentFormContainer"; const contextKey = "postCommentFormBody"; +const PostCommentFormContainerN = removeFragmentRefs(PostCommentFormContainer); -it("renders correctly", async () => { - const props: PropTypesOf = { - // tslint:disable-next-line:no-empty - createComment: (() => {}) as any, +function createDefaultProps(): PropTypesOf { + return { + createComment: noop as any, + refreshSettings: noop as any, storyID: "story-id", sessionStorage: createPromisifiedStorage(), + settings: { + charCount: { + enabled: true, + min: 3, + max: 100, + }, + }, + }; +} + +it("renders correctly", async () => { + const props: PropTypesOf = { + ...createDefaultProps(), }; - const wrapper = shallow(); + const wrapper = shallow(); await timeout(); wrapper.update(); expect(wrapper).toMatchSnapshot(); }); it("renders with initialValues", async () => { - const props: PropTypesOf = { - // tslint:disable-next-line:no-empty - createComment: (() => {}) as any, - storyID: "story-id", - sessionStorage: createPromisifiedStorage(), + const props: PropTypesOf = { + ...createDefaultProps(), }; await props.sessionStorage.setItem(contextKey, "Hello World!"); - const wrapper = shallow(); + const wrapper = shallow(); await timeout(); wrapper.update(); expect(wrapper).toMatchSnapshot(); }); it("save values", async () => { - const props: PropTypesOf = { - // tslint:disable-next-line:no-empty - createComment: (() => {}) as any, - storyID: "story-id", - sessionStorage: createPromisifiedStorage(), + const props: PropTypesOf = { + ...createDefaultProps(), }; await props.sessionStorage.setItem(contextKey, "Hello World!"); - const wrapper = shallow(); + const wrapper = shallow(); await timeout(); wrapper.update(); wrapper @@ -71,16 +81,15 @@ it("creates a comment", async () => { .withArgs({}) .once(); - const props: PropTypesOf = { - // tslint:disable-next-line:no-empty + const props: PropTypesOf = { + ...createDefaultProps(), createComment: createCommentStub, storyID, - sessionStorage: createPromisifiedStorage(), }; await props.sessionStorage.setItem(contextKey, "Hello World!"); - const wrapper = shallow(); + const wrapper = shallow(); await timeout(); wrapper.update(); wrapper diff --git a/src/core/client/stream/tabs/comments/containers/PostCommentFormContainer.tsx b/src/core/client/stream/tabs/comments/containers/PostCommentFormContainer.tsx index 065f75300..75611b468 100644 --- a/src/core/client/stream/tabs/comments/containers/PostCommentFormContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/PostCommentFormContainer.tsx @@ -2,21 +2,30 @@ import React, { Component } from "react"; import { withContext } from "talk-framework/lib/bootstrap"; import { InvalidRequestError } from "talk-framework/lib/errors"; +import { graphql, withFragmentContainer } from "talk-framework/lib/relay"; import { PromisifiedStorage } from "talk-framework/lib/storage"; import { PropTypesOf } from "talk-framework/types"; - +import { PostCommentFormContainer_settings as SettingsData } from "talk-stream/__generated__/PostCommentFormContainer_settings.graphql"; +import { + RefreshSettingsFetch, + withRefreshSettingsFetch, +} from "talk-stream/fetches"; import { CreateCommentMutation, withCreateCommentMutation, } from "talk-stream/mutations"; + import PostCommentForm, { PostCommentFormProps, } from "../components/PostCommentForm"; +import { shouldTriggerSettingsRefresh } from "../helpers"; interface Props { createComment: CreateCommentMutation; + refreshSettings: RefreshSettingsFetch; storyID: string; sessionStorage: PromisifiedStorage; + settings: SettingsData; } interface State { @@ -60,6 +69,9 @@ export class PostCommentFormContainer extends Component { form.reset({}); } catch (error) { if (error instanceof InvalidRequestError) { + if (shouldTriggerSettingsRefresh(error.code)) { + await this.props.refreshSettings(); + } return error.invalidArgs; } // tslint:disable-next-line:no-console @@ -68,12 +80,16 @@ export class PostCommentFormContainer extends Component { return undefined; }; - private handleOnChange: PostCommentFormProps["onChange"] = state => { + private handleOnChange: PostCommentFormProps["onChange"] = (state, form) => { if (state.values.body) { this.props.sessionStorage.setItem(contextKey, state.values.body); } else { this.props.sessionStorage.removeItem(contextKey); } + // Reset errors whenever user clears the form. + if (state.touched && state.touched.body && !state.values.body) { + form.reset({}); + } }; public render() { @@ -85,6 +101,16 @@ export class PostCommentFormContainer extends Component { onSubmit={this.handleOnSubmit} onChange={this.handleOnChange} initialValues={this.state.initialValues} + min={ + (this.props.settings.charCount.enabled && + this.props.settings.charCount.min) || + null + } + max={ + (this.props.settings.charCount.enabled && + this.props.settings.charCount.max) || + null + } /> ); } @@ -92,6 +118,22 @@ export class PostCommentFormContainer extends Component { const enhanced = withContext(({ sessionStorage }) => ({ sessionStorage, -}))(withCreateCommentMutation(PostCommentFormContainer)); +}))( + withCreateCommentMutation( + withRefreshSettingsFetch( + withFragmentContainer({ + settings: graphql` + fragment PostCommentFormContainer_settings on Settings { + charCount { + enabled + min + max + } + } + `, + })(PostCommentFormContainer) + ) + ) +); export type PostCommentFormContainerProps = PropTypesOf; export default enhanced; diff --git a/src/core/client/stream/tabs/comments/containers/RemainingCharactersContainer.tsx b/src/core/client/stream/tabs/comments/containers/RemainingCharactersContainer.tsx new file mode 100644 index 000000000..33aadeaca --- /dev/null +++ b/src/core/client/stream/tabs/comments/containers/RemainingCharactersContainer.tsx @@ -0,0 +1,21 @@ +import React, { StatelessComponent } from "react"; + +import RemainingCharacters from "../components/RemainingCharacters"; +import { getHTMLCharacterLength } from "../helpers"; + +interface Props { + className?: string; + max: number; + value: string | undefined; +} + +const RemainingCharactersContainer: StatelessComponent = props => { + return ( + + ); +}; + +export default RemainingCharactersContainer; diff --git a/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.spec.tsx b/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.spec.tsx index d5b83a454..32e9091fe 100644 --- a/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.spec.tsx +++ b/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.spec.tsx @@ -18,9 +18,10 @@ function getContextKey(commentID: string) { return `replyCommentFormBody-${commentID}`; } -it("renders correctly", async () => { - const props: PropTypesOf = { +function createDefaultProps(): PropTypesOf { + return { createCommentReply: noop as any, + refreshSettings: noop as any, story: { id: "story-id", }, @@ -35,6 +36,19 @@ it("renders correctly", async () => { }, sessionStorage: createPromisifiedStorage(), autofocus: false, + settings: { + charCount: { + enabled: true, + min: 3, + max: 100, + }, + }, + }; +} + +it("renders correctly", async () => { + const props: PropTypesOf = { + ...createDefaultProps(), }; const renderer = createRenderer(); @@ -45,21 +59,7 @@ it("renders correctly", async () => { it("renders with initialValues", async () => { const props: PropTypesOf = { - createCommentReply: noop as any, - story: { - id: "story-id", - }, - comment: { - id: "comment-id", - author: { - username: "Joe", - }, - revision: { - id: "revision-id", - }, - }, - sessionStorage: createPromisifiedStorage(), - autofocus: false, + ...createDefaultProps(), }; await props.sessionStorage.setItem( @@ -75,21 +75,7 @@ it("renders with initialValues", async () => { it("save values", async () => { const props: PropTypesOf = { - createCommentReply: noop as any, - story: { - id: "story-id", - }, - comment: { - id: "comment-id", - author: { - username: "Joe", - }, - revision: { - id: "revision-id", - }, - }, - sessionStorage: createPromisifiedStorage(), - autofocus: false, + ...createDefaultProps(), }; await props.sessionStorage.setItem( @@ -117,22 +103,9 @@ it("creates a comment", async () => { const onCloseStub = sinon.stub(); const props: PropTypesOf = { - createCommentReply: createCommentStub, - story: { - id: "story-id", - }, - comment: { - id: "comment-id", - author: { - username: "Joe", - }, - revision: { - id: "revision-id", - }, - }, - sessionStorage: createPromisifiedStorage(), + ...createDefaultProps(), onClose: onCloseStub, - autofocus: false, + createCommentReply: createCommentStub, }; await props.sessionStorage.setItem( @@ -162,22 +135,8 @@ it("creates a comment", async () => { it("closes on cancel", async () => { const onCloseStub = sinon.stub(); const props: PropTypesOf = { - createCommentReply: noop as any, - story: { - id: "story-id", - }, - comment: { - id: "comment-id", - author: { - username: "Joe", - }, - revision: { - id: "revision-id", - }, - }, - sessionStorage: createPromisifiedStorage(), + ...createDefaultProps(), onClose: onCloseStub, - autofocus: false, }; await props.sessionStorage.setItem( @@ -203,20 +162,7 @@ it("autofocuses", async () => { const focusStub = sinon.stub(); const rte = { focus: focusStub }; const props: PropTypesOf = { - createCommentReply: noop as any, - story: { - id: "story-id", - }, - comment: { - id: "comment-id", - author: { - username: "Joe", - }, - revision: { - id: "revision-id", - }, - }, - sessionStorage: createPromisifiedStorage(), + ...createDefaultProps(), autofocus: true, }; diff --git a/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.tsx b/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.tsx index d8ab4a213..2b4ec1f28 100644 --- a/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.tsx @@ -8,7 +8,12 @@ import { withFragmentContainer } from "talk-framework/lib/relay"; import { PromisifiedStorage } from "talk-framework/lib/storage"; import { PropTypesOf } from "talk-framework/types"; import { ReplyCommentFormContainer_comment as CommentData } from "talk-stream/__generated__/ReplyCommentFormContainer_comment.graphql"; +import { ReplyCommentFormContainer_settings as SettingsData } from "talk-stream/__generated__/ReplyCommentFormContainer_settings.graphql"; import { ReplyCommentFormContainer_story as StoryData } from "talk-stream/__generated__/ReplyCommentFormContainer_story.graphql"; +import { + RefreshSettingsFetch, + withRefreshSettingsFetch, +} from "talk-stream/fetches"; import { CreateCommentReplyMutation, withCreateCommentReplyMutation, @@ -17,15 +22,18 @@ import { import ReplyCommentForm, { ReplyCommentFormProps, } from "../components/ReplyCommentForm"; +import { shouldTriggerSettingsRefresh } from "../helpers"; interface Props { createCommentReply: CreateCommentReplyMutation; sessionStorage: PromisifiedStorage; comment: CommentData; + settings: SettingsData; story: StoryData; onClose?: () => void; autofocus: boolean; localReply?: boolean; + refreshSettings: RefreshSettingsFetch; } interface State { @@ -88,6 +96,9 @@ export class ReplyCommentFormContainer extends Component { } } catch (error) { if (error instanceof InvalidRequestError) { + if (shouldTriggerSettingsRefresh(error.code)) { + await this.props.refreshSettings(); + } return error.invalidArgs; } // tslint:disable-next-line:no-console @@ -96,12 +107,16 @@ export class ReplyCommentFormContainer extends Component { return undefined; }; - private handleOnChange: ReplyCommentFormProps["onChange"] = state => { + private handleOnChange: ReplyCommentFormProps["onChange"] = (state, form) => { if (state.values.body) { this.props.sessionStorage.setItem(this.contextKey, state.values.body); } else { this.props.sessionStorage.removeItem(this.contextKey); } + // Reset errors whenever user clears the form. + if (state.touched && state.touched.body && !state.values.body) { + form.reset({}); + } }; public render() { @@ -119,6 +134,16 @@ export class ReplyCommentFormContainer extends Component { parentUsername={ this.props.comment.author && this.props.comment.author.username } + min={ + (this.props.settings.charCount.enabled && + this.props.settings.charCount.min) || + null + } + max={ + (this.props.settings.charCount.enabled && + this.props.settings.charCount.max) || + null + } /> ); } @@ -128,25 +153,36 @@ const enhanced = withContext(({ sessionStorage, browserInfo }) => ({ // Disable autofocus on ios and enable for the rest. autofocus: !browserInfo.ios, }))( - withCreateCommentReplyMutation( - withFragmentContainer({ - story: graphql` - fragment ReplyCommentFormContainer_story on Story { - id - } - `, - comment: graphql` - fragment ReplyCommentFormContainer_comment on Comment { - id - author { - username + withRefreshSettingsFetch( + withCreateCommentReplyMutation( + withFragmentContainer({ + settings: graphql` + fragment ReplyCommentFormContainer_settings on Settings { + charCount { + enabled + min + max + } } - revision { + `, + story: graphql` + fragment ReplyCommentFormContainer_story on Story { id } - } - `, - })(ReplyCommentFormContainer) + `, + comment: graphql` + fragment ReplyCommentFormContainer_comment on Comment { + id + author { + username + } + revision { + id + } + } + `, + })(ReplyCommentFormContainer) + ) ) ); export type PostCommentFormContainerProps = PropTypesOf; diff --git a/src/core/client/stream/tabs/comments/containers/StreamContainer.tsx b/src/core/client/stream/tabs/comments/containers/StreamContainer.tsx index 5019e082c..68fb2ee24 100644 --- a/src/core/client/stream/tabs/comments/containers/StreamContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/StreamContainer.tsx @@ -135,6 +135,7 @@ const enhanced = withPaginationContainer< `, settings: graphql` fragment StreamContainer_settings on Settings { + ...PostCommentFormContainer_settings ...ReplyListContainer1_settings ...CommentContainer_settings ...UserBoxContainer_settings diff --git a/src/core/client/stream/tabs/comments/containers/__snapshots__/PostCommentFormContainer.spec.tsx.snap b/src/core/client/stream/tabs/comments/containers/__snapshots__/PostCommentFormContainer.spec.tsx.snap index de1ba17f5..1738daf62 100644 --- a/src/core/client/stream/tabs/comments/containers/__snapshots__/PostCommentFormContainer.spec.tsx.snap +++ b/src/core/client/stream/tabs/comments/containers/__snapshots__/PostCommentFormContainer.spec.tsx.snap @@ -2,6 +2,8 @@ exports[`renders correctly 1`] = ` @@ -14,6 +16,8 @@ exports[`renders with initialValues 1`] = ` "body": "Hello World!", } } + max={100} + min={3} onChange={[Function]} onSubmit={[Function]} /> diff --git a/src/core/client/stream/tabs/comments/containers/__snapshots__/ReplyCommentFormContainer.spec.tsx.snap b/src/core/client/stream/tabs/comments/containers/__snapshots__/ReplyCommentFormContainer.spec.tsx.snap index bd1ab75ff..8eb2d8ef0 100644 --- a/src/core/client/stream/tabs/comments/containers/__snapshots__/ReplyCommentFormContainer.spec.tsx.snap +++ b/src/core/client/stream/tabs/comments/containers/__snapshots__/ReplyCommentFormContainer.spec.tsx.snap @@ -3,6 +3,8 @@ exports[`renders correctly 1`] = ` ` or `
` or `
` which basically + * means renders nothing and return a standardized `""` instead. + * @param html the html to be cleaned up + */ +export default function cleanupRTEEmptyHTML(html: string) { + if (html.includes("blockquote")) { + return html; + } + const innerText = getHTMLText(html); + if ( + (innerText !== "\n" && innerText.includes("\n")) || + innerText.trim() !== "" + ) { + return html; + } + return ""; +} diff --git a/src/core/client/stream/tabs/comments/helpers/getCommentBodyValidators.ts b/src/core/client/stream/tabs/comments/helpers/getCommentBodyValidators.ts new file mode 100644 index 000000000..cce8edb4c --- /dev/null +++ b/src/core/client/stream/tabs/comments/helpers/getCommentBodyValidators.ts @@ -0,0 +1,27 @@ +import { + composeValidators, + required, + validateMaxLength, + validateMinLength, +} from "talk-framework/lib/validation"; + +import getHTMLCharacterLength from "./getHTMLCharacterLength"; + +/** + * getBodyValidators will return validators based on given min & max parameters. + * @param min minimum length or null + * @param max maximum length or null + */ +export default function getBodyValidators( + min: number | null, + max: number | null +) { + const validators = [required]; + if (min) { + validators.push(validateMinLength(min, getHTMLCharacterLength)); + } + if (max) { + validators.push(validateMaxLength(max, getHTMLCharacterLength)); + } + return composeValidators(...validators); +} diff --git a/src/core/client/stream/tabs/comments/helpers/getHTMLCharacterLength.ts b/src/core/client/stream/tabs/comments/helpers/getHTMLCharacterLength.ts new file mode 100644 index 000000000..5adbb28c6 --- /dev/null +++ b/src/core/client/stream/tabs/comments/helpers/getHTMLCharacterLength.ts @@ -0,0 +1,14 @@ +import getHTMLText from "./getHTMLText"; + +/** + * getHTMLCharacterLength will strip all tags and return remaining + * character length. + * @param html the html which length should be determined + */ +export default function getHTMLCharacterLength(html: string | undefined) { + if (!html) { + return 0; + } + const innerText = getHTMLText(html); + return innerText.trim().replace(/\n/g, "").length; +} diff --git a/src/core/client/stream/tabs/comments/helpers/getHTMLText.ts b/src/core/client/stream/tabs/comments/helpers/getHTMLText.ts new file mode 100644 index 000000000..1c0d22062 --- /dev/null +++ b/src/core/client/stream/tabs/comments/helpers/getHTMLText.ts @@ -0,0 +1,15 @@ +/** + * getHTMLText returns text representation of html. + * Includes a different implementation during test that works. + * @param html + */ +export default function getHTMLText(html: string) { + if (process.env.NODE_ENV === "test") { + // innerText is not implement in JSDOM, so we use `striptags` instead. + const striptags = require("striptags"); + return striptags(html.replace(/
/, "\n")); + } + const divElement = document.createElement("div"); + divElement.innerHTML = html; + return divElement.innerText; +} diff --git a/src/core/client/stream/tabs/comments/helpers/index.ts b/src/core/client/stream/tabs/comments/helpers/index.ts new file mode 100644 index 000000000..4d6c97756 --- /dev/null +++ b/src/core/client/stream/tabs/comments/helpers/index.ts @@ -0,0 +1,9 @@ +export { default as getHTMLCharacterLength } from "./getHTMLCharacterLength"; +export { + default as getCommentBodyValidators, +} from "./getCommentBodyValidators"; +export { default as cleanupRTEEmptyHTML } from "./cleanupRTEEmptyHTML"; +export { + default as shouldTriggerSettingsRefresh, +} from "./shouldTriggerSettingsRefresh"; +export { default as getHTMLText } from "./getHTMLText"; diff --git a/src/core/client/stream/tabs/comments/helpers/shouldTriggerSettingsRefresh.ts b/src/core/client/stream/tabs/comments/helpers/shouldTriggerSettingsRefresh.ts new file mode 100644 index 000000000..a7ea48c29 --- /dev/null +++ b/src/core/client/stream/tabs/comments/helpers/shouldTriggerSettingsRefresh.ts @@ -0,0 +1,17 @@ +import { ERROR_CODES } from "talk-common/errors"; + +const triggers = [ + ERROR_CODES.COMMENT_BODY_TOO_SHORT, + ERROR_CODES.COMMENT_BODY_EXCEEDS_MAX_LENGTH, +]; +/** + * shouldTriggerSettingsRefresh will indicate whether the settings + * needs to refresh based on a recently received error code. Some + * error codes signify that the settings on the client currently + * mismatches with the newest settings on the server, and thus + * e.g. validations fail. + * @param code the error code to check for + */ +export default function shouldTriggerSettingsRefresh(code: ERROR_CODES) { + return triggers.includes(code); +} diff --git a/src/core/client/stream/test/comments/__snapshots__/editComment.spec.tsx.snap b/src/core/client/stream/test/comments/__snapshots__/editComment.spec.tsx.snap index b048b901f..f489523ee 100644 --- a/src/core/client/stream/test/comments/__snapshots__/editComment.spec.tsx.snap +++ b/src/core/client/stream/test/comments/__snapshots__/editComment.spec.tsx.snap @@ -290,27 +290,27 @@ exports[`edit a comment and handle server error: edit form 1`] = `
-
-
- - - Edit: - - remaining - + alarm + + + Edit: + + remaining + +
-
-
- - - Edit: - - remaining - + alarm + + + Edit: + + remaining + +
-
-
- - - Edit: - - remaining - + alarm + + + Edit: + + remaining + +
- -
- - Edit time has expired. You can no longer edit this comment. Why not post another one? + + Edit time has expired. You can no longer edit this comment. Why not post another one? +
- ⁨2⁩ Comments + 2 Comments diff --git a/src/core/client/stream/test/comments/__snapshots__/reportComment.spec.tsx.snap b/src/core/client/stream/test/comments/__snapshots__/reportComment.spec.tsx.snap index b1d35e8d8..0a456c002 100644 --- a/src/core/client/stream/test/comments/__snapshots__/reportComment.spec.tsx.snap +++ b/src/core/client/stream/test/comments/__snapshots__/reportComment.spec.tsx.snap @@ -380,7 +380,7 @@ exports[`render popup expanded 1`] = `

- Max. ⁨500⁩ Characters + Max. 500 Characters

diff --git a/src/core/client/stream/test/comments/charCountEditComment.spec.tsx b/src/core/client/stream/test/comments/charCountEditComment.spec.tsx new file mode 100644 index 000000000..399af648d --- /dev/null +++ b/src/core/client/stream/test/comments/charCountEditComment.spec.tsx @@ -0,0 +1,165 @@ +import RTE from "@coralproject/rte"; +import sinon from "sinon"; +import timekeeper from "timekeeper"; + +import { ERROR_CODES } from "talk-common/errors"; +import { InvalidRequestError } from "talk-framework/lib/errors"; +import { + createSinonStub, + findParentWithType, + waitForElement, + within, +} from "talk-framework/testHelpers"; + +import { settings, stories, users } from "../fixtures"; +import create from "./create"; + +beforeAll(() => { + timekeeper.freeze(stories[0].comments.edges[0].node.createdAt); +}); +afterAll(() => { + timekeeper.reset(); +}); + +const settingsWithCharCount = { + ...settings, + charCount: { + enabled: true, + min: 3, + max: 10, + }, +}; + +async function createTestRenderer( + resolver: any = {}, + options: { muteNetworkErrors?: boolean } = {} +) { + const resolvers = { + ...resolver, + Query: { + settings: sinon.stub().returns(settingsWithCharCount), + me: sinon.stub().returns(users[0]), + story: sinon.stub().returns(stories[0]), + ...resolver.Query, + }, + }; + + const { testRenderer, context } = create({ + // Set this to true, to see graphql responses. + logNetwork: false, + muteNetworkErrors: options.muteNetworkErrors, + resolvers, + initLocalState: localRecord => { + localRecord.setValue(stories[0].id, "storyID"); + localRecord.setValue(true, "loggedIn"); + }, + }); + + const comment = await waitForElement(() => + within(testRenderer.root).getByTestID("comment-comment-0") + ); + + // Open edit form. + within(comment) + .getByText("Edit") + .props.onClick(); + + const rte = await waitForElement( + () => + findParentWithType( + within(comment).getByLabelText("Edit comment"), + // We'll use the RTE component here as an exception because the + // jsdom does not support all of what is needed for rendering the + // Rich Text Editor. + RTE + )! + ); + + const form = findParentWithType(rte, "form")!; + return { + testRenderer, + context, + comment, + rte, + form, + }; +} + +it("validate min", async () => { + const { rte, form } = await createTestRenderer(); + + const text = "Please enter at least 3 characters."; + + rte.props.onChange({ html: "ab" }); + form.props.onSubmit(); + within(form).getByText(text); +}); + +it("validate max", async () => { + const { rte, form } = await createTestRenderer(); + + const text = "Please enter at max 10 characters."; + + rte.props.onChange({ html: "abcdefghijklmnopqrst" }); + form.props.onSubmit(); + within(form).getByText(text); +}); + +it("show remaining characters", async () => { + const { rte, form } = await createTestRenderer(); + + rte.props.onChange({ html: "abc" }); + within(form).getByText("7 characters remaining"); + rte.props.onChange({ html: "abcdefghijkl" }); + within(form).getByText("-2 characters remaining"); +}); + +it("update from server upon specific char count error", async () => { + for (const errorCode of [ + ERROR_CODES.COMMENT_BODY_EXCEEDS_MAX_LENGTH, + ERROR_CODES.COMMENT_BODY_TOO_SHORT, + ]) { + const { rte, form } = await createTestRenderer( + { + Mutation: { + editComment: sinon.stub().callsFake(() => { + throw new InvalidRequestError({ + code: errorCode, + param: "input.body", + }); + }), + }, + Query: { + settings: createSinonStub( + s => s.onFirstCall().returns(settingsWithCharCount), + s => + s.onSecondCall().returns({ + ...settingsWithCharCount, + charCount: { + enabled: true, + min: 3, + max: 5, + }, + }) + ), + }, + }, + { muteNetworkErrors: true } + ); + + rte.props.onChange({ html: "abc" }); + within(form).getByText("7 characters remaining"); + rte.props.onChange({ html: "abcdefgh" }); + within(form).getByText("2 characters remaining"); + form.props.onSubmit(); + await waitForElement(() => + within(form).getByText("-3 characters remaining") + ); + // Body submit error should be displayed. + within(form).getByText(errorCode); + rte.props.onChange({ html: "abcde" }); + + // Body submit error should disappear when form gets dirty. + expect(within(form).queryByText(errorCode)).toBeNull(); + } +}); diff --git a/src/core/client/stream/test/comments/charCountPostComment.spec.tsx b/src/core/client/stream/test/comments/charCountPostComment.spec.tsx new file mode 100644 index 000000000..43fc7a83d --- /dev/null +++ b/src/core/client/stream/test/comments/charCountPostComment.spec.tsx @@ -0,0 +1,160 @@ +import sinon from "sinon"; + +import RTE from "@coralproject/rte"; +import { ERROR_CODES } from "talk-common/errors"; +import { InvalidRequestError } from "talk-framework/lib/errors"; +import { + createSinonStub, + findParentWithType, + waitForElement, + within, +} from "talk-framework/testHelpers"; + +import { settings, stories, users } from "../fixtures"; +import create from "./create"; + +const settingsWithCharCount = { + ...settings, + charCount: { + enabled: true, + min: 3, + max: 10, + }, +}; + +async function createTestRenderer( + resolver: any = {}, + options: { muteNetworkErrors?: boolean } = {} +) { + const resolvers = { + ...resolver, + Query: { + settings: sinon.stub().returns(settingsWithCharCount), + me: sinon.stub().returns(users[0]), + story: sinon.stub().returns(stories[0]), + ...resolver.Query, + }, + }; + + const { testRenderer, context } = create({ + // Set this to true, to see graphql responses. + logNetwork: false, + muteNetworkErrors: options.muteNetworkErrors, + resolvers, + initLocalState: localRecord => { + localRecord.setValue(stories[0].id, "storyID"); + localRecord.setValue(true, "loggedIn"); + }, + }); + + const rte = await waitForElement( + () => + findParentWithType( + within(testRenderer.root).getByLabelText("Post a comment"), + // We'll use the RTE component here as an exception because the + // jsdom does not support all of what is needed for rendering the + // Rich Text Editor. + RTE + )! + ); + const form = findParentWithType(rte, "form")!; + return { + testRenderer, + context, + rte, + form, + }; +} + +it("validate min", async () => { + const { rte, form } = await createTestRenderer(); + + const text = "Please enter at least 3 characters."; + + rte.props.onChange({ html: "ab" }); + form.props.onSubmit(); + within(form).getByText(text); + + // Reset validation when erasing all content. + rte.props.onChange({ html: "" }); + expect(within(form).queryByText(text)).toBeNull(); + + rte.props.onChange({ html: "ab" }); + expect(within(form).queryByText(text)).toBeNull(); +}); + +it("validate max", async () => { + const { rte, form } = await createTestRenderer(); + + const text = "Please enter at max 10 characters."; + + rte.props.onChange({ html: "abcdefghijklmnopqrst" }); + form.props.onSubmit(); + within(form).getByText(text); + + // Reset validation when erasing all content. + rte.props.onChange({ html: "" }); + expect(within(form).queryByText(text)).toBeNull(); + + rte.props.onChange({ html: "abcdefghijklmnopqrst" }); + expect(within(form).queryByText(text)).toBeNull(); +}); + +it("show remaining characters", async () => { + const { rte, form } = await createTestRenderer(); + + rte.props.onChange({ html: "abc" }); + within(form).getByText("7 characters remaining"); + rte.props.onChange({ html: "abcdefghijkl" }); + within(form).getByText("-2 characters remaining"); +}); + +it("update from server upon specific char count error", async () => { + for (const errorCode of [ + ERROR_CODES.COMMENT_BODY_EXCEEDS_MAX_LENGTH, + ERROR_CODES.COMMENT_BODY_TOO_SHORT, + ]) { + const { rte, form } = await createTestRenderer( + { + Mutation: { + createComment: sinon.stub().callsFake(() => { + throw new InvalidRequestError({ + code: errorCode, + param: "input.body", + }); + }), + }, + Query: { + settings: createSinonStub( + s => s.onFirstCall().returns(settingsWithCharCount), + s => + s.onSecondCall().returns({ + ...settingsWithCharCount, + charCount: { + enabled: true, + min: 3, + max: 5, + }, + }) + ), + }, + }, + { muteNetworkErrors: true } + ); + + rte.props.onChange({ html: "abc" }); + within(form).getByText("7 characters remaining"); + rte.props.onChange({ html: "abcdefgh" }); + within(form).getByText("2 characters remaining"); + form.props.onSubmit(); + await waitForElement(() => + within(form).getByText("-3 characters remaining") + ); + // Body submit error should be displayed. + within(form).getByText(errorCode); + rte.props.onChange({ html: "abcde" }); + + // Body submit error should disappear when form gets dirty. + expect(within(form).queryByText(errorCode)).toBeNull(); + } +}); diff --git a/src/core/client/stream/test/comments/charCountReplyComment.spec.tsx b/src/core/client/stream/test/comments/charCountReplyComment.spec.tsx new file mode 100644 index 000000000..2176b1b34 --- /dev/null +++ b/src/core/client/stream/test/comments/charCountReplyComment.spec.tsx @@ -0,0 +1,171 @@ +import sinon from "sinon"; + +import RTE from "@coralproject/rte"; +import { ERROR_CODES } from "talk-common/errors"; +import { InvalidRequestError } from "talk-framework/lib/errors"; +import { + createSinonStub, + findParentWithType, + waitForElement, + within, +} from "talk-framework/testHelpers"; + +import { settings, stories, users } from "../fixtures"; +import create from "./create"; + +const settingsWithCharCount = { + ...settings, + charCount: { + enabled: true, + min: 3, + max: 10, + }, +}; + +async function createTestRenderer( + resolver: any = {}, + options: { muteNetworkErrors?: boolean } = {} +) { + const resolvers = { + ...resolver, + Query: { + settings: sinon.stub().returns(settingsWithCharCount), + me: sinon.stub().returns(users[0]), + story: sinon.stub().returns(stories[0]), + ...resolver.Query, + }, + }; + + const { testRenderer, context } = create({ + // Set this to true, to see graphql responses. + logNetwork: false, + muteNetworkErrors: options.muteNetworkErrors, + resolvers, + initLocalState: localRecord => { + localRecord.setValue(stories[0].id, "storyID"); + localRecord.setValue(true, "loggedIn"); + }, + }); + + const comment = await waitForElement(() => + within(testRenderer.root).getByTestID("comment-comment-0") + ); + + // Open reply form. + within(comment) + .getByText("Reply", { selector: "button" }) + .props.onClick(); + + const rte = await waitForElement( + () => + findParentWithType( + within(comment).getByLabelText("Write a reply"), + // We'll use the RTE component here as an exception because the + // jsdom does not support all of what is needed for rendering the + // Rich Text Editor. + RTE + )! + ); + + const form = findParentWithType(rte, "form")!; + return { + testRenderer, + context, + comment, + rte, + form, + }; +} + +it("validate min", async () => { + const { rte, form } = await createTestRenderer(); + + const text = "Please enter at least 3 characters."; + + rte.props.onChange({ html: "ab" }); + form.props.onSubmit(); + within(form).getByText(text); + + // Reset validation when erasing all content. + rte.props.onChange({ html: "" }); + expect(within(form).queryByText(text)).toBeNull(); + + rte.props.onChange({ html: "ab" }); + expect(within(form).queryByText(text)).toBeNull(); +}); + +it("validate max", async () => { + const { rte, form } = await createTestRenderer(); + + const text = "Please enter at max 10 characters."; + + rte.props.onChange({ html: "abcdefghijklmnopqrst" }); + form.props.onSubmit(); + within(form).getByText(text); + + // Reset validation when erasing all content. + rte.props.onChange({ html: "" }); + expect(within(form).queryByText(text)).toBeNull(); + + rte.props.onChange({ html: "abcdefghijklmnopqrst" }); + expect(within(form).queryByText(text)).toBeNull(); +}); + +it("show remaining characters", async () => { + const { rte, form } = await createTestRenderer(); + + rte.props.onChange({ html: "abc" }); + within(form).getByText("7 characters remaining"); + rte.props.onChange({ html: "abcdefghijkl" }); + within(form).getByText("-2 characters remaining"); +}); + +it("update from server upon specific char count error", async () => { + for (const errorCode of [ + ERROR_CODES.COMMENT_BODY_EXCEEDS_MAX_LENGTH, + ERROR_CODES.COMMENT_BODY_TOO_SHORT, + ]) { + const { rte, form } = await createTestRenderer( + { + Mutation: { + createCommentReply: sinon.stub().callsFake(() => { + throw new InvalidRequestError({ + code: errorCode, + param: "input.body", + }); + }), + }, + Query: { + settings: createSinonStub( + s => s.onFirstCall().returns(settingsWithCharCount), + s => + s.onSecondCall().returns({ + ...settingsWithCharCount, + charCount: { + enabled: true, + min: 3, + max: 5, + }, + }) + ), + }, + }, + { muteNetworkErrors: true } + ); + + rte.props.onChange({ html: "abc" }); + within(form).getByText("7 characters remaining"); + rte.props.onChange({ html: "abcdefgh" }); + within(form).getByText("2 characters remaining"); + form.props.onSubmit(); + await waitForElement(() => + within(form).getByText("-3 characters remaining") + ); + // Body submit error should be displayed. + within(form).getByText(errorCode); + rte.props.onChange({ html: "abcde" }); + + // Body submit error should disappear when form gets dirty. + expect(within(form).queryByText(errorCode)).toBeNull(); + } +}); diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index 3f7f93312..e1ab883da 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -55,6 +55,9 @@ export const settings = { label: "Respect", labelActive: "Respected", }, + charCount: { + enabled: false, + }, }; export const users = [ diff --git a/src/core/client/stream/test/profile/__snapshots__/loadMore.spec.tsx.snap b/src/core/client/stream/test/profile/__snapshots__/loadMore.spec.tsx.snap index dfc05dec9..995892c5f 100644 --- a/src/core/client/stream/test/profile/__snapshots__/loadMore.spec.tsx.snap +++ b/src/core/client/stream/test/profile/__snapshots__/loadMore.spec.tsx.snap @@ -17,7 +17,7 @@ exports[`renders profile 1`] = `

- Story: ⁨title⁩ + Story: title