[CORL-282] Handle server errors in client (#2196)

* feat: Handle server errors in client

* refactor: use enum ERROR_TYPES

* chore: better comment

* fix: lint

* fix: also look in queries for custom errors
This commit is contained in:
Kiwi
2019-03-01 21:37:02 +01:00
committed by GitHub
parent 7ad724e576
commit 60f5b7e3c0
32 changed files with 1256 additions and 473 deletions
@@ -8,7 +8,7 @@ import {
withUpdateSettingsMutation,
} from "talk-admin/mutations";
import { TalkContext, withContext } from "talk-framework/lib/bootstrap";
import { BadUserInputError } from "talk-framework/lib/errors";
import { InvalidRequestError } from "talk-framework/lib/errors";
import { getMessage } from "talk-framework/lib/i18n";
import Configure from "../components/Configure";
@@ -86,8 +86,8 @@ class ConfigureContainer extends React.Component<Props> {
}
form.initialize(data);
} catch (error) {
if (error instanceof BadUserInputError) {
return error.invalidArgsLocalized;
if (error instanceof InvalidRequestError) {
return error.invalidArgs;
}
// tslint:disable-next-line:no-console
console.error(error);
@@ -2,6 +2,8 @@ import mockConsole from "jest-mock-console";
import { cloneDeep, get, merge } from "lodash";
import sinon from "sinon";
import { ERROR_CODES } from "talk-common/errors";
import { InvalidRequestError } from "talk-framework/lib/errors";
import {
createSinonStub,
replaceHistoryLocation,
@@ -27,7 +29,10 @@ afterEach(() => {
expect(console.error).not.toHaveBeenCalled();
});
const createTestRenderer = async (resolver: any = {}) => {
const createTestRenderer = async (
resolver: any = {},
options: { muteNetworkErrors?: boolean } = {}
) => {
const resolvers = {
...resolver,
Query: {
@@ -41,6 +46,7 @@ const createTestRenderer = async (resolver: any = {}) => {
const { testRenderer } = create({
// Set this to true, to see graphql responses.
logNetwork: false,
muteNetworkErrors: options.muteNetworkErrors,
resolvers,
initLocalState: localRecord => {
localRecord.setValue(true, "loggedIn");
@@ -465,3 +471,36 @@ it("change closing comment streams", async () => {
});
expect(updateSettingsStub.called).toBe(true);
});
it("handle server error", async () => {
const updateSettingsStub = createSinonStub(s =>
s.onFirstCall().callsFake((_: any, data: any) => {
throw new InvalidRequestError({ code: ERROR_CODES.INTERNAL_ERROR });
})
);
const { configureContainer, generalContainer } = await createTestRenderer(
{
Mutation: {
updateSettings: updateSettingsStub,
},
},
{ muteNetworkErrors: true }
);
const contentField = within(generalContainer).getByLabelText(
"Closed Stream Message"
);
// Let's change the content.
contentField.props.onChange("The stream has been closed");
// Send form
within(configureContainer)
.getByType("form")
.props.onSubmit();
// Look for internal error being displayed.
await waitForElement(() =>
within(configureContainer).getByText("INTERNAL_ERROR")
);
});
@@ -1,78 +0,0 @@
import { mapValues, once } from "lodash";
import { ReactNode } from "react";
import { VALIDATION_REQUIRED } from "../messages";
/**
* ValidationError represents all possible string values
* that is responded by the server.
*/
type ValidationError = "REQUIRED";
/**
* InvalidArgsMap as responded by the server.
*/
interface InvalidArgsMap {
[key: string]: ValidationError;
}
/**
* The localized version of `InvalidArgsMap`.
*/
interface InvalidArgsMapLocalilzed {
[key: string]: ReactNode;
}
/**
* Shape of the `BadUserInput` extension.
*/
interface BadUserInputExtension {
code: "BAD_USER_INPUT";
exception: {
invalidArgs: InvalidArgsMap;
};
}
/**
* Map server `ValidationError` to a translation message.
*/
const validationMap = {
REQUIRED: VALIDATION_REQUIRED,
};
/**
* BadUserInputError wraps the `BAD_USER_INPUT` error returned from the
* server.
*/
export default class BadUserInputError extends Error {
// Keep origin of original server response.
public readonly origin: BadUserInputExtension;
constructor(error: BadUserInputExtension) {
super("BadUserInputError");
// Maintains proper stack trace for where our error was thrown.
if (Error.captureStackTrace) {
Error.captureStackTrace(this, BadUserInputError);
}
this.origin = error;
}
get invalidArgs(): InvalidArgsMap {
return this.origin.exception.invalidArgs;
}
get invalidArgsLocalized(): InvalidArgsMapLocalilzed {
return this.computeInvalidArgsLocalized();
}
// Perform localization and memoize result.
private computeInvalidArgsLocalized = once(() => {
return mapValues(this.invalidArgs, v => {
if (v in validationMap) {
return validationMap[v]();
}
return v;
});
});
}
@@ -1,2 +1,2 @@
export { default as UnknownServerError } from "./unknownServerError";
export { default as BadUserInputError } from "./badUserInputError";
export { default as InvalidRequestError } from "./invalidRequestError";
@@ -0,0 +1,54 @@
import { FORM_ERROR } from "final-form";
import { ERROR_CODES } from "talk-common/errors";
/**
* Shape of the `InvalidRequest` extension as
* the client requires. Note: the only crucial
* field is the `code` field.
*/
interface InvalidRequestExtension {
code: ERROR_CODES;
message?: string;
id?: string;
param?: string;
}
/**
* InvalidRequestError wraps the `BAD_USER_INPUT` error returned from the
* server.
*/
export default class InvalidRequestError extends Error
implements InvalidRequestExtension {
// Keep extension of original server response.
public readonly extension: InvalidRequestExtension;
public readonly code: ERROR_CODES;
public readonly id?: string;
public readonly param?: string;
public readonly message: string;
public readonly extensions: string;
constructor(extension: InvalidRequestExtension) {
super("InvalidRequestError");
// Maintains proper stack trace for where our error was thrown.
if (Error.captureStackTrace) {
Error.captureStackTrace(this, InvalidRequestError);
}
this.extension = extension;
this.code = extension.code;
this.id = extension.id;
this.param = extension.param;
this.message = extension.message || extension.code;
}
get invalidArgs() {
if (this.param) {
return {
[this.param.substr("input.".length)]: this.message,
};
}
return {
[FORM_ERROR]: this.message,
};
}
}
@@ -1,26 +1,12 @@
import { Middleware } from "react-relay-network-modern/es";
import { BadUserInputError, UnknownServerError } from "../errors";
function getError(errors: Error[]): Error | null {
if (errors.length > 1 || !(errors[0] as any).extensions) {
// Multiple errors are GraphQL errors.
// TODO: (cvle) Is this assumption correct?
// No extensions == GraphQL error.
// TODO: (cvle) harmonize with server.
return null;
}
const err = errors[0];
if ((err as any).code === "BAD_USER_INPUT") {
return new BadUserInputError((err as any).extensions);
}
return new UnknownServerError(err.message, (err as any).extensions);
}
import extractError from "./extractError";
const customErrorMiddleware: Middleware = next => async req => {
const res = await next(req);
if (req.isMutation() && res.errors) {
if (res.errors) {
// Extract custom error.
const error = getError(res.errors);
const error = extractError(res.errors);
if (error) {
throw error;
}
@@ -0,0 +1,19 @@
import { ERROR_TYPES } from "talk-common/errors";
import { InvalidRequestError, UnknownServerError } from "../errors";
export default function extractError(errors: Error[]): Error | null {
if (errors.length > 1 || !(errors[0] as any).extensions) {
// Multiple errors are GraphQL errors.
// TODO: (cvle) Is this assumption correct?
// No extensions == GraphQL error.
// TODO: (cvle) harmonize with server.
return null;
}
// Handle custom errors here.
const err = errors[0];
if ((err as any).extensions.type === ERROR_TYPES.INVALID_REQUEST_ERROR) {
return new InvalidRequestError((err as any).extensions);
}
return new UnknownServerError(err.message, (err as any).extensions);
}
@@ -1 +1,2 @@
export { default as createNetwork, TokenGetter } from "./createNetwork";
export { default as extractError } from "./extractError";
@@ -1,5 +1,6 @@
import { graphql, GraphQLSchema } from "graphql";
import { IResolvers } from "graphql-tools";
import { createFetch } from "relay-local-schema";
import {
commitLocalUpdate,
Environment,
@@ -18,6 +19,7 @@ import {
} from "talk-framework/lib/relay";
import { loadSchema } from "talk-common/graphql";
import { InvalidRequestError } from "talk-framework/lib/errors";
export interface CreateRelayEnvironmentNetworkParams {
/** project name of graphql-config */
@@ -50,6 +52,37 @@ export interface CreateRelayEnvironmentParams {
source?: RecordSource;
}
function createFetch({
schema,
rootValue,
contextValue,
}: {
schema: GraphQLSchema;
rootValue?: any;
contextValue?: any;
}) {
return function fetchQuery(operation: any, variables: Record<string, any>) {
return graphql(
schema,
operation.text,
rootValue,
contextValue,
variables
).then(payload => {
if (payload.errors) {
payload.errors.forEach(e => {
// Throw our custom errors directly.
if (e.originalError instanceof InvalidRequestError) {
throw e.originalError;
}
});
throw new Error(payload.errors.toString());
}
return payload;
});
};
}
/**
* create Relay environment for tests environments.
*/
@@ -20,7 +20,6 @@ import {
Message,
MessageIcon,
RelativeTime,
Typography,
ValidationMessage,
} from "talk-ui/components";
@@ -51,7 +50,13 @@ const EditCommentForm: StatelessComponent<EditCommentFormProps> = props => {
const inputID = `comments-editCommentForm-rte-${props.id}`;
return (
<Form onSubmit={props.onSubmit} initialValues={props.initialValues}>
{({ handleSubmit, submitting, hasValidationErrors, pristine }) => (
{({
handleSubmit,
submitting,
hasValidationErrors,
pristine,
submitError,
}) => (
<form
className={props.className}
autoComplete="off"
@@ -69,7 +74,7 @@ const EditCommentForm: StatelessComponent<EditCommentFormProps> = props => {
</div>
<Field name="body" validate={required}>
{({ input, meta }) => (
<div>
<HorizontalGutter size="half">
<Localized id="comments-editCommentForm-rteLabel">
<AriaInfo component="label" htmlFor={inputID}>
Edit comment
@@ -90,11 +95,16 @@ const EditCommentForm: StatelessComponent<EditCommentFormProps> = props => {
</Localized>
{meta.touched &&
(meta.error || meta.submitError) && (
<Typography align="right" color="error" gutterBottom>
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</Typography>
</ValidationMessage>
)}
</div>
{submitError && (
<ValidationMessage fullWidth>
{submitError}
</ValidationMessage>
)}
</HorizontalGutter>
)}
</Field>
{props.expired ? (
@@ -5,17 +5,12 @@ import { Field, Form, FormSpy } from "react-final-form";
import { OnSubmit } from "talk-framework/lib/form";
import { required } from "talk-framework/lib/validation";
import {
AriaInfo,
Button,
Flex,
HorizontalGutter,
Typography,
} from "talk-ui/components";
import { AriaInfo, Button, Flex, HorizontalGutter } from "talk-ui/components";
import PoweredBy from "./PoweredBy";
import RTE from "./RTE";
import ValidationMessage from "talk-admin/routes/configure/components/ValidationMessage";
import styles from "./PostCommentForm.css";
interface FormProps {
@@ -30,7 +25,7 @@ export interface PostCommentFormProps {
const PostCommentForm: StatelessComponent<PostCommentFormProps> = props => (
<Form onSubmit={props.onSubmit} initialValues={props.initialValues}>
{({ handleSubmit, submitting, hasValidationErrors }) => (
{({ handleSubmit, submitting, hasValidationErrors, submitError }) => (
<form
autoComplete="off"
onSubmit={handleSubmit}
@@ -41,7 +36,7 @@ const PostCommentForm: StatelessComponent<PostCommentFormProps> = props => (
<HorizontalGutter>
<Field name="body" validate={required}>
{({ input, meta }) => (
<div>
<HorizontalGutter size="half">
<Localized id="comments-postCommentForm-rteLabel">
<AriaInfo
component="label"
@@ -64,11 +59,14 @@ const PostCommentForm: StatelessComponent<PostCommentFormProps> = props => (
</Localized>
{meta.touched &&
(meta.error || meta.submitError) && (
<Typography align="right" color="error" gutterBottom>
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</Typography>
</ValidationMessage>
)}
</div>
{submitError && (
<ValidationMessage fullWidth>{submitError}</ValidationMessage>
)}
</HorizontalGutter>
)}
</Field>
<Flex
@@ -17,7 +17,7 @@ import {
Flex,
HorizontalGutter,
MatchMedia,
Typography,
ValidationMessage,
} from "talk-ui/components";
import ReplyTo from "./ReplyTo";
@@ -42,7 +42,7 @@ const ReplyCommentForm: StatelessComponent<ReplyCommentFormProps> = props => {
const inputID = `comments-replyCommentForm-rte-${props.id}`;
return (
<Form onSubmit={props.onSubmit} initialValues={props.initialValues}>
{({ handleSubmit, submitting, hasValidationErrors }) => (
{({ handleSubmit, submitting, hasValidationErrors, submitError }) => (
<form
className={props.className}
autoComplete="off"
@@ -53,35 +53,42 @@ const ReplyCommentForm: StatelessComponent<ReplyCommentFormProps> = props => {
<HorizontalGutter>
<Field name="body" validate={required}>
{({ input, meta }) => (
<div>
<Localized id="comments-replyCommentForm-rteLabel">
<AriaInfo component="label" htmlFor={inputID}>
Write a reply
</AriaInfo>
</Localized>
{props.parentUsername && (
<ReplyTo username={props.parentUsername} />
)}
<Localized
id="comments-replyCommentForm-rte"
attrs={{ placeholder: true }}
>
<RTE
inputId={inputID}
onChange={({ html }) => input.onChange(html)}
value={input.value}
placeholder="Write a reply"
forwardRef={props.rteRef}
disabled={submitting}
/>
</Localized>
<HorizontalGutter size="half">
<div>
<Localized id="comments-replyCommentForm-rteLabel">
<AriaInfo component="label" htmlFor={inputID}>
Write a reply
</AriaInfo>
</Localized>
{props.parentUsername && (
<ReplyTo username={props.parentUsername} />
)}
<Localized
id="comments-replyCommentForm-rte"
attrs={{ placeholder: true }}
>
<RTE
inputId={inputID}
onChange={({ html }) => input.onChange(html)}
value={input.value}
placeholder="Write a reply"
forwardRef={props.rteRef}
disabled={submitting}
/>
</Localized>
</div>
{meta.touched &&
(meta.error || meta.submitError) && (
<Typography align="right" color="error" gutterBottom>
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</Typography>
</ValidationMessage>
)}
</div>
{submitError && (
<ValidationMessage fullWidth>
{submitError}
</ValidationMessage>
)}
</HorizontalGutter>
)}
</Field>
<MatchMedia ltWidth="sm">
@@ -4,7 +4,7 @@ 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 { InvalidRequestError } from "talk-framework/lib/errors";
import { withFragmentContainer } from "talk-framework/lib/relay";
import { PropTypesOf } from "talk-framework/types";
@@ -82,8 +82,8 @@ export class EditCommentFormContainer extends Component<Props, State> {
this.props.onClose();
}
} catch (error) {
if (error instanceof BadUserInputError) {
return error.invalidArgsLocalized;
if (error instanceof InvalidRequestError) {
return error.invalidArgs;
}
// tslint:disable-next-line:no-console
console.error(error);
@@ -1,7 +1,7 @@
import React, { Component } from "react";
import { withContext } from "talk-framework/lib/bootstrap";
import { BadUserInputError } from "talk-framework/lib/errors";
import { InvalidRequestError } from "talk-framework/lib/errors";
import { PromisifiedStorage } from "talk-framework/lib/storage";
import { PropTypesOf } from "talk-framework/types";
@@ -59,8 +59,8 @@ export class PostCommentFormContainer extends Component<Props, State> {
});
form.reset({});
} catch (error) {
if (error instanceof BadUserInputError) {
return error.invalidArgsLocalized;
if (error instanceof InvalidRequestError) {
return error.invalidArgs;
}
// tslint:disable-next-line:no-console
console.error(error);
@@ -3,7 +3,7 @@ import React, { Component } from "react";
import { graphql } from "react-relay";
import { withContext } from "talk-framework/lib/bootstrap";
import { BadUserInputError } from "talk-framework/lib/errors";
import { InvalidRequestError } from "talk-framework/lib/errors";
import { withFragmentContainer } from "talk-framework/lib/relay";
import { PromisifiedStorage } from "talk-framework/lib/storage";
import { PropTypesOf } from "talk-framework/types";
@@ -87,8 +87,8 @@ export class ReplyCommentFormContainer extends Component<Props, State> {
this.props.onClose();
}
} catch (error) {
if (error instanceof BadUserInputError) {
return error.invalidArgsLocalized;
if (error instanceof InvalidRequestError) {
return error.invalidArgs;
}
// tslint:disable-next-line:no-console
console.error(error);
@@ -12,6 +12,7 @@ import {
HorizontalGutter,
RadioButton,
Typography,
ValidationMessage,
} from "talk-ui/components";
import PropagateMount from "./PropagateMount";
@@ -62,7 +63,13 @@ class ReportCommentForm extends React.Component<Props> {
const { onCancel, onSubmit, onResize, id } = this.props;
return (
<Form onSubmit={onSubmit}>
{({ handleSubmit, submitting, hasValidationErrors, form }) => (
{({
handleSubmit,
submitting,
hasValidationErrors,
form,
submitError,
}) => (
<form
autoComplete="off"
onSubmit={handleSubmit}
@@ -188,6 +195,9 @@ class ReportCommentForm extends React.Component<Props> {
</div>
</>
)}
{submitError && (
<ValidationMessage fullWidth>{submitError}</ValidationMessage>
)}
</HorizontalGutter>
{get(form.getFieldState("reason"), "value") && (
<Flex alignItems="center" justifyContent="flex-end">
@@ -1,7 +1,7 @@
import React, { Component } from "react";
import { graphql } from "react-relay";
import { BadUserInputError } from "talk-framework/lib/errors";
import { InvalidRequestError } from "talk-framework/lib/errors";
import { withFragmentContainer } from "talk-framework/lib/relay";
import { PropTypesOf } from "talk-framework/types";
import { ReportCommentFormContainer_comment as CommentData } from "talk-stream/__generated__/ReportCommentFormContainer_comment.graphql";
@@ -52,8 +52,8 @@ export class ReportCommentFormContainer extends Component<Props, State> {
}
this.setState({ done: true });
} catch (error) {
if (error instanceof BadUserInputError) {
return error.invalidArgsLocalized;
if (error instanceof InvalidRequestError) {
return error.invalidArgs;
}
// tslint:disable-next-line:no-console
console.error(error);
@@ -180,6 +180,172 @@ exports[`cancel edit 1`] = `
</div>
`;
exports[`edit a comment and handle server error: edit form 1`] = `
<div
data-testid="comment-comment-0"
>
<form
autoComplete="off"
onSubmit={[Function]}
>
<div
className="HorizontalGutter-root HorizontalGutter-full"
>
<div>
<div
className="Flex-root Flex-flex Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
>
<span
className="Typography-root Typography-heading3 Typography-colorTextPrimary Username-root"
>
Markus
</span>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
<div
className="HorizontalGutter-root HorizontalGutter-half"
>
<label
className="AriaInfo-root"
htmlFor="comments-editCommentForm-rte-comment-0"
>
Edit comment
</label>
<div>
<div
className=""
>
<div
aria-placeholder="Edit comment"
className="RTE-contentEditable RTE-content"
contentEditable={true}
dangerouslySetInnerHTML={
Object {
"__html": "Joining Too",
}
}
disabled={false}
id="comments-editCommentForm-rte-comment-0"
onBlur={[Function]}
onChange={[Function]}
onCut={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
onPaste={[Function]}
onSelect={[Function]}
/>
<div
className="RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
>
<button
className="Button-button"
disabled={false}
onClick={[Function]}
title="Bold"
type="button"
>
<span
aria-hidden="true"
className="Icon-root Icon-md"
>
format_bold
</span>
</button>
<button
className="Button-button"
disabled={false}
onClick={[Function]}
title="Italic"
type="button"
>
<span
aria-hidden="true"
className="Icon-root Icon-md"
>
format_italic
</span>
</button>
<button
className="Button-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
type="button"
>
<span
aria-hidden="true"
className="Icon-root Icon-md"
>
format_quote
</span>
</button>
</div>
</div>
</div>
</div>
<div
className="Message-root Message-colorGrey Message-fullWidth"
>
<span
aria-hidden="true"
className="Icon-root MessageIcon-root Icon-sm"
>
alarm
</span>
<span>
Edit:
<time
className="RelativeTime-root"
dateTime="2018-07-06T18:24:30.000Z"
title="2018-07-06T18:24:30.000Z"
>
2018-07-06T18:24:30.000Z
</time>
remaining
</span>
</div>
<div
className="Flex-root Flex-flex Flex-halfItemGutter Flex-justifyFlexEnd Flex-directionRow"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantOutlined"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Cancel
</button>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-disabled"
disabled={true}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Save Changes
</button>
</div>
</div>
</form>
</div>
`;
exports[`edit a comment: edit form 1`] = `
<div
data-testid="comment-comment-0"
@@ -209,7 +375,9 @@ exports[`edit a comment: edit form 1`] = `
</time>
</div>
</div>
<div>
<div
className="HorizontalGutter-root HorizontalGutter-half"
>
<label
className="AriaInfo-root"
htmlFor="comments-editCommentForm-rte-comment-0"
@@ -373,7 +541,9 @@ exports[`edit a comment: optimistic response 1`] = `
</time>
</div>
</div>
<div>
<div
className="HorizontalGutter-root HorizontalGutter-half"
>
<label
className="AriaInfo-root"
htmlFor="comments-editCommentForm-rte-comment-0"
@@ -1086,7 +1256,9 @@ exports[`shows expiry message: edit time expired 1`] = `
</time>
</div>
</div>
<div>
<div
className="HorizontalGutter-root HorizontalGutter-half"
>
<label
className="AriaInfo-root"
htmlFor="comments-editCommentForm-rte-comment-0"
@@ -185,111 +185,115 @@ exports[`post a reply: open reply form 1`] = `
<div
className="HorizontalGutter-root HorizontalGutter-full"
>
<div>
<label
className="AriaInfo-root"
htmlFor="comments-replyCommentForm-rte-comment-with-deepest-replies-5"
>
Write a reply
</label>
<div
className="Flex-root ReplyTo-root Flex-flex Flex-alignCenter"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
>
reply
</span>
<span>
 
</span>
<span
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary ReplyTo-text"
>
Replying to:
<span
className="Typography-root Typography-heading4 Typography-colorTextPrimary ReplyTo-username"
>
Markus
</span>
</span>
</div>
<div
className="HorizontalGutter-root HorizontalGutter-half"
>
<div>
<div
className=""
<label
className="AriaInfo-root"
htmlFor="comments-replyCommentForm-rte-comment-with-deepest-replies-5"
>
<div
Write a reply
</label>
<div
className="Flex-root ReplyTo-root Flex-flex Flex-alignCenter"
>
<span
aria-hidden="true"
className="RTE-placeholder RTE-placeholder"
className="Icon-root Icon-sm"
>
Write a reply
</div>
reply
</span>
<span>
 
</span>
<span
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary ReplyTo-text"
>
Replying to:
<span
className="Typography-root Typography-heading4 Typography-colorTextPrimary ReplyTo-username"
>
Markus
</span>
</span>
</div>
<div>
<div
aria-placeholder="Write a reply"
className="RTE-contentEditable RTE-content"
contentEditable={true}
dangerouslySetInnerHTML={
Object {
"__html": "",
className=""
>
<div
aria-hidden="true"
className="RTE-placeholder RTE-placeholder"
>
Write a reply
</div>
<div
aria-placeholder="Write a reply"
className="RTE-contentEditable RTE-content"
contentEditable={true}
dangerouslySetInnerHTML={
Object {
"__html": "",
}
}
}
disabled={false}
id="comments-replyCommentForm-rte-comment-with-deepest-replies-5"
onBlur={[Function]}
onChange={[Function]}
onCut={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
onPaste={[Function]}
onSelect={[Function]}
/>
<div
className="RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
>
<button
className="Button-button"
disabled={false}
onClick={[Function]}
title="Bold"
type="button"
id="comments-replyCommentForm-rte-comment-with-deepest-replies-5"
onBlur={[Function]}
onChange={[Function]}
onCut={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
onPaste={[Function]}
onSelect={[Function]}
/>
<div
className="RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
>
<span
aria-hidden="true"
className="Icon-root Icon-md"
<button
className="Button-button"
disabled={false}
onClick={[Function]}
title="Bold"
type="button"
>
format_bold
</span>
</button>
<button
className="Button-button"
disabled={false}
onClick={[Function]}
title="Italic"
type="button"
>
<span
aria-hidden="true"
className="Icon-root Icon-md"
<span
aria-hidden="true"
className="Icon-root Icon-md"
>
format_bold
</span>
</button>
<button
className="Button-button"
disabled={false}
onClick={[Function]}
title="Italic"
type="button"
>
format_italic
</span>
</button>
<button
className="Button-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
type="button"
>
<span
aria-hidden="true"
className="Icon-root Icon-md"
<span
aria-hidden="true"
className="Icon-root Icon-md"
>
format_italic
</span>
</button>
<button
className="Button-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
type="button"
>
format_quote
</span>
</button>
<span
aria-hidden="true"
className="Icon-root Icon-md"
>
format_quote
</span>
</button>
</div>
</div>
</div>
</div>
@@ -1,5 +1,323 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`post a reply and handle server error: open reply form 1`] = `
<div
data-testid="comment-comment-0"
>
<div
className="HorizontalGutter-root HorizontalGutter-full"
>
<div
className="Indent-root"
>
<div
className=""
>
<div
className="Comment-root"
role="article"
>
<div
className="Flex-root Flex-flex Flex-justifySpaceBetween Flex-directionRow"
>
<div
className="Flex-root Flex-flex Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
>
<span
className="Typography-root Typography-heading3 Typography-colorTextPrimary Username-root"
>
Markus
</span>
<div
className="Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow"
>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:24:00.000Z
</time>
</div>
</div>
</div>
<div
className="HorizontalGutter-root HorizontalGutter-full"
>
<div
className="HTMLContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "Joining Too",
}
}
/>
<div
className="Flex-root Flex-flex Flex-justifySpaceBetween"
>
<div
className="Flex-root Flex-flex Flex-halfItemGutter Flex-directionRow"
>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost Button-active"
id="comments-commentContainer-replyButton-comment-0"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Reply
</span>
</button>
<div
className="Popover-root"
>
<button
aria-controls="permalink-popover-comment-0"
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Share
</span>
</button>
<div
aria-hidden={true}
aria-labelledby="permalink-popover-comment-0-ariainfo"
id="permalink-popover-comment-0"
role="popup"
>
<div
className="AriaInfo-root"
id="permalink-popover-comment-0-ariainfo"
>
A dialog showing a permalink to the comment
</div>
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Respect
</span>
</button>
</div>
<div
className="Flex-root Flex-flex Flex-halfItemGutter Flex-directionRow"
>
<div
className="Popover-root"
>
<button
aria-controls="report-popover-comment-0"
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Report
</span>
</button>
<div
aria-hidden={true}
aria-labelledby="report-popover-comment-0-ariainfo"
id="report-popover-comment-0"
role="popup"
>
<div
className="AriaInfo-root"
id="report-popover-comment-0-ariainfo"
>
A dialog for reporting comments
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<form
autoComplete="off"
id="comments-replyCommentForm-form-comment-0"
onSubmit={[Function]}
>
<div
className="HorizontalGutter-root HorizontalGutter-full"
>
<div
className="HorizontalGutter-root HorizontalGutter-half"
>
<div>
<label
className="AriaInfo-root"
htmlFor="comments-replyCommentForm-rte-comment-0"
>
Write a reply
</label>
<div
className="Flex-root ReplyTo-root Flex-flex Flex-alignCenter"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
>
reply
</span>
<span>
 
</span>
<span
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary ReplyTo-text"
>
Replying to:
<span
className="Typography-root Typography-heading4 Typography-colorTextPrimary ReplyTo-username"
>
Markus
</span>
</span>
</div>
<div>
<div
className=""
>
<div
aria-hidden="true"
className="RTE-placeholder RTE-placeholder"
>
Write a reply
</div>
<div
aria-placeholder="Write a reply"
className="RTE-contentEditable RTE-content"
contentEditable={true}
dangerouslySetInnerHTML={
Object {
"__html": "",
}
}
disabled={false}
id="comments-replyCommentForm-rte-comment-0"
onBlur={[Function]}
onChange={[Function]}
onCut={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
onPaste={[Function]}
onSelect={[Function]}
/>
<div
className="RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
>
<button
className="Button-button"
disabled={false}
onClick={[Function]}
title="Bold"
type="button"
>
<span
aria-hidden="true"
className="Icon-root Icon-md"
>
format_bold
</span>
</button>
<button
className="Button-button"
disabled={false}
onClick={[Function]}
title="Italic"
type="button"
>
<span
aria-hidden="true"
className="Icon-root Icon-md"
>
format_italic
</span>
</button>
<button
className="Button-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
type="button"
>
<span
aria-hidden="true"
className="Icon-root Icon-md"
>
format_quote
</span>
</button>
</div>
</div>
</div>
</div>
</div>
<div
className="Flex-root Flex-flex Flex-halfItemGutter Flex-justifyFlexEnd Flex-directionRow"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantOutlined"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Cancel
</button>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-disabled"
disabled={true}
onBlur={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="submit"
>
Submit
</button>
</div>
</div>
</form>
</div>
</div>
`;
exports[`post a reply: open reply form 1`] = `
<div
data-testid="comment-comment-0"
@@ -170,111 +488,115 @@ exports[`post a reply: open reply form 1`] = `
<div
className="HorizontalGutter-root HorizontalGutter-full"
>
<div>
<label
className="AriaInfo-root"
htmlFor="comments-replyCommentForm-rte-comment-0"
>
Write a reply
</label>
<div
className="Flex-root ReplyTo-root Flex-flex Flex-alignCenter"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
>
reply
</span>
<span>
 
</span>
<span
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary ReplyTo-text"
>
Replying to:
<span
className="Typography-root Typography-heading4 Typography-colorTextPrimary ReplyTo-username"
>
Markus
</span>
</span>
</div>
<div
className="HorizontalGutter-root HorizontalGutter-half"
>
<div>
<div
className=""
<label
className="AriaInfo-root"
htmlFor="comments-replyCommentForm-rte-comment-0"
>
<div
Write a reply
</label>
<div
className="Flex-root ReplyTo-root Flex-flex Flex-alignCenter"
>
<span
aria-hidden="true"
className="RTE-placeholder RTE-placeholder"
className="Icon-root Icon-sm"
>
Write a reply
</div>
reply
</span>
<span>
 
</span>
<span
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary ReplyTo-text"
>
Replying to:
<span
className="Typography-root Typography-heading4 Typography-colorTextPrimary ReplyTo-username"
>
Markus
</span>
</span>
</div>
<div>
<div
aria-placeholder="Write a reply"
className="RTE-contentEditable RTE-content"
contentEditable={true}
dangerouslySetInnerHTML={
Object {
"__html": "",
className=""
>
<div
aria-hidden="true"
className="RTE-placeholder RTE-placeholder"
>
Write a reply
</div>
<div
aria-placeholder="Write a reply"
className="RTE-contentEditable RTE-content"
contentEditable={true}
dangerouslySetInnerHTML={
Object {
"__html": "",
}
}
}
disabled={false}
id="comments-replyCommentForm-rte-comment-0"
onBlur={[Function]}
onChange={[Function]}
onCut={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
onPaste={[Function]}
onSelect={[Function]}
/>
<div
className="RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
>
<button
className="Button-button"
disabled={false}
onClick={[Function]}
title="Bold"
type="button"
id="comments-replyCommentForm-rte-comment-0"
onBlur={[Function]}
onChange={[Function]}
onCut={[Function]}
onFocus={[Function]}
onInput={[Function]}
onKeyDown={[Function]}
onPaste={[Function]}
onSelect={[Function]}
/>
<div
className="RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
>
<span
aria-hidden="true"
className="Icon-root Icon-md"
<button
className="Button-button"
disabled={false}
onClick={[Function]}
title="Bold"
type="button"
>
format_bold
</span>
</button>
<button
className="Button-button"
disabled={false}
onClick={[Function]}
title="Italic"
type="button"
>
<span
aria-hidden="true"
className="Icon-root Icon-md"
<span
aria-hidden="true"
className="Icon-root Icon-md"
>
format_bold
</span>
</button>
<button
className="Button-button"
disabled={false}
onClick={[Function]}
title="Italic"
type="button"
>
format_italic
</span>
</button>
<button
className="Button-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
type="button"
>
<span
aria-hidden="true"
className="Icon-root Icon-md"
<span
aria-hidden="true"
className="Icon-root Icon-md"
>
format_italic
</span>
</button>
<button
className="Button-button"
disabled={false}
onClick={[Function]}
title="Blockquote"
type="button"
>
format_quote
</span>
</button>
<span
aria-hidden="true"
className="Icon-root Icon-md"
>
format_quote
</span>
</button>
</div>
</div>
</div>
</div>
@@ -1,6 +1,8 @@
import sinon from "sinon";
import timekeeper from "timekeeper";
import { ERROR_CODES } from "talk-common/errors";
import { InvalidRequestError } from "talk-framework/lib/errors";
import {
createSinonStub,
waitForElement,
@@ -10,7 +12,10 @@ import {
import { settings, stories, users } from "../fixtures";
import create from "./create";
function createTestRenderer() {
function createTestRenderer(
resolver: any = {},
options: { muteNetworkErrors?: boolean } = {}
) {
const resolvers = {
Query: {
story: createSinonStub(
@@ -24,38 +29,38 @@ function createTestRenderer() {
settings: sinon.stub().returns(settings),
},
Mutation: {
editComment: createSinonStub(
s => s.throws(),
s =>
s
.withArgs(undefined, {
input: {
commentID: stories[0].comments.edges[0].node.id,
body: "Edited!",
clientMutationId: "0",
},
})
.returns({
// TODO: add a type assertion here to ensure that if the type changes, that the test will fail
comment: {
id: stories[0].comments.edges[0].node.id,
body: "Edited! (from server)",
editing: {
edited: true,
},
revision: {
id: stories[0].comments.edges[0].node.revision.id,
},
},
clientMutationId: "0",
})
),
editComment: sinon.stub().callsFake((_, data) => {
expect(data).toEqual({
input: {
commentID: stories[0].comments.edges[0].node.id,
body: "Edited!",
clientMutationId: "0",
},
});
return {
// TODO: add a type assertion here to ensure that if the type changes, that the test will fail
comment: {
id: stories[0].comments.edges[0].node.id,
body: "Edited! (from server)",
editing: {
edited: true,
},
revision: {
id: stories[0].comments.edges[0].node.revision.id,
},
},
clientMutationId: "0",
};
}),
},
...resolver,
};
const { testRenderer } = create({
// Set this to true, to see graphql responses.
logNetwork: false,
muteNetworkErrors: options.muteNetworkErrors,
resolvers,
initLocalState: localRecord => {
localRecord.setValue(stories[0].id, "storyID");
@@ -157,3 +162,39 @@ it("shows expiry message", async () => {
.props.onClick();
expect(within(comment).toJSON()).toMatchSnapshot("edit form closed");
});
it("edit a comment and handle server error", async () => {
const commentData = stories[0].comments.edges[0].node;
timekeeper.freeze(commentData.createdAt);
const testRenderer = createTestRenderer(
{
Mutation: {
editComment: sinon.stub().callsFake(() => {
throw new InvalidRequestError({ code: ERROR_CODES.INTERNAL_ERROR });
}),
},
},
{ muteNetworkErrors: true }
);
const comment = await waitForElement(() =>
within(testRenderer.root).getByTestID(`comment-${commentData.id}`)
);
// Open edit form.
within(comment)
.getByText("Edit")
.props.onClick();
expect(within(comment).toJSON()).toMatchSnapshot("edit form");
testRenderer.root
.findByProps({ inputId: `comments-editCommentForm-rte-${commentData.id}` })
.props.onChange({ html: "Edited!" });
within(comment)
.getByType("form")
.props.onSubmit();
// Look for internal error being displayed.
await waitForElement(() => within(comment).getByText("INTERNAL_ERROR"));
});
@@ -1,7 +1,8 @@
import { ReactTestRenderer } from "react-test-renderer";
import sinon from "sinon";
import timekeeper from "timekeeper";
import { ERROR_CODES } from "talk-common/errors";
import { InvalidRequestError } from "talk-framework/lib/errors";
import {
createSinonStub,
waitForElement,
@@ -11,9 +12,12 @@ import {
import { baseComment, settings, stories, users } from "../fixtures";
import create from "./create";
let testRenderer: ReactTestRenderer;
beforeEach(() => {
function createTestRenderer(
resolver: any,
options: { muteNetworkErrors?: boolean } = {}
) {
const resolvers = {
...resolver,
Query: {
settings: sinon.stub().returns(settings),
me: sinon.stub().returns(users[0]),
@@ -25,47 +29,47 @@ beforeEach(() => {
.returns(stories[0])
),
},
Mutation: {
createComment: createSinonStub(
s => s.throws(),
s =>
s
.withArgs(undefined, {
input: {
storyID: stories[0].id,
body: "<b>Hello world!</b>",
clientMutationId: "0",
},
})
.returns({
// TODO: add a type assertion here to ensure that if the type changes, that the test will fail
edge: {
cursor: null,
node: {
...baseComment,
id: "comment-x",
author: users[0],
body: "<b>Hello world! (from server)</b>",
},
},
clientMutationId: "0",
})
),
},
};
({ testRenderer } = create({
return 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");
},
}));
});
});
}
it("post a comment", async () => {
const { testRenderer } = createTestRenderer({
Mutation: {
createComment: sinon.stub().callsFake((_, data) => {
expect(data).toEqual({
input: {
storyID: stories[0].id,
body: "<b>Hello world!</b>",
clientMutationId: "0",
},
});
return {
edge: {
cursor: null,
node: {
...baseComment,
id: "comment-x",
author: users[0],
body: "<b>Hello world! (from server)</b>",
},
},
clientMutationId: "0",
};
}),
},
});
const tabPane = await waitForElement(() =>
within(testRenderer.root).getByTestID("current-tab-pane")
);
@@ -94,3 +98,35 @@ it("post a comment", async () => {
)
);
});
it("post a comment and handle server error", async () => {
const { testRenderer } = createTestRenderer(
{
Mutation: {
createComment: sinon.stub().callsFake(() => {
throw new InvalidRequestError({ code: ERROR_CODES.INTERNAL_ERROR });
}),
},
},
{ muteNetworkErrors: true }
);
const tabPane = await waitForElement(() =>
within(testRenderer.root).getByTestID("current-tab-pane")
);
testRenderer.root
.findByProps({ inputId: "comments-postCommentForm-field" })
.props.onChange({ html: "<b>Hello world!</b>" });
timekeeper.freeze(new Date(baseComment.createdAt));
testRenderer.root
.findByProps({ id: "comments-postCommentForm-form" })
.props.onSubmit();
timekeeper.reset();
// Look for internal error being displayed.
await waitForElement(() => within(tabPane).getByText("INTERNAL_ERROR"));
});
@@ -1,7 +1,8 @@
import { ReactTestRenderer } from "react-test-renderer";
import sinon from "sinon";
import timekeeper from "timekeeper";
import { ERROR_CODES } from "talk-common/errors";
import { InvalidRequestError } from "talk-framework/lib/errors";
import {
createSinonStub,
waitForElement,
@@ -11,9 +12,12 @@ import {
import { baseComment, settings, stories, users } from "../fixtures";
import create from "./create";
let testRenderer: ReactTestRenderer;
beforeEach(() => {
function createTestRenderer(
resolver: any,
options: { muteNetworkErrors?: boolean } = {}
) {
const resolvers = {
...resolver,
Query: {
settings: sinon.stub().returns(settings),
me: sinon.stub().returns(users[0]),
@@ -25,48 +29,48 @@ beforeEach(() => {
.returns(stories[0])
),
},
Mutation: {
createCommentReply: createSinonStub(
s => s.throws(),
s =>
s
.withArgs(undefined, {
input: {
storyID: stories[0].id,
parentID: stories[0].comments.edges[0].node.id,
parentRevisionID: stories[0].comments.edges[0].node.revision.id,
body: "<b>Hello world!</b>",
clientMutationId: "0",
},
})
.returns({
edge: {
cursor: null,
node: {
...baseComment,
id: "comment-x",
author: users[0],
body: "<b>Hello world! (from server)</b>",
},
},
clientMutationId: "0",
})
),
},
};
({ testRenderer } = create({
return 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");
},
}));
});
});
}
it("post a reply", async () => {
const { testRenderer } = createTestRenderer({
Mutation: {
createCommentReply: sinon.stub().callsFake((_, data) => {
expect(data).toEqual({
input: {
storyID: stories[0].id,
parentID: stories[0].comments.edges[0].node.id,
parentRevisionID: stories[0].comments.edges[0].node.revision.id,
body: "<b>Hello world!</b>",
clientMutationId: "0",
},
});
return {
edge: {
cursor: null,
node: {
...baseComment,
id: "comment-x",
author: users[0],
body: "<b>Hello world! (from server)</b>",
},
},
clientMutationId: "0",
};
}),
},
});
const streamLog = await waitForElement(() =>
within(testRenderer.root).getByTestID("comments-stream-log")
);
@@ -104,3 +108,40 @@ it("post a reply", async () => {
within(commentReplyList).getByText("(from server)", { exact: false })
);
});
it("post a reply and handle server error", async () => {
const { testRenderer } = createTestRenderer(
{
Mutation: {
createCommentReply: sinon.stub().callsFake(() => {
throw new InvalidRequestError({ code: ERROR_CODES.INTERNAL_ERROR });
}),
},
},
{ muteNetworkErrors: true }
);
const streamLog = await waitForElement(() =>
within(testRenderer.root).getByTestID("comments-stream-log")
);
const comment = within(streamLog).getByTestID("comment-comment-0");
// Open reply form.
within(comment)
.getByText("Reply", { selector: "button" })
.props.onClick();
const form = await waitForElement(() => within(comment).getByType("form"));
expect(within(comment).toJSON()).toMatchSnapshot("open reply form");
// Write reply .
testRenderer.root
.findByProps({ inputId: "comments-replyCommentForm-rte-comment-0" })
.props.onChange({ html: "<b>Hello world!</b>" });
timekeeper.freeze(new Date(baseComment.createdAt));
form.props.onSubmit();
// Look for internal error being displayed.
await waitForElement(() => within(comment).getByText("INTERNAL_ERROR"));
});
@@ -1,12 +1,17 @@
import sinon from "sinon";
import { ERROR_CODES } from "talk-common/errors";
import { timeout } from "talk-common/utils";
import { InvalidRequestError } from "talk-framework/lib/errors";
import { wait, waitForElement, within } from "talk-framework/testHelpers";
import { timeout } from "talk-common/utils";
import { settings, stories, users } from "../fixtures";
import create from "./create";
function createTestRenderer() {
function createTestRenderer(
resolver: any = {},
options: { muteNetworkErrors?: boolean } = {}
) {
const resolvers = {
Query: {
story: sinon.stub().callsFake((_: any, data: any) => {
@@ -18,6 +23,7 @@ function createTestRenderer() {
}),
me: sinon.stub().returns(users[0]),
settings: sinon.stub().returns(settings),
...resolver.Query,
},
Mutation: {
createCommentFlag: sinon.stub().callsFake((_: any, data: any) => {
@@ -51,12 +57,14 @@ function createTestRenderer() {
clientMutationId: "0",
};
}),
...resolver.Mutation,
},
};
const { testRenderer } = create({
// Set this to true, to see graphql responses.
logNetwork: false,
muteNetworkErrors: options.muteNetworkErrors,
resolvers,
initLocalState: localRecord => {
localRecord.setValue(stories[0].id, "storyID");
@@ -222,3 +230,41 @@ it("dont agree with comment", async () => {
expect(reportedButton.props.disabled).toBe(true);
expect(resolvers.Mutation.createCommentDontAgree.called).toBe(true);
});
it("report comment as offensive and handle server error", async () => {
const commentID = stories[0].comments.edges[0].node.id;
const { testRenderer } = createTestRenderer(
{
Mutation: {
createCommentFlag: sinon.stub().callsFake(() => {
throw new InvalidRequestError({ code: ERROR_CODES.INTERNAL_ERROR });
}),
},
},
{ muteNetworkErrors: true }
);
const comment = await waitForElement(() =>
within(testRenderer.root).getByTestID(`comment-${commentID}`)
);
const button = within(comment).getByText("Report", { selector: "button" });
button.props.onClick();
const popover = within(testRenderer.root).getByID(
button.props["aria-controls"]
);
const radioButton = within(popover).getByLabelText(
"This comment is offensive"
);
radioButton.props.onChange({
target: { type: "radio", value: radioButton.props.value },
});
within(popover)
.getByType("form")
.props.onSubmit({});
// Look for internal error being displayed.
await waitForElement(() => within(popover).getByText("INTERNAL_ERROR"));
});
+2
View File
@@ -18,6 +18,7 @@ import AppContainer from "../containers/AppContainer";
export interface CreateParams {
logNetwork?: boolean;
muteNetworkErrors?: boolean;
resolvers: IResolvers<any, any>;
initLocalState?: (
local: RecordProxy,
@@ -31,6 +32,7 @@ export default function create(params: CreateParams) {
// Set this to true, to see graphql responses.
logNetwork: params.logNetwork,
resolvers: params.resolvers,
muteNetworkErrors: params.muteNetworkErrors,
initLocalState: (localRecord, source, env) => {
if (params.initLocalState) {
params.initLocalState(localRecord, source, env);
@@ -5,6 +5,7 @@ import { AUTH_POPUP_ID, AUTH_POPUP_TYPE } from "talk-stream/local";
interface CreateEnvironmentParams {
logNetwork?: boolean;
muteNetworkErrors?: boolean;
resolvers: IResolvers<any, any>;
initLocalState?: (
local: RecordProxy,
@@ -17,6 +18,7 @@ export default function createEnvironment(params: CreateEnvironmentParams) {
return createRelayEnvironment({
network: {
logNetwork: params.logNetwork,
muteNetworkErrors: params.muteNetworkErrors,
resolvers: params.resolvers,
projectName: "tenant",
},
+14
View File
@@ -1,4 +1,18 @@
export enum ERROR_TYPES {
INVALID_REQUEST_ERROR = "INVALID_REQUEST_ERROR",
}
export enum ERROR_CODES {
/**
* COMMENT_BODY_TOO_SHORT is used when a submitted comment body is too short.
*/
COMMENT_BODY_TOO_SHORT = "COMMENT_BODY_TOO_SHORT",
/**
* COMMENT_BODY_EXCEEDS_MAX_LENGTH is used when a submitted comment body exceeds the maximum length.
*/
COMMENT_BODY_EXCEEDS_MAX_LENGTH = "COMMENT_BODY_EXCEEDS_MAX_LENGTH",
/**
* STORY_URL_NOT_PERMITTED is used when the given Story being created or
* updated does not have a URL that is permitted by the Tenant.
+23 -10
View File
@@ -4,16 +4,11 @@ import { FluentBundle } from "fluent/compat";
import uuid from "uuid";
import { VError } from "verror";
import { ERROR_CODES } from "talk-common/errors";
import { ERROR_CODES, ERROR_TYPES } from "talk-common/errors";
import { translate } from "talk-server/services/i18n";
import { ERROR_TRANSLATIONS } from "./translations";
/**
* TalkErrorTypes associates a class of errors with a specific code.
*/
export type TalkErrorTypes = "invalid_request_error";
/**
* TalkErrorExtensions is the different extension data that is associated with
* a given error. This data is surfaced in the GraphQL, REST error response as
@@ -35,7 +30,7 @@ export interface TalkErrorExtensions {
/**
* type represents the class of errors that this error is associated with.
*/
readonly type: TalkErrorTypes;
readonly type: ERROR_TYPES;
/**
* message is the (optionally translated) message that can be shown to users.
@@ -90,7 +85,7 @@ export interface TalkErrorOptions {
/**
* type represents the class of errors that this error is associated with.
*/
type?: TalkErrorTypes;
type?: ERROR_TYPES;
/**
* cause is the error that provides the root cause of the underlying error
@@ -128,7 +123,7 @@ export class TalkError extends VError {
/**
* type represents the class of errors that this error is associated with.
*/
public readonly type: TalkErrorTypes;
public readonly type: ERROR_TYPES;
/**
* param, if set, references the fieldSpec to which the error is related to.
@@ -146,7 +141,7 @@ export class TalkError extends VError {
code,
context = {},
status = 500,
type = "invalid_request_error",
type = ERROR_TYPES.INVALID_REQUEST_ERROR,
cause,
param,
}: TalkErrorOptions) {
@@ -194,6 +189,24 @@ export class TalkError extends VError {
}
}
export class CommentBodyTooShortError extends TalkError {
constructor(min: number) {
super({
code: ERROR_CODES.COMMENT_BODY_TOO_SHORT,
context: { pub: { min } },
});
}
}
export class CommentBodyExceedsMaxLengthError extends TalkError {
constructor(max: number) {
super({
code: ERROR_CODES.COMMENT_BODY_EXCEEDS_MAX_LENGTH,
context: { pub: { max } },
});
}
}
export class StoryURLInvalidError extends TalkError {
constructor(properties: { storyURL: string; tenantDomains: string[] }) {
super({
+2
View File
@@ -1,6 +1,8 @@
import { ERROR_CODES } from "talk-common/errors";
export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
COMMENT_BODY_TOO_SHORT: "error-commentBodyTooShort",
COMMENT_BODY_EXCEEDS_MAX_LENGTH: "error-commentBodyExceedsMaxLength",
STORY_URL_NOT_PERMITTED: "error-storyURLNotPermitted",
TOKEN_NOT_FOUND: "error-tokenNotFound",
DUPLICATE_STORY_URL: "error-duplicateStoryURL",
@@ -1,4 +1,6 @@
import { ERROR_CODES } from "talk-common/errors";
import { ADDITIONAL_DETAILS_MAX_LENGTH } from "talk-common/helpers/validate";
import { mapFieldsetToErrorCodes } from "talk-server/graph/common/errors";
import TenantContext from "talk-server/graph/tenant/context";
import {
GQLCreateCommentDontAgreeInput,
@@ -26,13 +28,21 @@ export const Comment = (ctx: TenantContext) => ({
clientMutationId,
...comment
}: GQLCreateCommentInput | GQLCreateCommentReplyInput) =>
create(
ctx.mongo,
ctx.redis,
ctx.tenant,
ctx.user!,
{ authorID: ctx.user!.id, ...comment },
ctx.req
mapFieldsetToErrorCodes(
create(
ctx.mongo,
ctx.redis,
ctx.tenant,
ctx.user!,
{ authorID: ctx.user!.id, ...comment },
ctx.req
),
{
"input.body": [
ERROR_CODES.COMMENT_BODY_EXCEEDS_MAX_LENGTH,
ERROR_CODES.COMMENT_BODY_TOO_SHORT,
],
}
),
edit: ({ commentID, body }: GQLEditCommentInput) =>
edit(
+3
View File
@@ -1,3 +1,6 @@
error-commentBodyTooShort = Comment body must have at least {$min} characters.
error-commentBodyExceedsMaxLength =
Comment body exceeds maximum length of {$max} characters.
error-storyURLNotPermitted =
The specified story URL does not exist in the permitted domains list.
error-duplicateStoryURL = The specified story URL already exists.
@@ -1,6 +1,10 @@
import striptags from "striptags";
import { isNil } from "lodash";
import {
CommentBodyExceedsMaxLengthError,
CommentBodyTooShortError,
} from "talk-server/errors";
import { ModerationSettings } from "talk-server/models/settings";
import {
IntermediateModerationPhase,
@@ -14,20 +18,12 @@ const testCharCount = (
if (settings.charCount && settings.charCount.enabled) {
if (!isNil(settings.charCount.min)) {
if (length < settings.charCount.min) {
throw new Error(
`Body did not meet minimum length requirement of ${
settings.charCount.min
}`
);
throw new CommentBodyTooShortError(settings.charCount.min);
}
}
if (!isNil(settings.charCount.max)) {
if (length > settings.charCount.max) {
throw new Error(
`Body exceeded maximum length requirement of ${
settings.charCount.max
}`
);
throw new CommentBodyExceedsMaxLengthError(settings.charCount.max);
}
}
}