mirror of
https://github.com/wassname/talk.git
synced 2026-08-20 12:50:41 +08:00
[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:
@@ -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);
|
||||
|
||||
+11
-1
@@ -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">
|
||||
|
||||
+3
-3
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user