mirror of
https://github.com/wassname/talk.git
synced 2026-07-31 12:50:48 +08:00
[CORL-1029] Better HTML Support (#2956)
* feat: Improve html handling, integrate new @coralproject/rte * chore: refactor and add comments * chore: remove obsolete line * chore: rename `inputId` to `inputID` * chore: upgrade @coralproject/rte * fix: update snapshots * chore: apply review suggestions * fix: snapshot / tests * fix: merge issues * [CORL-1056] Configurable RTE (#2967) * fix: merge issues * feat: Configure RTE * test: add tests * chore: just a comment * chore: remove unused translations
This commit is contained in:
Generated
+3807
-987
File diff suppressed because it is too large
Load Diff
+11
-6
@@ -149,7 +149,7 @@
|
||||
"@babel/preset-typescript": "^7.9.0",
|
||||
"@babel/runtime-corejs3": "^7.9.2",
|
||||
"@coralproject/npm-run-all": "^4.1.5",
|
||||
"@coralproject/rte": "^0.11.1",
|
||||
"@coralproject/rte": "^1.0.0",
|
||||
"@fluent/react": "^0.11.1",
|
||||
"@intervolga/optimize-cssnano-plugin": "^1.0.6",
|
||||
"@types/archiver": "^3.1.0",
|
||||
@@ -185,8 +185,8 @@
|
||||
"@types/html-to-text": "^1.4.31",
|
||||
"@types/html-webpack-plugin": "^3.2.2",
|
||||
"@types/ioredis": "^4.14.9",
|
||||
"@types/jest": "^25.1.4",
|
||||
"@types/jest-axe": "^3.2.1",
|
||||
"@types/jest": "^25.2.1",
|
||||
"@types/jest-axe": "^3.2.2",
|
||||
"@types/jsdom": "^16.2.0",
|
||||
"@types/jsonwebtoken": "^8.3.8",
|
||||
"@types/linkifyjs": "^2.1.3",
|
||||
@@ -292,10 +292,10 @@
|
||||
"html-webpack-plugin": "^4.0.4",
|
||||
"husky": "^4.2.3",
|
||||
"intersection-observer": "^0.7.0",
|
||||
"jest": "^25.2.4",
|
||||
"jest": "^26.0.1",
|
||||
"jest-axe": "^3.4.0",
|
||||
"jest-junit": "^10.0.0",
|
||||
"jest-localstorage-mock": "^2.4.0",
|
||||
"jest-localstorage-mock": "^2.4.2",
|
||||
"jest-mock-console": "^1.0.0",
|
||||
"keymaster": "^1.6.2",
|
||||
"lint-staged": "^10.1.1",
|
||||
@@ -356,7 +356,7 @@
|
||||
"terser-webpack-plugin": "^2.3.5",
|
||||
"thread-loader": "^2.1.3",
|
||||
"timekeeper": "^2.2.0",
|
||||
"ts-jest": "^25.3.0",
|
||||
"ts-jest": "25.4.0",
|
||||
"ts-loader": "^6.2.2",
|
||||
"ts-node": "^8.8.1",
|
||||
"ts-node-dev": "^1.0.0-pre.44",
|
||||
@@ -379,6 +379,11 @@
|
||||
"whatwg-fetch": "^3.0.0"
|
||||
},
|
||||
"dependencies-pins-documentation": {
|
||||
"ts-jest@25.4.0": [
|
||||
"Newer versions require every definition file to be included",
|
||||
"in tsconfig.json in the `files` field. This is cumbersome and",
|
||||
"here is a discussion of it: https://github.com/kulshekhar/ts-jest/issues/1604"
|
||||
],
|
||||
"wait-for-expect@1.x.x": [
|
||||
"Newer versions breaks the use of jest fake timers"
|
||||
],
|
||||
|
||||
@@ -77,6 +77,7 @@ export type Config = typeof config;
|
||||
|
||||
export const createClientEnv = (c: Config) => ({
|
||||
NODE_ENV: c.get("env"),
|
||||
WEBPACK: "true",
|
||||
});
|
||||
|
||||
// Setup the base configuration.
|
||||
|
||||
@@ -1,20 +1,50 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import striptags from "striptags";
|
||||
|
||||
import {
|
||||
getPhrasesRegExp,
|
||||
GetPhrasesRegExpOptions,
|
||||
markHTMLNode,
|
||||
} from "coral-admin/helpers";
|
||||
import { createPurify } from "coral-common/utils/purify";
|
||||
import {
|
||||
ALL_FEATURES,
|
||||
createSanitize,
|
||||
Sanitize,
|
||||
} from "coral-common/helpers/sanitize";
|
||||
|
||||
import styles from "./CommentContent.css";
|
||||
|
||||
/**
|
||||
* Create a purify instance that will be used to handle HTML content.
|
||||
* Return a purify instance that will be used to handle HTML content.
|
||||
*/
|
||||
const purify = createPurify(window, false);
|
||||
const getSanitize: (highlight: boolean) => Sanitize = (() => {
|
||||
let sanitizers: Record<"default" | "highlight", Sanitize> | null = null;
|
||||
return (highlight: boolean) => {
|
||||
if (!sanitizers) {
|
||||
sanitizers = {
|
||||
default: createSanitize(window, {
|
||||
// Allow all RTE features to be displayed.
|
||||
features: ALL_FEATURES,
|
||||
}),
|
||||
highlight: createSanitize(window, {
|
||||
// We need normalized text nodes to mark nodes for suspect/banned words.
|
||||
normalize: true,
|
||||
// Allow all RTE features to be displayed.
|
||||
features: ALL_FEATURES,
|
||||
config: {
|
||||
FORBID_TAGS: highlight
|
||||
? ["b", "strong", "i", "em", "s", "span"]
|
||||
: [],
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (highlight) {
|
||||
return sanitizers.highlight!;
|
||||
}
|
||||
return sanitizers.default!;
|
||||
};
|
||||
})();
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -51,15 +81,7 @@ const CommentContent: FunctionComponent<Props> = ({
|
||||
}
|
||||
|
||||
// Sanitize the input for display.
|
||||
let html = purify.sanitize(children);
|
||||
if (highlight) {
|
||||
html = striptags(html, ["a"]);
|
||||
}
|
||||
|
||||
// We create a Shadow DOM Tree with the HTML body content and use it as a
|
||||
// parser.
|
||||
const node = document.createElement("div");
|
||||
node.innerHTML = html;
|
||||
const node = getSanitize(highlight)(children);
|
||||
|
||||
// If the expression is available, then mark the nodes.
|
||||
if (expression) {
|
||||
|
||||
@@ -110,7 +110,7 @@ const CommentLengthConfig: FunctionComponent<Props> = ({ disabled }) => (
|
||||
<TextFieldAdornment>Characters</TextFieldAdornment>
|
||||
</Localized>
|
||||
}
|
||||
placeholder={"No limit"}
|
||||
placeholder="No limit"
|
||||
textAlignCenter
|
||||
/>
|
||||
</Localized>
|
||||
|
||||
@@ -18,6 +18,7 @@ import CommentLengthConfig from "./CommentLengthConfig";
|
||||
import GuidelinesConfig from "./GuidelinesConfig";
|
||||
import LocaleConfig from "./LocaleConfig";
|
||||
import ReactionConfigContainer from "./ReactionConfigContainer";
|
||||
import RTEConfig from "./RTEConfig";
|
||||
import SitewideCommentingConfig from "./SitewideCommentingConfig";
|
||||
import StaffConfig from "./StaffConfig";
|
||||
|
||||
@@ -44,6 +45,7 @@ const GeneralConfigContainer: React.FunctionComponent<Props> = ({
|
||||
<SitewideCommentingConfig disabled={submitting} />
|
||||
<AnnouncementConfigContainer disabled={submitting} settings={settings} />
|
||||
<GuidelinesConfig disabled={submitting} />
|
||||
<RTEConfig disabled={submitting} />
|
||||
<CommentLengthConfig disabled={submitting} />
|
||||
<CommentEditingConfig disabled={submitting} />
|
||||
<ClosingCommentStreamsConfig disabled={submitting} />
|
||||
@@ -67,6 +69,7 @@ const enhanced = withFragmentContainer<Props>({
|
||||
...SitewideCommentingConfig_formValues @relay(mask: false)
|
||||
...ReactionConfig_formValues @relay(mask: false)
|
||||
...StaffConfig_formValues @relay(mask: false)
|
||||
...RTEConfig_formValues @relay(mask: false)
|
||||
|
||||
...ReactionConfigContainer_settings
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.disabledLabel {
|
||||
color: var(--v2-colors-grey-400);
|
||||
}
|
||||
.spoilerDesc {
|
||||
font-size: var(--v2-font-size-2);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
color: var(--v2-colors-grey-400);
|
||||
padding-left: 26px;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Field, FormSpy } from "react-final-form";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { parseBool } from "coral-framework/lib/form";
|
||||
import {
|
||||
CheckBox,
|
||||
FieldSet,
|
||||
FormField,
|
||||
FormFieldDescription,
|
||||
Label,
|
||||
} from "coral-ui/components/v2";
|
||||
|
||||
import ConfigBox from "../../ConfigBox";
|
||||
import Header from "../../Header";
|
||||
import OnOffField from "../../OnOffField";
|
||||
|
||||
import styles from "./RTEConfig.css";
|
||||
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
graphql`
|
||||
fragment RTEConfig_formValues on Settings {
|
||||
rte {
|
||||
enabled
|
||||
strikethrough
|
||||
spoiler
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const RTEConfig: FunctionComponent<Props> = ({ disabled }) => (
|
||||
<ConfigBox
|
||||
title={
|
||||
<Localized id="configure-general-rte-title">
|
||||
<Header container={<legend />}>Rich-text comments</Header>
|
||||
</Localized>
|
||||
}
|
||||
container={<FieldSet />}
|
||||
>
|
||||
<Localized id="configure-general-rte-express">
|
||||
<FormFieldDescription>
|
||||
Give your community more ways to express themselves beyond plain text
|
||||
with rich-text formatting.
|
||||
</FormFieldDescription>
|
||||
</Localized>
|
||||
|
||||
<FormField container={<fieldset />}>
|
||||
<Localized id="configure-general-rte-richTextComments">
|
||||
<Label>Rich-Text comments</Label>
|
||||
</Localized>
|
||||
<OnOffField
|
||||
name="rte.enabled"
|
||||
disabled={disabled}
|
||||
onLabel={
|
||||
<Localized id="configure-general-rte-onBasicFeatures">
|
||||
<span>On - bold, italics, block quotes, and bulletted lists</span>
|
||||
</Localized>
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormSpy subscription={{ values: true }}>
|
||||
{(props) => {
|
||||
const rteDisabled = !props.values.rte.enabled;
|
||||
return (
|
||||
<>
|
||||
<FormField container={<fieldset />}>
|
||||
<Localized id="configure-general-rte-additional">
|
||||
<Label
|
||||
className={cn({
|
||||
[styles.disabledLabel]: rteDisabled,
|
||||
})}
|
||||
>
|
||||
Additional rich-text options
|
||||
</Label>
|
||||
</Localized>
|
||||
<Field name="rte.strikethrough" type="checkbox" parse={parseBool}>
|
||||
{({ input }) => (
|
||||
<Localized id="configure-general-rte-strikethrough">
|
||||
<CheckBox
|
||||
{...input}
|
||||
id={input.name}
|
||||
disabled={rteDisabled || disabled}
|
||||
>
|
||||
Strikethrough
|
||||
</CheckBox>
|
||||
</Localized>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="rte.spoiler" type="checkbox" parse={parseBool}>
|
||||
{({ input }) => (
|
||||
<div>
|
||||
<Localized id="configure-general-rte-spoiler">
|
||||
<CheckBox
|
||||
{...input}
|
||||
id={input.name}
|
||||
disabled={rteDisabled || disabled}
|
||||
>
|
||||
Spoiler
|
||||
</CheckBox>
|
||||
</Localized>
|
||||
<Localized id="configure-general-rte-spoilerDesc">
|
||||
<div className={styles.spoilerDesc}>
|
||||
Words and phrases formatted as Spoiler are hidden behind
|
||||
a dark background until the reader chooses to reveal the
|
||||
text.
|
||||
</div>
|
||||
</Localized>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</FormField>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</FormSpy>
|
||||
</ConfigBox>
|
||||
);
|
||||
|
||||
export default RTEConfig;
|
||||
@@ -117,7 +117,7 @@ const SlackConfigContainer: FunctionComponent<Props> = ({ form, settings }) => {
|
||||
<FieldArray name="slack.channels">
|
||||
{({ fields }) =>
|
||||
fields
|
||||
.map((channel, index) => (
|
||||
.map((channel: any, index: number) => (
|
||||
<SlackChannel
|
||||
key={index}
|
||||
channel={channel}
|
||||
|
||||
@@ -551,6 +551,163 @@ here:
|
||||
</fieldset>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
className="FieldSet-root Box-root ConfigBox-root"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root ConfigBox-title Flex-flex Flex-justifySpaceBetween"
|
||||
>
|
||||
<div>
|
||||
<legend
|
||||
className="Header-root"
|
||||
>
|
||||
Rich-text comments
|
||||
</legend>
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
<div
|
||||
className="ConfigBox-content"
|
||||
>
|
||||
<fieldset
|
||||
className="FieldSet-root Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
|
||||
>
|
||||
<p
|
||||
className="FormFieldDescription-root"
|
||||
>
|
||||
Give your community more ways to express themselves beyond plain text with rich-text formatting.
|
||||
</p>
|
||||
<fieldset
|
||||
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
|
||||
>
|
||||
<label
|
||||
className="Label-root"
|
||||
>
|
||||
Rich-text comments
|
||||
</label>
|
||||
<div>
|
||||
<div
|
||||
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<input
|
||||
checked={true}
|
||||
className="RadioButton-input"
|
||||
disabled={false}
|
||||
id="rte.enabled-true"
|
||||
name="rte.enabled"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
type="radio"
|
||||
value="true"
|
||||
/>
|
||||
<label
|
||||
className="RadioButton-label RadioButton-labelChecked"
|
||||
htmlFor="rte.enabled-true"
|
||||
>
|
||||
<span>
|
||||
On - bold, italics, block quotes, and bulletted lists
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root Flex-root RadioButton-root Flex-flex Flex-alignCenter"
|
||||
>
|
||||
<input
|
||||
checked={false}
|
||||
className="RadioButton-input"
|
||||
disabled={false}
|
||||
id="rte.enabled-false"
|
||||
name="rte.enabled"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
type="radio"
|
||||
value="false"
|
||||
/>
|
||||
<label
|
||||
className="RadioButton-label"
|
||||
htmlFor="rte.enabled-false"
|
||||
>
|
||||
<span>
|
||||
Off
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
|
||||
>
|
||||
<label
|
||||
className="Label-root"
|
||||
>
|
||||
Additional rich-text options
|
||||
</label>
|
||||
<div
|
||||
className="CheckBox-root"
|
||||
>
|
||||
<input
|
||||
checked={false}
|
||||
className="CheckBox-input"
|
||||
disabled={false}
|
||||
id="rte.strikethrough"
|
||||
name="rte.strikethrough"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
type="checkbox"
|
||||
value={false}
|
||||
/>
|
||||
<label
|
||||
className="CheckBox-label"
|
||||
htmlFor="rte.strikethrough"
|
||||
>
|
||||
<span
|
||||
className="CheckBox-labelSpan"
|
||||
>
|
||||
Strikethrough
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className="CheckBox-root"
|
||||
>
|
||||
<input
|
||||
checked={false}
|
||||
className="CheckBox-input"
|
||||
disabled={false}
|
||||
id="rte.spoiler"
|
||||
name="rte.spoiler"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
type="checkbox"
|
||||
value={false}
|
||||
/>
|
||||
<label
|
||||
className="CheckBox-label"
|
||||
htmlFor="rte.spoiler"
|
||||
>
|
||||
<span
|
||||
className="CheckBox-labelSpan"
|
||||
>
|
||||
Spoiler
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
className="RTEConfig-spoilerDesc"
|
||||
>
|
||||
Words and phrases formatted as Spoiler are hidden behind a
|
||||
dark background until the reader chooses to reveal the text.
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset
|
||||
className="FieldSet-root Box-root ConfigBox-root"
|
||||
>
|
||||
|
||||
@@ -148,7 +148,7 @@ it("prevents admin lock out", async () => {
|
||||
|
||||
// Let's disable local auth.
|
||||
act(() => {
|
||||
within(container).getByLabelText("Enabled").props.onChange();
|
||||
within(container).getByLabelText("Enabled").props.onChange(false);
|
||||
});
|
||||
|
||||
// Send form
|
||||
@@ -203,7 +203,8 @@ it("prevents stream lock out", async () => {
|
||||
try {
|
||||
window.confirm = stubCancel;
|
||||
// Let's disable stream target in local auth.
|
||||
act(() => streamTarget.props.onChange());
|
||||
act(() => streamTarget.props.onChange(false));
|
||||
within(streamTarget).debug();
|
||||
|
||||
// Send form
|
||||
await act(async () => await form.props.onSubmit());
|
||||
@@ -215,9 +216,6 @@ it("prevents stream lock out", async () => {
|
||||
});
|
||||
|
||||
window.confirm = stubContinue;
|
||||
// Let's enable stream target in local auth.
|
||||
act(() => streamTarget.props.onChange());
|
||||
|
||||
// Send form
|
||||
await act(async () => await form.props.onSubmit());
|
||||
|
||||
|
||||
@@ -538,3 +538,66 @@ it("handle server error", async () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("change rte config", async () => {
|
||||
const resolvers = createResolversStub<GQLResolver>({
|
||||
Mutation: {
|
||||
updateSettings: ({ variables }) => {
|
||||
expectAndFail(variables.settings.rte).toEqual({
|
||||
enabled: true,
|
||||
strikethrough: true,
|
||||
spoiler: false,
|
||||
});
|
||||
return {
|
||||
settings: pureMerge(settings, variables.settings),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const {
|
||||
configureContainer,
|
||||
generalContainer,
|
||||
saveChangesButton,
|
||||
} = await createTestRenderer({
|
||||
resolvers,
|
||||
});
|
||||
|
||||
const rteContainer = within(
|
||||
generalContainer
|
||||
).getAllByText("Rich-text comments", { selector: "fieldset" })[0];
|
||||
const onField = within(rteContainer).getByLabelText("On", { exact: false });
|
||||
const offField = within(rteContainer).getByLabelText("Off", { exact: false });
|
||||
const strikethroughField = within(rteContainer).getByLabelText(
|
||||
"Strikethrough"
|
||||
);
|
||||
|
||||
// Turn off rte will disable additional options.
|
||||
act(() => offField.props.onChange(offField.props.value.toString()));
|
||||
expect(strikethroughField.props.disabled).toBe(true);
|
||||
|
||||
// Turn on rte will enable additional options.
|
||||
act(() => onField.props.onChange(onField.props.value.toString()));
|
||||
expect(strikethroughField.props.disabled).toBe(false);
|
||||
|
||||
// Enable strikethrough option.
|
||||
act(() => strikethroughField.props.onChange(true));
|
||||
|
||||
// Send form
|
||||
act(() => {
|
||||
within(configureContainer).getByType("form").props.onSubmit();
|
||||
});
|
||||
|
||||
// Submit button and text field should be disabled.
|
||||
expect(saveChangesButton.props.disabled).toBe(true);
|
||||
expect(onField.props.disabled).toBe(true);
|
||||
expect(strikethroughField.props.disabled).toBe(true);
|
||||
|
||||
// Wait for submission to be finished
|
||||
await act(async () => {
|
||||
await wait(() => {
|
||||
expect(onField.props.disabled).toBe(false);
|
||||
expect(strikethroughField.props.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
expect(resolvers.Mutation!.updateSettings!.called).toBe(true);
|
||||
});
|
||||
|
||||
@@ -188,6 +188,11 @@ export const settings = createFixture<GQLSettings>({
|
||||
},
|
||||
multisite: false,
|
||||
featureFlags: [],
|
||||
rte: {
|
||||
enabled: true,
|
||||
strikethrough: false,
|
||||
spoiler: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const settingsWithEmptyAuth = createFixture<GQLSettings>(
|
||||
|
||||
@@ -193,7 +193,7 @@ export const validateWholeNumber = createValidator(
|
||||
*/
|
||||
export const validateWholeNumberGreaterThan = (x: number) =>
|
||||
createValidator(
|
||||
(v) => !v || v === 0 || (Number.isInteger(parseFloat(v)) && v > 0),
|
||||
(v) => v === null || (Number.isInteger(parseFloat(v)) && v > x),
|
||||
NOT_A_WHOLE_NUMBER_GREATER_THAN(x)
|
||||
);
|
||||
|
||||
@@ -202,7 +202,7 @@ export const validateWholeNumberGreaterThan = (x: number) =>
|
||||
*/
|
||||
export const validateWholeNumberGreaterThanOrEqual = (x: number) =>
|
||||
createValidator(
|
||||
(v) => !v || v === 0 || (Number.isInteger(parseFloat(v)) && v >= 0),
|
||||
(v) => v === null || (Number.isInteger(parseFloat(v)) && v >= x),
|
||||
NOT_A_WHOLE_NUMBER_GREATER_THAN_OR_EQUAL(x)
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ReactTestInstance } from "react-test-renderer";
|
||||
|
||||
import matchText, { TextMatchOptions, TextMatchPattern } from "./matchText";
|
||||
|
||||
const matcher = (pattern: TextMatchPattern, options?: TextMatchOptions) => (
|
||||
i: ReactTestInstance
|
||||
) => {
|
||||
// Only look at dom components.
|
||||
if (typeof i.type !== "string" || !i.props.title) {
|
||||
return false;
|
||||
}
|
||||
return matchText(pattern, i.props.title, {
|
||||
collapseWhitespace: false,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export function getByTitle(
|
||||
container: ReactTestInstance,
|
||||
pattern: TextMatchPattern,
|
||||
options?: TextMatchOptions
|
||||
) {
|
||||
return container.find(matcher(pattern, options));
|
||||
}
|
||||
|
||||
export function getAllByTitle(
|
||||
container: ReactTestInstance,
|
||||
pattern: TextMatchPattern,
|
||||
options?: TextMatchOptions
|
||||
) {
|
||||
const results = container.findAll(matcher(pattern, options));
|
||||
if (!results.length) {
|
||||
throw new Error(`Couldn't find test id ${pattern}`);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function queryByTitle(
|
||||
container: ReactTestInstance,
|
||||
pattern: TextMatchPattern,
|
||||
options?: TextMatchOptions
|
||||
) {
|
||||
const results = container.findAll(matcher(pattern, options));
|
||||
if (!results.length) {
|
||||
return null;
|
||||
}
|
||||
return results[0];
|
||||
}
|
||||
|
||||
export function queryAllByTitle(
|
||||
container: ReactTestInstance,
|
||||
pattern: TextMatchPattern,
|
||||
options?: TextMatchOptions
|
||||
) {
|
||||
return container.findAll(matcher(pattern, options));
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { EventEmitter2 } from "eventemitter2";
|
||||
import { IResolvers } from "graphql-tools";
|
||||
import { noop } from "lodash";
|
||||
import path from "path";
|
||||
import React from "react";
|
||||
import TestRenderer, { ReactTestRenderer } from "react-test-renderer";
|
||||
@@ -41,12 +40,8 @@ export interface TestResolvers<T extends Resolvers = any> {
|
||||
}
|
||||
|
||||
function createNodeMock(element: React.ReactElement<any>) {
|
||||
if (element.type === "div") {
|
||||
return {
|
||||
innerHtml: "",
|
||||
className: "",
|
||||
focus: noop,
|
||||
};
|
||||
if (typeof element.type === "string") {
|
||||
return document.createElement(element.type);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ import {
|
||||
queryByTestID,
|
||||
} from "./byTestID";
|
||||
import { getAllByText, getByText, queryAllByText, queryByText } from "./byText";
|
||||
import {
|
||||
getAllByTitle,
|
||||
getByTitle,
|
||||
queryAllByTitle,
|
||||
queryByTitle,
|
||||
} from "./byTitle";
|
||||
import {
|
||||
getAllByType,
|
||||
getByType,
|
||||
@@ -70,6 +76,10 @@ export default function within(container: ReactTestInstance) {
|
||||
queryByType: applyContainer(container, queryByType),
|
||||
queryParentByType: applyContainer(container, queryParentByType),
|
||||
queryAllByType: applyContainer(container, queryAllByType),
|
||||
getByTitle: applyContainer(container, getByTitle),
|
||||
getAllByTitle: applyContainer(container, getAllByTitle),
|
||||
queryByTitle: applyContainer(container, queryByTitle),
|
||||
queryAllByTitle: applyContainer(container, queryAllByTitle),
|
||||
toJSON: applyContainer(container, toJSON),
|
||||
toHTML: applyContainer(container, toHTML),
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from "react";
|
||||
import TestRenderer from "react-test-renderer";
|
||||
|
||||
import { SPOILER_CLASSNAME } from "coral-common/constants";
|
||||
|
||||
import HTMLContent from "./HTMLContent";
|
||||
|
||||
it("renders correctly", () => {
|
||||
@@ -21,3 +23,40 @@ it("sanitizes evil html", () => {
|
||||
);
|
||||
expect(renderer.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("transform spoiler tags", () => {
|
||||
const revealed = `<span aria-hidden="true">Spoiler</span>`;
|
||||
const renderer = TestRenderer.create(
|
||||
<HTMLContent>
|
||||
{`
|
||||
<span class="${SPOILER_CLASSNAME}">Spoiler</span>
|
||||
`}
|
||||
</HTMLContent>
|
||||
);
|
||||
|
||||
// Check for transformation of unrevealed spoiler.
|
||||
const html = renderer.toTree()?.rendered?.props.dangerouslySetInnerHTML
|
||||
.__html;
|
||||
expect(html).toMatchSnapshot();
|
||||
|
||||
// Reveal on click.
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = html;
|
||||
(global as any).handleCoralSpoilerButton(div, new MouseEvent("click"));
|
||||
expect(div.innerHTML).toEqual(revealed);
|
||||
|
||||
// Not reveal on unrelated keydown.
|
||||
div.innerHTML = html;
|
||||
(global as any).handleCoralSpoilerButton(
|
||||
div,
|
||||
new KeyboardEvent("keydown", { key: "a" })
|
||||
);
|
||||
expect(div.innerHTML).toEqual(html);
|
||||
|
||||
// Reveal on enter.
|
||||
(global as any).handleCoralSpoilerButton(
|
||||
div,
|
||||
new KeyboardEvent("keydown", { key: "Enter" })
|
||||
);
|
||||
expect(div.innerHTML).toEqual(revealed);
|
||||
});
|
||||
|
||||
@@ -1,14 +1,102 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { createPurify } from "coral-common/utils/purify";
|
||||
import { SPOILER_CLASSNAME } from "coral-common/constants";
|
||||
import {
|
||||
ALL_FEATURES,
|
||||
createSanitize,
|
||||
Sanitize,
|
||||
} from "coral-common/helpers/sanitize";
|
||||
|
||||
import styles from "./HTMLContent.css";
|
||||
|
||||
/**
|
||||
* Create a purify instance that will be used to handle HTML content.
|
||||
* Sanitize html content and find spoiler tags.
|
||||
*/
|
||||
const purify = createPurify(window, false);
|
||||
const sanitizeAndFindSpoilerTags: (
|
||||
source: string | Node
|
||||
) => [HTMLElement, Element[]] = (() => {
|
||||
/** Resused instance */
|
||||
let sanitize: Sanitize | null = null;
|
||||
|
||||
/** Found spoiler tags during sanitization will be placed here. */
|
||||
let spoilerTags: Element[] = [];
|
||||
|
||||
return (source: string | Node): [HTMLElement, Element[]] => {
|
||||
if (!sanitize) {
|
||||
sanitize = createSanitize(window, {
|
||||
// Allow all rte features to be displayed.
|
||||
features: ALL_FEATURES,
|
||||
modify: (purify) => {
|
||||
// Add a hook that detects spoiler tags and adds to `spoilerTags` array
|
||||
purify.addHook("afterSanitizeAttributes", (node) => {
|
||||
if (
|
||||
node.tagName === "SPAN" &&
|
||||
node.className === SPOILER_CLASSNAME
|
||||
) {
|
||||
spoilerTags.push(node);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
const sanitized = sanitize(source);
|
||||
const ret = spoilerTags;
|
||||
spoilerTags = [];
|
||||
return [sanitized, ret];
|
||||
};
|
||||
})();
|
||||
|
||||
/**
|
||||
* Makes sure SpoilerTag Handler is registered in `global`.
|
||||
*/
|
||||
function registerSpoilerTagHandler() {
|
||||
if ("handleCoralSpoilerButton" in global) {
|
||||
return;
|
||||
}
|
||||
// Add global spoiler tag handler `handleCoralSpoilerButton`.
|
||||
(global as any).handleCoralSpoilerButton = (
|
||||
el: HTMLElement,
|
||||
event: MouseEvent | KeyboardEvent
|
||||
) => {
|
||||
if (
|
||||
"key" in event &&
|
||||
event.key !== " " &&
|
||||
event.key !== "Enter" &&
|
||||
event.key !== "Spacebar"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Turn to a dumb span and add the `reveal` css class.
|
||||
el.innerHTML = el.firstElementChild!.innerHTML;
|
||||
el.removeAttribute("onClick");
|
||||
el.removeAttribute("title");
|
||||
el.removeAttribute("tabindex");
|
||||
el.removeAttribute("role");
|
||||
el.classList.add(`${SPOILER_CLASSNAME}-reveal`);
|
||||
};
|
||||
}
|
||||
|
||||
function transform(source: string | Node) {
|
||||
// Sanitize source.
|
||||
const [sanitized, spoilerTags] = sanitizeAndFindSpoilerTags(source);
|
||||
|
||||
// Make sure spoiler tag handler exists.
|
||||
registerSpoilerTagHandler();
|
||||
|
||||
// Attach event handlers to spoiler tags.
|
||||
spoilerTags.forEach((node) => {
|
||||
node.setAttribute("onmousedown", "javascript: event.preventDefault()");
|
||||
node.setAttribute("onclick", "handleCoralSpoilerButton(this, event)");
|
||||
node.setAttribute("onkeydown", "handleCoralSpoilerButton(this, event)");
|
||||
node.setAttribute("role", "button");
|
||||
node.setAttribute("title", "Reveal spoiler");
|
||||
node.setAttribute("tabindex", "0");
|
||||
node.innerHTML = `<span aria-hidden="true">${node.innerHTML}</span>`;
|
||||
});
|
||||
// Return results.
|
||||
return sanitized.innerHTML;
|
||||
}
|
||||
|
||||
interface HTMLContentProps {
|
||||
children: string;
|
||||
@@ -21,7 +109,7 @@ const HTMLContent: FunctionComponent<HTMLContentProps> = ({
|
||||
}) => (
|
||||
<div
|
||||
className={cn(styles.root, className)}
|
||||
dangerouslySetInnerHTML={{ __html: purify.sanitize(children) }}
|
||||
dangerouslySetInnerHTML={{ __html: transform(children) }}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -23,3 +23,9 @@ exports[`sanitizes evil html 1`] = `
|
||||
}
|
||||
/>
|
||||
`;
|
||||
|
||||
exports[`transform spoiler tags 1`] = `
|
||||
"
|
||||
<span class=\\"coral-rte-spoiler\\" onmousedown=\\"javascript: event.preventDefault()\\" onclick=\\"handleCoralSpoilerButton(this, event)\\" onkeydown=\\"handleCoralSpoilerButton(this, event)\\" role=\\"button\\" title=\\"Reveal spoiler\\" tabindex=\\"0\\"><span aria-hidden=\\"true\\">Spoiler</span></span>
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -30,4 +30,17 @@
|
||||
color: var(--palette-primary-dark);
|
||||
}
|
||||
}
|
||||
& :global(.coral-rte-spoiler) {
|
||||
background-color: var(--palette-text-dark);
|
||||
transition: background 300ms;
|
||||
border-radius: var(--round-corners);
|
||||
color: rgba(0, 0, 0, 0);
|
||||
cursor: pointer;
|
||||
}
|
||||
& :global(.coral-rte-spoiler-reveal) {
|
||||
opacity: 1.0;
|
||||
color: var(--palette-text-dark);
|
||||
background-color: transparent;
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,14 @@ import {
|
||||
MessageIcon,
|
||||
RelativeTime,
|
||||
} from "coral-ui/components";
|
||||
import { PropTypesOf } from "coral-ui/types";
|
||||
|
||||
import { getCommentBodyValidators, normalizeRTEHTML } from "../../helpers";
|
||||
import {
|
||||
getCommentBodyValidators,
|
||||
getHTMLCharacterLength,
|
||||
} from "../../helpers";
|
||||
import RemainingCharactersContainer from "../../RemainingCharacters";
|
||||
import RTE from "../../RTE";
|
||||
import RTEContainer from "../../RTE";
|
||||
import TopBarLeft from "../TopBarLeft";
|
||||
import Username from "../Username";
|
||||
|
||||
@@ -45,6 +49,7 @@ export interface EditCommentFormProps {
|
||||
expired?: boolean;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
rteConfig: PropTypesOf<typeof RTEContainer>["config"];
|
||||
}
|
||||
|
||||
const EditCommentForm: FunctionComponent<EditCommentFormProps> = (props) => {
|
||||
@@ -85,14 +90,13 @@ const EditCommentForm: FunctionComponent<EditCommentFormProps> = (props) => {
|
||||
id="comments-editCommentForm-rte"
|
||||
attrs={{ placeholder: true }}
|
||||
>
|
||||
<RTE
|
||||
inputId={inputID}
|
||||
onChange={({ html }) =>
|
||||
input.onChange(normalizeRTEHTML(html))
|
||||
}
|
||||
<RTEContainer
|
||||
config={props.rteConfig}
|
||||
inputID={inputID}
|
||||
onChange={(html) => input.onChange(html)}
|
||||
value={input.value}
|
||||
placeholder="Edit comment"
|
||||
forwardRef={props.rteRef}
|
||||
ref={props.rteRef}
|
||||
disabled={submitting || props.expired}
|
||||
/>
|
||||
</Localized>
|
||||
@@ -180,7 +184,9 @@ const EditCommentForm: FunctionComponent<EditCommentFormProps> = (props) => {
|
||||
color="primary"
|
||||
variant="filled"
|
||||
disabled={
|
||||
submitting || !input.value || pristine
|
||||
submitting ||
|
||||
getHTMLCharacterLength(input.value) === 0 ||
|
||||
pristine
|
||||
}
|
||||
type="submit"
|
||||
fullWidth={matches}
|
||||
|
||||
+4
@@ -134,6 +134,7 @@ export class EditCommentFormContainer extends Component<Props, State> {
|
||||
return (
|
||||
<EditCommentForm
|
||||
id={this.props.comment.id}
|
||||
rteConfig={this.props.settings.rte}
|
||||
onSubmit={this.handleOnSubmit}
|
||||
initialValues={this.intitialValues}
|
||||
onCancel={this.handleOnCancelOrClose}
|
||||
@@ -189,6 +190,9 @@ const enhanced = withContext(({ sessionStorage, browserInfo }) => ({
|
||||
min
|
||||
max
|
||||
}
|
||||
rte {
|
||||
...RTEContainer_config
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(EditCommentFormContainer)
|
||||
|
||||
@@ -23,10 +23,14 @@ import {
|
||||
HorizontalGutter,
|
||||
MatchMedia,
|
||||
} from "coral-ui/components";
|
||||
import { PropTypesOf } from "coral-ui/types";
|
||||
|
||||
import { getCommentBodyValidators, normalizeRTEHTML } from "../../helpers";
|
||||
import {
|
||||
getCommentBodyValidators,
|
||||
getHTMLCharacterLength,
|
||||
} from "../../helpers";
|
||||
import RemainingCharactersContainer from "../../RemainingCharacters";
|
||||
import RTE from "../../RTE";
|
||||
import RTEContainer from "../../RTE";
|
||||
import ReplyTo from "./ReplyTo";
|
||||
|
||||
interface FormProps {
|
||||
@@ -46,6 +50,7 @@ export interface ReplyCommentFormProps {
|
||||
max: number | null;
|
||||
disabled?: boolean;
|
||||
disabledMessage?: React.ReactNode;
|
||||
rteConfig: PropTypesOf<typeof RTEContainer>["config"];
|
||||
}
|
||||
|
||||
const ReplyCommentForm: FunctionComponent<ReplyCommentFormProps> = (props) => {
|
||||
@@ -88,15 +93,14 @@ const ReplyCommentForm: FunctionComponent<ReplyCommentFormProps> = (props) => {
|
||||
id="comments-replyCommentForm-rte"
|
||||
attrs={{ placeholder: true }}
|
||||
>
|
||||
<RTE
|
||||
inputId={inputID}
|
||||
<RTEContainer
|
||||
config={props.rteConfig}
|
||||
inputID={inputID}
|
||||
onFocus={onFocus}
|
||||
onChange={({ html }) =>
|
||||
input.onChange(normalizeRTEHTML(html))
|
||||
}
|
||||
onChange={(html) => input.onChange(html)}
|
||||
value={input.value}
|
||||
placeholder="Write a reply"
|
||||
forwardRef={props.rteRef}
|
||||
ref={props.rteRef}
|
||||
disabled={submitting || props.disabled}
|
||||
/>
|
||||
</Localized>
|
||||
@@ -157,7 +161,9 @@ const ReplyCommentForm: FunctionComponent<ReplyCommentFormProps> = (props) => {
|
||||
color="primary"
|
||||
variant="filled"
|
||||
disabled={
|
||||
submitting || !input.value || props.disabled
|
||||
submitting ||
|
||||
getHTMLCharacterLength(input.value) === 0 ||
|
||||
props.disabled
|
||||
}
|
||||
type="submit"
|
||||
className={CLASSES.createReplyComment.submit}
|
||||
|
||||
+3
-1
@@ -55,6 +55,7 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
|
||||
enabled: false,
|
||||
message: "",
|
||||
},
|
||||
rte: {},
|
||||
},
|
||||
},
|
||||
add
|
||||
@@ -106,7 +107,7 @@ it("save values", async () => {
|
||||
|
||||
it("creates a comment", async () => {
|
||||
const storyID = "story-id";
|
||||
const input = { body: "Hello World!", local: false };
|
||||
const input = { body: "Hello World!" };
|
||||
const createCommentStub = sinon.stub().returns({ edge: { node: {} } });
|
||||
const form = { reset: noop };
|
||||
const onCloseStub = sinon.stub();
|
||||
@@ -131,6 +132,7 @@ it("creates a comment", async () => {
|
||||
parentID: props.comment.id,
|
||||
parentRevisionID: "revision-id",
|
||||
nudge: true,
|
||||
local: undefined,
|
||||
...input,
|
||||
})
|
||||
).toBeTruthy();
|
||||
|
||||
+5
-1
@@ -114,7 +114,7 @@ export class ReplyCommentFormContainer extends Component<Props, State> {
|
||||
parentRevisionID: this.props.comment.revision!.id,
|
||||
local: this.props.localReply,
|
||||
nudge: this.state.nudge,
|
||||
...input,
|
||||
body: input.body,
|
||||
})
|
||||
);
|
||||
if (submitStatus !== "RETRY") {
|
||||
@@ -176,6 +176,7 @@ export class ReplyCommentFormContainer extends Component<Props, State> {
|
||||
return (
|
||||
<ReplyCommentForm
|
||||
id={this.props.comment.id}
|
||||
rteConfig={this.props.settings.rte}
|
||||
onSubmit={this.handleOnSubmit}
|
||||
onChange={this.handleOnChange}
|
||||
initialValues={this.state.initialValues}
|
||||
@@ -229,6 +230,9 @@ const enhanced = withContext(({ sessionStorage, browserInfo }) => ({
|
||||
closeCommenting {
|
||||
message
|
||||
}
|
||||
rte {
|
||||
...RTEContainer_config
|
||||
}
|
||||
}
|
||||
`,
|
||||
story: graphql`
|
||||
|
||||
+4
@@ -11,6 +11,7 @@ exports[`renders correctly 1`] = `
|
||||
onChange={[Function]}
|
||||
onSubmit={[Function]}
|
||||
parentUsername="Joe"
|
||||
rteConfig={Object {}}
|
||||
rteRef={[Function]}
|
||||
/>
|
||||
`;
|
||||
@@ -26,6 +27,7 @@ exports[`renders when commenting has been disabled 1`] = `
|
||||
onChange={[Function]}
|
||||
onSubmit={[Function]}
|
||||
parentUsername="Joe"
|
||||
rteConfig={Object {}}
|
||||
rteRef={[Function]}
|
||||
/>
|
||||
`;
|
||||
@@ -41,6 +43,7 @@ exports[`renders when story has been closed 1`] = `
|
||||
onChange={[Function]}
|
||||
onSubmit={[Function]}
|
||||
parentUsername="Joe"
|
||||
rteConfig={Object {}}
|
||||
rteRef={[Function]}
|
||||
/>
|
||||
`;
|
||||
@@ -61,6 +64,7 @@ exports[`renders with initialValues 1`] = `
|
||||
onChange={[Function]}
|
||||
onSubmit={[Function]}
|
||||
parentUsername="Joe"
|
||||
rteConfig={Object {}}
|
||||
rteRef={[Function]}
|
||||
/>
|
||||
`;
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
.content {
|
||||
composes: root from "~coral-stream/shared/htmlContent.css";
|
||||
border: 1px solid var(--palette-grey-main);
|
||||
|
||||
& :global(.coral-rte-spoiler) {
|
||||
background-color: black;
|
||||
color: white;
|
||||
padding: 0 2px;
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
background-color: var(--palette-common-white);
|
||||
border: 1px solid var(--palette-grey-main);
|
||||
}
|
||||
.toolbarHidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
composes: placeholder from "~coral-ui/shared/typography.css";
|
||||
|
||||
@@ -1,35 +1,83 @@
|
||||
import { Blockquote, Bold, CoralRTE, Italic } from "@coralproject/rte";
|
||||
import {
|
||||
Blockquote,
|
||||
Bold,
|
||||
CoralRTE,
|
||||
Italic,
|
||||
Spoiler,
|
||||
Strike,
|
||||
UnorderedList,
|
||||
} from "@coralproject/rte";
|
||||
import { Localized as LocalizedOriginal } from "@fluent/react/compat";
|
||||
import cn from "classnames";
|
||||
import React, { EventHandler, FocusEvent, FunctionComponent, Ref } from "react";
|
||||
import React, {
|
||||
EventHandler,
|
||||
FocusEvent,
|
||||
FunctionComponent,
|
||||
Ref,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
import { createSanitize } from "coral-common/helpers/sanitize";
|
||||
import CLASSES from "coral-stream/classes";
|
||||
import { Icon } from "coral-ui/components";
|
||||
import { PropTypesOf } from "coral-ui/types";
|
||||
|
||||
import styles from "./RTE.css";
|
||||
|
||||
interface RTEFeatures {
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
blockquote?: boolean;
|
||||
strikethrough?: boolean;
|
||||
bulletList?: boolean;
|
||||
spoiler?: boolean;
|
||||
}
|
||||
|
||||
const createSanitizeToDOMFragment = (features: RTEFeatures = {}) => {
|
||||
/** Resused Sanitize instance */
|
||||
const sanitize = createSanitize(window, {
|
||||
features: {
|
||||
bold: features.bold,
|
||||
italic: features.italic,
|
||||
blockquote: features.blockquote,
|
||||
bulletList: features.bulletList,
|
||||
strikethrough: features.strikethrough,
|
||||
spoiler: features.spoiler,
|
||||
},
|
||||
});
|
||||
return (html: string) => {
|
||||
const frag = document.createDocumentFragment();
|
||||
if (html) {
|
||||
const sanitized = sanitize(html);
|
||||
while (sanitized.firstChild) {
|
||||
frag.appendChild(sanitized.firstChild);
|
||||
}
|
||||
}
|
||||
return frag;
|
||||
};
|
||||
};
|
||||
|
||||
// Use a special Localized version that forwards
|
||||
// ref and passes the api prop to the children.
|
||||
// This is currently required in order for the RTE
|
||||
// to detect and setup the features.
|
||||
const Localized = React.forwardRef<any, PropTypesOf<typeof LocalizedOriginal>>(
|
||||
function RTELocalized({ api, ...props }, ref) {
|
||||
function RTELocalized({ ctrlKey, squire, ...props }, ref) {
|
||||
return (
|
||||
<LocalizedOriginal {...props}>
|
||||
{React.cloneElement(
|
||||
React.Children.only(props.children as React.ReactElement),
|
||||
{ api, ref }
|
||||
{ ctrlKey, squire, ref }
|
||||
)}
|
||||
</LocalizedOriginal>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export interface RTEProps {
|
||||
inputId?: string;
|
||||
interface Props {
|
||||
inputID?: string;
|
||||
/**
|
||||
* The content value of the component.
|
||||
* The default content value of the component.
|
||||
*/
|
||||
defaultValue?: string;
|
||||
/**
|
||||
@@ -66,43 +114,23 @@ export interface RTEProps {
|
||||
/**
|
||||
* onChange
|
||||
*/
|
||||
onChange?: (data: { html: string; text: string }) => void;
|
||||
onChange?: (html: string) => void;
|
||||
onFocus?: EventHandler<FocusEvent>;
|
||||
onBlur?: EventHandler<FocusEvent>;
|
||||
|
||||
disabled?: boolean;
|
||||
|
||||
forwardRef?: Ref<CoralRTE>;
|
||||
|
||||
features?: RTEFeatures;
|
||||
}
|
||||
|
||||
const features = [
|
||||
<Localized key="bold" id="comments-rte-bold" attrs={{ title: true }}>
|
||||
<Bold>
|
||||
<Icon size="md">format_bold</Icon>
|
||||
</Bold>
|
||||
</Localized>,
|
||||
<Localized key="italic" id="comments-rte-italic" attrs={{ title: true }}>
|
||||
<Italic>
|
||||
<Icon size="md">format_italic</Icon>
|
||||
</Italic>
|
||||
</Localized>,
|
||||
<Localized
|
||||
key="blockquote"
|
||||
id="comments-rte-blockquote"
|
||||
attrs={{ title: true }}
|
||||
>
|
||||
<Blockquote key="blockquote">
|
||||
<Icon size="md">format_quote</Icon>
|
||||
</Blockquote>
|
||||
</Localized>,
|
||||
];
|
||||
|
||||
const RTE: FunctionComponent<RTEProps> = (props) => {
|
||||
const RTE: FunctionComponent<Props> = (props) => {
|
||||
const {
|
||||
className,
|
||||
fullWidth,
|
||||
value,
|
||||
inputId,
|
||||
inputID,
|
||||
placeholder,
|
||||
onChange,
|
||||
disabled,
|
||||
@@ -113,12 +141,91 @@ const RTE: FunctionComponent<RTEProps> = (props) => {
|
||||
toolbarClassName,
|
||||
onFocus,
|
||||
onBlur,
|
||||
features,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const sanitizeToDOMFragment = useMemo(() => {
|
||||
return createSanitizeToDOMFragment(features);
|
||||
}, [features]);
|
||||
|
||||
const featureElements = useMemo(() => {
|
||||
const x = [];
|
||||
if (features?.bold) {
|
||||
x.push(
|
||||
<Localized key="bold" id="comments-rte-bold" attrs={{ title: true }}>
|
||||
<Bold>
|
||||
<Icon size="md">format_bold</Icon>
|
||||
</Bold>
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
if (features?.italic) {
|
||||
x.push(
|
||||
<Localized
|
||||
key="italic"
|
||||
id="comments-rte-italic"
|
||||
attrs={{ title: true }}
|
||||
>
|
||||
<Italic>
|
||||
<Icon size="md">format_italic</Icon>
|
||||
</Italic>
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
if (features?.blockquote) {
|
||||
x.push(
|
||||
<Localized
|
||||
key="blockquote"
|
||||
id="comments-rte-blockquote"
|
||||
attrs={{ title: true }}
|
||||
>
|
||||
<Blockquote>
|
||||
<Icon size="md">format_quote</Icon>
|
||||
</Blockquote>
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
if (features?.bulletList) {
|
||||
x.push(
|
||||
<Localized
|
||||
key="bulletedList"
|
||||
id="comments-rte-bulletedList"
|
||||
attrs={{ title: true }}
|
||||
>
|
||||
<UnorderedList>
|
||||
<Icon size="md">format_list_bulleted</Icon>
|
||||
</UnorderedList>
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
if (features?.strikethrough) {
|
||||
x.push(
|
||||
<Localized
|
||||
key="strikethrough"
|
||||
id="comments-rte-strikethrough"
|
||||
attrs={{ title: true }}
|
||||
>
|
||||
<Strike>
|
||||
<Icon size="md">strikethrough_s</Icon>
|
||||
</Strike>
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
if (features?.spoiler) {
|
||||
x.push(
|
||||
<Localized key="spoiler" id="comments-rte-spoiler">
|
||||
<Spoiler>Spoiler</Spoiler>
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
return x;
|
||||
}, [features]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CoralRTE
|
||||
inputId={inputId}
|
||||
inputID={inputID}
|
||||
className={cn(CLASSES.rte, className)}
|
||||
contentClassName={cn(
|
||||
CLASSES.rte.content,
|
||||
@@ -133,17 +240,21 @@ const RTE: FunctionComponent<RTEProps> = (props) => {
|
||||
toolbarClassName={cn(
|
||||
CLASSES.rte.toolbar,
|
||||
toolbarClassName,
|
||||
styles.toolbar
|
||||
styles.toolbar,
|
||||
{
|
||||
[styles.toolbarHidden]: featureElements.length === 0,
|
||||
}
|
||||
)}
|
||||
onChange={onChange}
|
||||
value={value || defaultValue}
|
||||
value={value || defaultValue || "<div><br></div>"}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
features={features}
|
||||
features={featureElements}
|
||||
ref={forwardRef}
|
||||
toolbarPosition="bottom"
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
sanitizeToDOMFragment={sanitizeToDOMFragment}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { CoralRTE } from "@coralproject/rte";
|
||||
import React, { Ref } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { convertGQLRTEConfigToRTEFeatures } from "coral-common/helpers/sanitize";
|
||||
import { withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { withForwardRef } from "coral-ui/hocs";
|
||||
import { PropTypesOf } from "coral-ui/types";
|
||||
|
||||
import { RTEContainer_config } from "coral-stream/__generated__/RTEContainer_config.graphql";
|
||||
|
||||
import RTE from "./RTE";
|
||||
|
||||
interface Props extends Omit<PropTypesOf<typeof RTE>, "ref"> {
|
||||
forwardRef: Ref<CoralRTE>;
|
||||
config: RTEContainer_config;
|
||||
}
|
||||
|
||||
const RTEContainer: React.FunctionComponent<Props> = ({ config, ...rest }) => {
|
||||
const features: Partial<
|
||||
PropTypesOf<typeof RTE>["features"]
|
||||
> = convertGQLRTEConfigToRTEFeatures(config);
|
||||
return <RTE features={features} {...rest} />;
|
||||
};
|
||||
|
||||
const enhanced = withForwardRef(
|
||||
withFragmentContainer<Props>({
|
||||
config: graphql`
|
||||
fragment RTEContainer_config on RTEConfiguration {
|
||||
enabled
|
||||
strikethrough
|
||||
spoiler
|
||||
}
|
||||
`,
|
||||
})(RTEContainer)
|
||||
);
|
||||
export default enhanced;
|
||||
@@ -9,62 +9,13 @@ exports[`renders correctly 1`] = `
|
||||
contentClassNameDisabled=""
|
||||
contentContainerClassName=""
|
||||
contentContainerClassNameDisabled=""
|
||||
features={
|
||||
Array [
|
||||
<ForwardRef(RTELocalized)
|
||||
attrs={
|
||||
Object {
|
||||
"title": true,
|
||||
}
|
||||
}
|
||||
id="comments-rte-bold"
|
||||
>
|
||||
<Toggle>
|
||||
<ForwardRef(forwardRef)
|
||||
size="md"
|
||||
>
|
||||
format_bold
|
||||
</ForwardRef(forwardRef)>
|
||||
</Toggle>
|
||||
</ForwardRef(RTELocalized)>,
|
||||
<ForwardRef(RTELocalized)
|
||||
attrs={
|
||||
Object {
|
||||
"title": true,
|
||||
}
|
||||
}
|
||||
id="comments-rte-italic"
|
||||
>
|
||||
<Toggle>
|
||||
<ForwardRef(forwardRef)
|
||||
size="md"
|
||||
>
|
||||
format_italic
|
||||
</ForwardRef(forwardRef)>
|
||||
</Toggle>
|
||||
</ForwardRef(RTELocalized)>,
|
||||
<ForwardRef(RTELocalized)
|
||||
attrs={
|
||||
Object {
|
||||
"title": true,
|
||||
}
|
||||
}
|
||||
id="comments-rte-blockquote"
|
||||
>
|
||||
<Toggle>
|
||||
<ForwardRef(forwardRef)
|
||||
size="md"
|
||||
>
|
||||
format_quote
|
||||
</ForwardRef(forwardRef)>
|
||||
</Toggle>
|
||||
</ForwardRef(RTELocalized)>,
|
||||
]
|
||||
}
|
||||
features={Array []}
|
||||
placeholder="Post a comment"
|
||||
placeholderClassName="coral coral-rte-placeholder RTE-placeholder"
|
||||
placeholderClassNameDisabled=""
|
||||
toolbarClassName="coral coral-rte-toolbar RTE-toolbar"
|
||||
sanitizeToDOMFragment={[Function]}
|
||||
sanitizeValue={true}
|
||||
toolbarClassName="coral coral-rte-toolbar RTE-toolbar RTE-toolbarHidden"
|
||||
toolbarClassNameDisabled=""
|
||||
toolbarPosition="bottom"
|
||||
value="Hello world"
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default } from "./RTE";
|
||||
export { default as RTE } from "./RTE";
|
||||
export { default } from "./RTEContainer";
|
||||
|
||||
@@ -13,9 +13,12 @@ import ValidationMessage from "coral-stream/common/ValidationMessage";
|
||||
import { CreateCommentFocusEvent } from "coral-stream/events";
|
||||
import { AriaInfo, Button, Flex, HorizontalGutter } from "coral-ui/components";
|
||||
|
||||
import { getCommentBodyValidators, normalizeRTEHTML } from "../../helpers";
|
||||
import {
|
||||
getCommentBodyValidators,
|
||||
getHTMLCharacterLength,
|
||||
} from "../../helpers";
|
||||
import RemainingCharactersContainer from "../../RemainingCharacters";
|
||||
import RTE from "../../RTE";
|
||||
import RTEContainer from "../../RTE";
|
||||
import MessageBoxContainer from "../MessageBoxContainer";
|
||||
import PostCommentSubmitStatusContainer from "./PostCommentSubmitStatusContainer";
|
||||
|
||||
@@ -44,6 +47,7 @@ interface Props {
|
||||
submitStatus: PropTypesOf<PostCommentSubmitStatusContainer>["status"];
|
||||
showMessageBox?: boolean;
|
||||
story: PropTypesOf<typeof MessageBoxContainer>["story"] & StorySettings;
|
||||
rteConfig: PropTypesOf<typeof RTEContainer>["config"];
|
||||
}
|
||||
|
||||
const PostCommentForm: FunctionComponent<Props> = (props) => {
|
||||
@@ -108,12 +112,11 @@ const PostCommentForm: FunctionComponent<Props> = (props) => {
|
||||
}
|
||||
attrs={{ placeholder: true }}
|
||||
>
|
||||
<RTE
|
||||
inputId="comments-postCommentForm-field"
|
||||
<RTEContainer
|
||||
inputID="comments-postCommentForm-field"
|
||||
config={props.rteConfig}
|
||||
onFocus={onFocus}
|
||||
onChange={({ html }) =>
|
||||
input.onChange(normalizeRTEHTML(html))
|
||||
}
|
||||
onChange={(html) => input.onChange(html)}
|
||||
contentClassName={
|
||||
props.showMessageBox
|
||||
? styles.rteBorderless
|
||||
@@ -166,7 +169,9 @@ const PostCommentForm: FunctionComponent<Props> = (props) => {
|
||||
variant="filled"
|
||||
className={CLASSES.createComment.submit}
|
||||
disabled={
|
||||
submitting || !input.value || props.disabled
|
||||
submitting ||
|
||||
getHTMLCharacterLength(input.value) === 0 ||
|
||||
props.disabled
|
||||
}
|
||||
type="submit"
|
||||
>
|
||||
|
||||
+5
@@ -47,6 +47,11 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
|
||||
enabled: false,
|
||||
message: "",
|
||||
},
|
||||
rte: {
|
||||
enabled: true,
|
||||
strikethrough: false,
|
||||
spoiler: false,
|
||||
},
|
||||
},
|
||||
viewer: {
|
||||
id: "viewer-id",
|
||||
|
||||
+6
-1
@@ -130,7 +130,7 @@ export class PostCommentFormContainer extends Component<Props, State> {
|
||||
storyID: this.props.story.id,
|
||||
nudge: this.state.nudge,
|
||||
commentsOrderBy: this.props.commentsOrderBy,
|
||||
...input,
|
||||
body: input.body,
|
||||
})
|
||||
);
|
||||
if (submitStatus !== "RETRY") {
|
||||
@@ -211,6 +211,7 @@ export class PostCommentFormContainer extends Component<Props, State> {
|
||||
if (!this.props.viewer) {
|
||||
return (
|
||||
<PostCommentFormFake
|
||||
rteConfig={this.props.settings.rte}
|
||||
draft={this.state.notLoggedInDraft}
|
||||
onDraftChange={this.handleDraftChange}
|
||||
story={this.props.story}
|
||||
@@ -231,6 +232,7 @@ export class PostCommentFormContainer extends Component<Props, State> {
|
||||
onSubmit={this.handleOnSubmit}
|
||||
onChange={this.handleOnChange}
|
||||
initialValues={this.state.initialValues}
|
||||
rteConfig={this.props.settings.rte}
|
||||
min={
|
||||
(this.props.settings.charCount.enabled &&
|
||||
this.props.settings.charCount.min) ||
|
||||
@@ -285,6 +287,9 @@ const enhanced = withContext(({ sessionStorage }) => ({
|
||||
closeCommenting {
|
||||
message
|
||||
}
|
||||
rte {
|
||||
...RTEContainer_config
|
||||
}
|
||||
}
|
||||
`,
|
||||
story: graphql`
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ it("renders correctly", () => {
|
||||
const renderer = createRenderer();
|
||||
renderer.render(
|
||||
<PostCommentFormFakeN
|
||||
rteConfig={{}}
|
||||
story={{}}
|
||||
draft=""
|
||||
onDraftChange={noop}
|
||||
|
||||
@@ -9,7 +9,7 @@ import CLASSES from "coral-stream/classes";
|
||||
import { CreateCommentFocusEvent } from "coral-stream/events";
|
||||
import { Button, HorizontalGutter } from "coral-ui/components";
|
||||
|
||||
import RTE from "../../RTE";
|
||||
import RTEContainer from "../../RTE";
|
||||
import MessageBoxContainer from "../MessageBoxContainer";
|
||||
|
||||
import styles from "./PostCommentFormFake.css";
|
||||
@@ -26,6 +26,7 @@ interface Props {
|
||||
draft: string;
|
||||
onDraftChange: (draft: string) => void;
|
||||
onSignIn: () => void;
|
||||
rteConfig: PropTypesOf<typeof RTEContainer>["config"];
|
||||
}
|
||||
|
||||
const PostCommentFormFake: FunctionComponent<Props> = (props) => {
|
||||
@@ -33,10 +34,9 @@ const PostCommentFormFake: FunctionComponent<Props> = (props) => {
|
||||
const onFocus = useCallback(() => {
|
||||
emitFocusEvent();
|
||||
}, [emitFocusEvent]);
|
||||
const onChange = useCallback(
|
||||
(data: { html: string; text: string }) => props.onDraftChange(data.html),
|
||||
[props.onDraftChange]
|
||||
);
|
||||
const onChange = useCallback((html: string) => props.onDraftChange(html), [
|
||||
props.onDraftChange,
|
||||
]);
|
||||
const isQA =
|
||||
props.story.settings && props.story.settings.mode === GQLSTORY_MODE.QA;
|
||||
return (
|
||||
@@ -57,7 +57,8 @@ const PostCommentFormFake: FunctionComponent<Props> = (props) => {
|
||||
}
|
||||
attrs={{ placeholder: true }}
|
||||
>
|
||||
<RTE
|
||||
<RTEContainer
|
||||
config={props.rteConfig}
|
||||
placeholder={isQA ? "Post a question" : "Post a comment"}
|
||||
value={props.draft}
|
||||
onChange={onChange}
|
||||
|
||||
+35
@@ -13,6 +13,13 @@ exports[`renders correctly 1`] = `
|
||||
min={3}
|
||||
onChange={[Function]}
|
||||
onSubmit={[Function]}
|
||||
rteConfig={
|
||||
Object {
|
||||
"enabled": true,
|
||||
"spoiler": false,
|
||||
"strikethrough": false,
|
||||
}
|
||||
}
|
||||
showMessageBox={false}
|
||||
story={
|
||||
Object {
|
||||
@@ -62,6 +69,13 @@ exports[`renders when commenting has been disabled (non-collapsing) 1`] = `
|
||||
min={3}
|
||||
onChange={[Function]}
|
||||
onSubmit={[Function]}
|
||||
rteConfig={
|
||||
Object {
|
||||
"enabled": true,
|
||||
"spoiler": false,
|
||||
"strikethrough": false,
|
||||
}
|
||||
}
|
||||
showMessageBox={false}
|
||||
story={
|
||||
Object {
|
||||
@@ -111,6 +125,13 @@ exports[`renders when story has been closed (non-collapsing) 1`] = `
|
||||
min={3}
|
||||
onChange={[Function]}
|
||||
onSubmit={[Function]}
|
||||
rteConfig={
|
||||
Object {
|
||||
"enabled": true,
|
||||
"spoiler": false,
|
||||
"strikethrough": false,
|
||||
}
|
||||
}
|
||||
showMessageBox={false}
|
||||
story={
|
||||
Object {
|
||||
@@ -147,6 +168,13 @@ exports[`renders when user is scheduled to be deleted 1`] = `
|
||||
min={3}
|
||||
onChange={[Function]}
|
||||
onSubmit={[Function]}
|
||||
rteConfig={
|
||||
Object {
|
||||
"enabled": true,
|
||||
"spoiler": false,
|
||||
"strikethrough": false,
|
||||
}
|
||||
}
|
||||
showMessageBox={false}
|
||||
story={
|
||||
Object {
|
||||
@@ -177,6 +205,13 @@ exports[`renders with initialValues 1`] = `
|
||||
min={3}
|
||||
onChange={[Function]}
|
||||
onSubmit={[Function]}
|
||||
rteConfig={
|
||||
Object {
|
||||
"enabled": true,
|
||||
"spoiler": false,
|
||||
"strikethrough": false,
|
||||
}
|
||||
}
|
||||
showMessageBox={false}
|
||||
story={
|
||||
Object {
|
||||
|
||||
+2
-1
@@ -20,7 +20,8 @@ exports[`renders correctly 1`] = `
|
||||
}
|
||||
id="comments-postCommentFormFake-rte"
|
||||
>
|
||||
<RTE
|
||||
<ForwardRef(forwardRef)
|
||||
config={Object {}}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
placeholder="Post a comment"
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import getHTMLText from "./getHTMLText";
|
||||
import getHTMLPlainText from "coral-common/helpers/getHTMLPlainText";
|
||||
|
||||
/**
|
||||
* getHTMLCharacterLength will strip all tags and return remaining
|
||||
* character length.
|
||||
* getHTMLCharacterLength will return current character length.
|
||||
*
|
||||
* @param html the html which length should be determined
|
||||
*/
|
||||
@@ -10,6 +9,5 @@ export default function getHTMLCharacterLength(html: string | undefined) {
|
||||
if (!html) {
|
||||
return 0;
|
||||
}
|
||||
const innerText = getHTMLText(html);
|
||||
return innerText.trim().replace(/\n/g, "").length;
|
||||
return getHTMLPlainText(html).length;
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* getHTMLText returns text representation of html.
|
||||
* Includes a different implementation during test that works.
|
||||
*
|
||||
* @param html string
|
||||
*/
|
||||
export default function getHTMLText(html: string) {
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
// innerText is not implement in JSDOM, so we use `striptags` instead.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const striptags = require("striptags");
|
||||
return striptags(html.replace(/<br *\/?>/, "\n"));
|
||||
}
|
||||
const divElement = document.createElement("div");
|
||||
divElement.innerHTML = html;
|
||||
return divElement.innerText;
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
export { default as getHTMLCharacterLength } from "./getHTMLCharacterLength";
|
||||
export { default as getCommentBodyValidators } from "./getCommentBodyValidators";
|
||||
export { default as normalizeRTEHTML } from "./normalizeRTEHTML";
|
||||
export { default as shouldTriggerSettingsRefresh } from "./shouldTriggerSettingsRefresh";
|
||||
export { default as getHTMLText } from "./getHTMLText";
|
||||
export { default as getSubmitStatus, SubmitStatus } from "./getSubmitStatus";
|
||||
export { default as incrementStoryCommentCounts } from "./incrementStoryCommentCounts";
|
||||
export { default as isInReview } from "./isInReview";
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import getHTMLText from "./getHTMLText";
|
||||
|
||||
/**
|
||||
* cleanupRTEEmptyHTML will try to figure out if given html only contains
|
||||
* dead tags like `<b></b>` or `<br />` or `<i></i><br />` which basically
|
||||
* means renders nothing and return a standardized `""` instead.
|
||||
*
|
||||
* @param html the html to be cleaned up
|
||||
*/
|
||||
function cleanupRTEEmptyHTML(html: string) {
|
||||
if (html.includes("blockquote")) {
|
||||
return html;
|
||||
}
|
||||
const innerText = getHTMLText(html);
|
||||
if (
|
||||
(innerText !== "\n" && innerText.includes("\n")) ||
|
||||
innerText.trim() !== ""
|
||||
) {
|
||||
return html;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export default function normalizeRTEHTML(html: string) {
|
||||
// IE11 uses strong and em instead of b and i.
|
||||
html = html.replace("<strong>", "<b>");
|
||||
html = html.replace("</strong>", "</b>");
|
||||
html = html.replace("<em>", "<i>");
|
||||
html = html.replace("</em>", "</i>");
|
||||
return cleanupRTEEmptyHTML(html);
|
||||
}
|
||||
+21
-21
@@ -96,38 +96,24 @@ exports[`renders comment stream 1`] = `
|
||||
onFocus={[Function]}
|
||||
>
|
||||
<div
|
||||
className="RTE-contentEditableContainer"
|
||||
className="RTE.module-contentEditableContainer"
|
||||
>
|
||||
<div
|
||||
aria-placeholder="Post a comment"
|
||||
className="RTE-contentEditable coral coral-rte-content RTE-content"
|
||||
contentEditable={true}
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "",
|
||||
}
|
||||
}
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onCut={[Function]}
|
||||
onFocus={[Function]}
|
||||
onInput={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onPaste={[Function]}
|
||||
onSelect={[Function]}
|
||||
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="RTE-placeholder coral coral-rte-placeholder RTE-placeholder"
|
||||
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
|
||||
>
|
||||
Post a comment
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
|
||||
>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bold"
|
||||
@@ -141,7 +127,7 @@ exports[`renders comment stream 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Italic"
|
||||
@@ -155,7 +141,7 @@ exports[`renders comment stream 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Blockquote"
|
||||
@@ -168,6 +154,20 @@ exports[`renders comment stream 1`] = `
|
||||
format_quote
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bulleted List"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md"
|
||||
>
|
||||
format_list_bulleted
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -126,7 +126,7 @@ it("post a reply", async () => {
|
||||
expect(await within(form).axe()).toHaveNoViolations();
|
||||
|
||||
// Write reply .
|
||||
act(() => rte.props.onChange({ html: "<b>Hello world!</b>" }));
|
||||
act(() => rte.props.onChange("<b>Hello world!</b>"));
|
||||
act(() => {
|
||||
form.props.onSubmit();
|
||||
});
|
||||
|
||||
+80
-84
@@ -306,34 +306,19 @@ exports[`edit a comment and handle server error: edit form 1`] = `
|
||||
onFocus={[Function]}
|
||||
>
|
||||
<div
|
||||
className="RTE-contentEditableContainer"
|
||||
className="RTE.module-contentEditableContainer"
|
||||
>
|
||||
<div
|
||||
aria-placeholder="Edit comment"
|
||||
className="RTE-contentEditable coral coral-rte-content RTE-content"
|
||||
contentEditable={true}
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "I like yoghurt",
|
||||
}
|
||||
}
|
||||
disabled={false}
|
||||
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
|
||||
id="comments-editCommentForm-rte-comment-with-replies"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onCut={[Function]}
|
||||
onFocus={[Function]}
|
||||
onInput={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onPaste={[Function]}
|
||||
onSelect={[Function]}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
|
||||
>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bold"
|
||||
@@ -347,7 +332,7 @@ exports[`edit a comment and handle server error: edit form 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Italic"
|
||||
@@ -361,7 +346,7 @@ exports[`edit a comment and handle server error: edit form 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Blockquote"
|
||||
@@ -374,6 +359,20 @@ exports[`edit a comment and handle server error: edit form 1`] = `
|
||||
format_quote
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bulleted List"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md"
|
||||
>
|
||||
format_list_bulleted
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -496,34 +495,19 @@ exports[`edit a comment: edit form 1`] = `
|
||||
onFocus={[Function]}
|
||||
>
|
||||
<div
|
||||
className="RTE-contentEditableContainer"
|
||||
className="RTE.module-contentEditableContainer"
|
||||
>
|
||||
<div
|
||||
aria-placeholder="Edit comment"
|
||||
className="RTE-contentEditable coral coral-rte-content RTE-content"
|
||||
contentEditable={true}
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "I like yoghurt",
|
||||
}
|
||||
}
|
||||
disabled={false}
|
||||
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
|
||||
id="comments-editCommentForm-rte-comment-with-replies"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onCut={[Function]}
|
||||
onFocus={[Function]}
|
||||
onInput={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onPaste={[Function]}
|
||||
onSelect={[Function]}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
|
||||
>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bold"
|
||||
@@ -537,7 +521,7 @@ exports[`edit a comment: edit form 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Italic"
|
||||
@@ -551,7 +535,7 @@ exports[`edit a comment: edit form 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Blockquote"
|
||||
@@ -564,6 +548,20 @@ exports[`edit a comment: edit form 1`] = `
|
||||
format_quote
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bulleted List"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md"
|
||||
>
|
||||
format_list_bulleted
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -686,34 +684,19 @@ exports[`edit a comment: optimistic response 1`] = `
|
||||
onFocus={[Function]}
|
||||
>
|
||||
<div
|
||||
className="RTE-contentEditableContainer"
|
||||
className="RTE.module-contentEditableContainer RTE.module-contentEditableContainerDisabled"
|
||||
>
|
||||
<div
|
||||
aria-placeholder="Edit comment"
|
||||
className="RTE-contentEditable coral coral-rte-content RTE-content RTE-contentEditableDisabled"
|
||||
contentEditable={false}
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "Edited!",
|
||||
}
|
||||
}
|
||||
disabled={true}
|
||||
className="RTE.module-contentEditable coral coral-rte-content RTE-content RTE.module-contentEditableDisabled"
|
||||
id="comments-editCommentForm-rte-comment-with-replies"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onCut={[Function]}
|
||||
onFocus={[Function]}
|
||||
onInput={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onPaste={[Function]}
|
||||
onSelect={[Function]}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarDisabled RTE-toolbarBottom Toolbar-toolbar"
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarDisabled RTE.module-toolbarBottom Toolbar.module-toolbar"
|
||||
>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bold"
|
||||
@@ -727,7 +710,7 @@ exports[`edit a comment: optimistic response 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Italic"
|
||||
@@ -741,7 +724,7 @@ exports[`edit a comment: optimistic response 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Blockquote"
|
||||
@@ -754,6 +737,20 @@ exports[`edit a comment: optimistic response 1`] = `
|
||||
format_quote
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bulleted List"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md"
|
||||
>
|
||||
format_list_bulleted
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1626,34 +1623,19 @@ exports[`shows expiry message: edit time expired 1`] = `
|
||||
onFocus={[Function]}
|
||||
>
|
||||
<div
|
||||
className="RTE-contentEditableContainer"
|
||||
className="RTE.module-contentEditableContainer RTE.module-contentEditableContainerDisabled"
|
||||
>
|
||||
<div
|
||||
aria-placeholder="Edit comment"
|
||||
className="RTE-contentEditable coral coral-rte-content RTE-content RTE-contentEditableDisabled"
|
||||
contentEditable={false}
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "I like yoghurt",
|
||||
}
|
||||
}
|
||||
disabled={true}
|
||||
className="RTE.module-contentEditable coral coral-rte-content RTE-content RTE.module-contentEditableDisabled"
|
||||
id="comments-editCommentForm-rte-comment-with-replies"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onCut={[Function]}
|
||||
onFocus={[Function]}
|
||||
onInput={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onPaste={[Function]}
|
||||
onSelect={[Function]}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarDisabled RTE-toolbarBottom Toolbar-toolbar"
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarDisabled RTE.module-toolbarBottom Toolbar.module-toolbar"
|
||||
>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bold"
|
||||
@@ -1667,7 +1649,7 @@ exports[`shows expiry message: edit time expired 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Italic"
|
||||
@@ -1681,7 +1663,7 @@ exports[`shows expiry message: edit time expired 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Blockquote"
|
||||
@@ -1694,6 +1676,20 @@ exports[`shows expiry message: edit time expired 1`] = `
|
||||
format_quote
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bulleted List"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md"
|
||||
>
|
||||
format_list_bulleted
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+21
-22
@@ -321,40 +321,25 @@ exports[`post a reply: open reply form 1`] = `
|
||||
onFocus={[Function]}
|
||||
>
|
||||
<div
|
||||
className="RTE-contentEditableContainer"
|
||||
className="RTE.module-contentEditableContainer"
|
||||
>
|
||||
<div
|
||||
aria-placeholder="Write a reply"
|
||||
className="RTE-contentEditable coral coral-rte-content RTE-content"
|
||||
contentEditable={true}
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "",
|
||||
}
|
||||
}
|
||||
disabled={false}
|
||||
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
|
||||
id="comments-replyCommentForm-rte-comment-with-deepest-replies-3"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onCut={[Function]}
|
||||
onFocus={[Function]}
|
||||
onInput={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onPaste={[Function]}
|
||||
onSelect={[Function]}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="RTE-placeholder coral coral-rte-placeholder RTE-placeholder"
|
||||
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
|
||||
>
|
||||
Write a reply
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
|
||||
>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bold"
|
||||
@@ -368,7 +353,7 @@ exports[`post a reply: open reply form 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Italic"
|
||||
@@ -382,7 +367,7 @@ exports[`post a reply: open reply form 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Blockquote"
|
||||
@@ -395,6 +380,20 @@ exports[`post a reply: open reply form 1`] = `
|
||||
format_quote
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bulleted List"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md"
|
||||
>
|
||||
format_list_bulleted
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -277,40 +277,25 @@ exports[`post a reply: open reply form 1`] = `
|
||||
onFocus={[Function]}
|
||||
>
|
||||
<div
|
||||
className="RTE-contentEditableContainer"
|
||||
className="RTE.module-contentEditableContainer"
|
||||
>
|
||||
<div
|
||||
aria-placeholder="Write a reply"
|
||||
className="RTE-contentEditable coral coral-rte-content RTE-content"
|
||||
contentEditable={true}
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "",
|
||||
}
|
||||
}
|
||||
disabled={false}
|
||||
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
|
||||
id="comments-replyCommentForm-rte-comment-0"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onCut={[Function]}
|
||||
onFocus={[Function]}
|
||||
onInput={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onPaste={[Function]}
|
||||
onSelect={[Function]}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="RTE-placeholder coral coral-rte-placeholder RTE-placeholder"
|
||||
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
|
||||
>
|
||||
Write a reply
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
|
||||
>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bold"
|
||||
@@ -324,7 +309,7 @@ exports[`post a reply: open reply form 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Italic"
|
||||
@@ -338,7 +323,7 @@ exports[`post a reply: open reply form 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Blockquote"
|
||||
@@ -351,6 +336,20 @@ exports[`post a reply: open reply form 1`] = `
|
||||
format_quote
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bulleted List"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md"
|
||||
>
|
||||
format_list_bulleted
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+21
-21
@@ -74,38 +74,24 @@ exports[`renders comment stream with community guidelines 1`] = `
|
||||
onFocus={[Function]}
|
||||
>
|
||||
<div
|
||||
className="RTE-contentEditableContainer"
|
||||
className="RTE.module-contentEditableContainer"
|
||||
>
|
||||
<div
|
||||
aria-placeholder="Post a comment"
|
||||
className="RTE-contentEditable coral coral-rte-content RTE-content"
|
||||
contentEditable={true}
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "",
|
||||
}
|
||||
}
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onCut={[Function]}
|
||||
onFocus={[Function]}
|
||||
onInput={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onPaste={[Function]}
|
||||
onSelect={[Function]}
|
||||
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="RTE-placeholder coral coral-rte-placeholder RTE-placeholder"
|
||||
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
|
||||
>
|
||||
Post a comment
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
|
||||
>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bold"
|
||||
@@ -119,7 +105,7 @@ exports[`renders comment stream with community guidelines 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Italic"
|
||||
@@ -133,7 +119,7 @@ exports[`renders comment stream with community guidelines 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Blockquote"
|
||||
@@ -146,6 +132,20 @@ exports[`renders comment stream with community guidelines 1`] = `
|
||||
format_quote
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bulleted List"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md"
|
||||
>
|
||||
format_list_bulleted
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+42
-43
@@ -316,40 +316,25 @@ exports[`renders message box when logged in 1`] = `
|
||||
onFocus={[Function]}
|
||||
>
|
||||
<div
|
||||
className="RTE-contentEditableContainer"
|
||||
className="RTE.module-contentEditableContainer"
|
||||
>
|
||||
<div
|
||||
aria-placeholder="Post a comment"
|
||||
className="RTE-contentEditable coral coral-rte-content PostCommentForm-rteBorderless RTE-content"
|
||||
contentEditable={true}
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "",
|
||||
}
|
||||
}
|
||||
disabled={false}
|
||||
className="RTE.module-contentEditable coral coral-rte-content PostCommentForm-rteBorderless RTE-content"
|
||||
id="comments-postCommentForm-field"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onCut={[Function]}
|
||||
onFocus={[Function]}
|
||||
onInput={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onPaste={[Function]}
|
||||
onSelect={[Function]}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="RTE-placeholder coral coral-rte-placeholder RTE-placeholder"
|
||||
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
|
||||
>
|
||||
Post a comment
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
|
||||
>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bold"
|
||||
@@ -363,7 +348,7 @@ exports[`renders message box when logged in 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Italic"
|
||||
@@ -377,7 +362,7 @@ exports[`renders message box when logged in 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Blockquote"
|
||||
@@ -390,6 +375,20 @@ exports[`renders message box when logged in 1`] = `
|
||||
format_quote
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bulleted List"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md"
|
||||
>
|
||||
format_list_bulleted
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -623,38 +622,24 @@ exports[`renders message box when not logged in 1`] = `
|
||||
onFocus={[Function]}
|
||||
>
|
||||
<div
|
||||
className="RTE-contentEditableContainer"
|
||||
className="RTE.module-contentEditableContainer"
|
||||
>
|
||||
<div
|
||||
aria-placeholder="Post a comment"
|
||||
className="RTE-contentEditable coral coral-rte-content RTE-content"
|
||||
contentEditable={true}
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "",
|
||||
}
|
||||
}
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onCut={[Function]}
|
||||
onFocus={[Function]}
|
||||
onInput={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onPaste={[Function]}
|
||||
onSelect={[Function]}
|
||||
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="RTE-placeholder coral coral-rte-placeholder RTE-placeholder"
|
||||
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
|
||||
>
|
||||
Post a comment
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
|
||||
>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bold"
|
||||
@@ -668,7 +653,7 @@ exports[`renders message box when not logged in 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Italic"
|
||||
@@ -682,7 +667,7 @@ exports[`renders message box when not logged in 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Blockquote"
|
||||
@@ -695,6 +680,20 @@ exports[`renders message box when not logged in 1`] = `
|
||||
format_quote
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bulleted List"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md"
|
||||
>
|
||||
format_list_bulleted
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+21
-21
@@ -96,38 +96,24 @@ exports[`renders comment stream 1`] = `
|
||||
onFocus={[Function]}
|
||||
>
|
||||
<div
|
||||
className="RTE-contentEditableContainer"
|
||||
className="RTE.module-contentEditableContainer"
|
||||
>
|
||||
<div
|
||||
aria-placeholder="Post a comment"
|
||||
className="RTE-contentEditable coral coral-rte-content RTE-content"
|
||||
contentEditable={true}
|
||||
dangerouslySetInnerHTML={
|
||||
Object {
|
||||
"__html": "",
|
||||
}
|
||||
}
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onCut={[Function]}
|
||||
onFocus={[Function]}
|
||||
onInput={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onPaste={[Function]}
|
||||
onSelect={[Function]}
|
||||
className="RTE.module-contentEditable coral coral-rte-content RTE-content"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="RTE-placeholder coral coral-rte-placeholder RTE-placeholder"
|
||||
className="RTE.module-placeholder coral coral-rte-placeholder RTE-placeholder"
|
||||
>
|
||||
Post a comment
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE-toolbarBottom Toolbar-toolbar"
|
||||
className="coral coral-rte-toolbar RTE-toolbar RTE.module-toolbarBottom Toolbar.module-toolbar"
|
||||
>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bold"
|
||||
@@ -141,7 +127,7 @@ exports[`renders comment stream 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Italic"
|
||||
@@ -155,7 +141,7 @@ exports[`renders comment stream 1`] = `
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button-button"
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Blockquote"
|
||||
@@ -168,6 +154,20 @@ exports[`renders comment stream 1`] = `
|
||||
format_quote
|
||||
</i>
|
||||
</button>
|
||||
<button
|
||||
className="Button.module-button"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
title="Bulleted List"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-md"
|
||||
>
|
||||
format_list_bulleted
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import RTE from "@coralproject/rte";
|
||||
import sinon from "sinon";
|
||||
import timekeeper from "timekeeper";
|
||||
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
waitForElement,
|
||||
within,
|
||||
} from "coral-framework/testHelpers";
|
||||
import waitForRTE from "coral-stream/test/helpers/waitForRTE";
|
||||
|
||||
import { commenters, settings, stories } from "../../fixtures";
|
||||
import create from "./create";
|
||||
@@ -61,18 +61,12 @@ async function createTestRenderer(
|
||||
);
|
||||
|
||||
// Open edit form.
|
||||
within(comment).getByText("Edit").props.onClick();
|
||||
act(() => {
|
||||
within(comment).getByText("Edit").props.onClick();
|
||||
});
|
||||
|
||||
const rte = await waitForElement(
|
||||
() =>
|
||||
findParentWithType(
|
||||
within(comment).getByLabelText("Edit comment"),
|
||||
// We'll use the RTE component here as an exception because the
|
||||
// jsdom does not support all of what is needed for rendering the
|
||||
// Rich Text Editor.
|
||||
RTE
|
||||
)!
|
||||
);
|
||||
// Wait for edit RTE to initialize.
|
||||
const rte = await waitForRTE(comment, "Edit comment");
|
||||
|
||||
const form = findParentWithType(rte, "form")!;
|
||||
return {
|
||||
@@ -89,7 +83,7 @@ it("validate min", async () => {
|
||||
|
||||
const text = "Please enter at least 3 characters.";
|
||||
|
||||
act(() => rte.props.onChange({ html: "ab" }));
|
||||
act(() => rte.props.onChange("ab"));
|
||||
act(() => {
|
||||
form.props.onSubmit();
|
||||
});
|
||||
@@ -101,7 +95,7 @@ it("validate max", async () => {
|
||||
|
||||
const text = "Please enter at max 10 characters.";
|
||||
|
||||
act(() => rte.props.onChange({ html: "abcdefghijklmnopqrst" }));
|
||||
act(() => rte.props.onChange("abcdefghijklmnopqrst"));
|
||||
act(() => {
|
||||
form.props.onSubmit();
|
||||
});
|
||||
@@ -111,9 +105,9 @@ it("validate max", async () => {
|
||||
it("show remaining characters", async () => {
|
||||
const { rte, form } = await createTestRenderer();
|
||||
|
||||
act(() => rte.props.onChange({ html: "abc" }));
|
||||
act(() => rte.props.onChange("abc"));
|
||||
within(form).getByText("7 characters remaining");
|
||||
act(() => rte.props.onChange({ html: "abcdefghijkl" }));
|
||||
act(() => rte.props.onChange("abcdefghijkl"));
|
||||
within(form).getByText("-2 characters remaining");
|
||||
});
|
||||
|
||||
@@ -153,9 +147,9 @@ it("update from server upon specific char count error", async () => {
|
||||
{ muteNetworkErrors: true }
|
||||
);
|
||||
|
||||
act(() => rte.props.onChange({ html: "abc" }));
|
||||
act(() => rte.props.onChange("abc"));
|
||||
within(form).getByText("7 characters remaining");
|
||||
act(() => rte.props.onChange({ html: "abcdefgh" }));
|
||||
act(() => rte.props.onChange("abcdefgh"));
|
||||
within(form).getByText("2 characters remaining");
|
||||
act(() => {
|
||||
form.props.onSubmit();
|
||||
@@ -167,7 +161,7 @@ it("update from server upon specific char count error", async () => {
|
||||
});
|
||||
// Body submit error should be displayed.
|
||||
within(form).getByText(errorCode);
|
||||
act(() => rte.props.onChange({ html: "abcde" }));
|
||||
act(() => rte.props.onChange("abcde"));
|
||||
|
||||
// Body submit error should disappear when form gets dirty.
|
||||
expect(within(form).queryByText(errorCode)).toBeNull();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import RTE from "@coralproject/rte";
|
||||
import sinon from "sinon";
|
||||
|
||||
import { ERROR_CODES } from "coral-common/errors";
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
waitForElement,
|
||||
within,
|
||||
} from "coral-framework/testHelpers";
|
||||
import waitForRTE from "coral-stream/test/helpers/waitForRTE";
|
||||
|
||||
import { commenters, settings, stories } from "../../fixtures";
|
||||
import create from "./create";
|
||||
@@ -49,16 +49,7 @@ async function createTestRenderer(
|
||||
},
|
||||
});
|
||||
|
||||
const rte = await waitForElement(
|
||||
() =>
|
||||
findParentWithType(
|
||||
within(testRenderer.root).getByLabelText("Post a comment"),
|
||||
// We'll use the RTE component here as an exception because the
|
||||
// jsdom does not support all of what is needed for rendering the
|
||||
// Rich Text Editor.
|
||||
RTE
|
||||
)!
|
||||
);
|
||||
const rte = await waitForRTE(testRenderer.root, "Post a comment");
|
||||
const form = findParentWithType(rte, "form")!;
|
||||
return {
|
||||
testRenderer,
|
||||
@@ -73,19 +64,19 @@ it("validate min", async () => {
|
||||
|
||||
const text = "Please enter at least 3 characters.";
|
||||
|
||||
act(() => rte.props.onChange({ html: "ab" }));
|
||||
act(() => rte.props.onChange("ab"));
|
||||
act(() => form.props.onSubmit());
|
||||
await act(async () => {
|
||||
waitForElement(() => within(form).getByText(text));
|
||||
});
|
||||
|
||||
// Reset validation when erasing all content.
|
||||
act(() => rte.props.onChange({ html: "" }));
|
||||
act(() => rte.props.onChange(""));
|
||||
await wait(() => {
|
||||
expect(within(form).queryByText(text)).toBeNull();
|
||||
});
|
||||
|
||||
act(() => rte.props.onChange({ html: "ab" }));
|
||||
act(() => rte.props.onChange("ab"));
|
||||
await wait(() => {
|
||||
expect(within(form).queryByText(text)).toBeNull();
|
||||
});
|
||||
@@ -96,7 +87,7 @@ it("validate max", async () => {
|
||||
|
||||
const text = "Please enter at max 10 characters.";
|
||||
|
||||
act(() => rte.props.onChange({ html: "abcdefghijklmnopqrst" }));
|
||||
act(() => rte.props.onChange("abcdefghijklmnopqrst"));
|
||||
act(() => {
|
||||
form.props.onSubmit();
|
||||
});
|
||||
@@ -105,12 +96,12 @@ it("validate max", async () => {
|
||||
});
|
||||
|
||||
// Reset validation when erasing all content.
|
||||
act(() => rte.props.onChange({ html: "" }));
|
||||
act(() => rte.props.onChange(""));
|
||||
await wait(() => {
|
||||
expect(within(form).queryByText(text)).toBeNull();
|
||||
});
|
||||
|
||||
act(() => rte.props.onChange({ html: "abcdefghijklmnopqrst" }));
|
||||
act(() => rte.props.onChange("abcdefghijklmnopqrst"));
|
||||
await wait(() => {
|
||||
expect(within(form).queryByText(text)).toBeNull();
|
||||
});
|
||||
@@ -119,9 +110,9 @@ it("validate max", async () => {
|
||||
it("show remaining characters", async () => {
|
||||
const { rte, form } = await createTestRenderer();
|
||||
|
||||
act(() => rte.props.onChange({ html: "abc" }));
|
||||
act(() => rte.props.onChange("abc"));
|
||||
within(form).getByText("7 characters remaining");
|
||||
act(() => rte.props.onChange({ html: "abcdefghijkl" }));
|
||||
act(() => rte.props.onChange("abcdefghijkl"));
|
||||
within(form).getByText("-2 characters remaining");
|
||||
});
|
||||
|
||||
@@ -161,10 +152,10 @@ it("update from server upon specific char count error", async () => {
|
||||
{ muteNetworkErrors: true }
|
||||
);
|
||||
|
||||
act(() => rte.props.onChange({ html: "abc" }));
|
||||
act(() => rte.props.onChange("abc"));
|
||||
waitForElement(() => within(form).getByText("7 characters remaining"));
|
||||
|
||||
act(() => rte.props.onChange({ html: "abcdefgh" }));
|
||||
act(() => rte.props.onChange("abcdefgh"));
|
||||
waitForElement(() => within(form).getByText("2 characters remaining"));
|
||||
|
||||
act(() => {
|
||||
@@ -178,7 +169,7 @@ it("update from server upon specific char count error", async () => {
|
||||
// Body submit error should be displayed.
|
||||
await waitForElement(() => within(form).getByText(errorCode));
|
||||
|
||||
act(() => rte.props.onChange({ html: "abcde" }));
|
||||
act(() => rte.props.onChange("abcde"));
|
||||
await wait(() => {
|
||||
// Body submit error should disappear when form gets dirty.
|
||||
expect(within(form).queryByText(errorCode)).toBeNull();
|
||||
|
||||
@@ -81,15 +81,15 @@ it("validate min", async () => {
|
||||
|
||||
const text = "Please enter at least 3 characters.";
|
||||
|
||||
act(() => rte.props.onChange({ html: "ab" }));
|
||||
act(() => rte.props.onChange("ab"));
|
||||
act(() => form.props.onSubmit());
|
||||
within(form).getByText(text);
|
||||
|
||||
// Reset validation when erasing all content.
|
||||
act(() => rte.props.onChange({ html: "" }));
|
||||
act(() => rte.props.onChange(""));
|
||||
expect(within(form).queryByText(text)).toBeNull();
|
||||
|
||||
act(() => rte.props.onChange({ html: "ab" }));
|
||||
act(() => rte.props.onChange("ab"));
|
||||
expect(within(form).queryByText(text)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -98,25 +98,25 @@ it("validate max", async () => {
|
||||
|
||||
const text = "Please enter at max 10 characters.";
|
||||
|
||||
act(() => rte.props.onChange({ html: "abcdefghijklmnopqrst" }));
|
||||
act(() => rte.props.onChange("abcdefghijklmnopqrst"));
|
||||
act(() => form.props.onSubmit());
|
||||
within(form).getByText(text);
|
||||
|
||||
// Reset validation when erasing all content.
|
||||
act(() => rte.props.onChange({ html: "" }));
|
||||
act(() => rte.props.onChange(""));
|
||||
expect(within(form).queryByText(text)).toBeNull();
|
||||
|
||||
act(() => rte.props.onChange({ html: "abcdefghijklmnopqrst" }));
|
||||
act(() => rte.props.onChange("abcdefghijklmnopqrst"));
|
||||
expect(within(form).queryByText(text)).toBeNull();
|
||||
});
|
||||
|
||||
it("show remaining characters", async () => {
|
||||
const { rte, form } = await createTestRenderer();
|
||||
|
||||
act(() => rte.props.onChange({ html: "abc" }));
|
||||
act(() => rte.props.onChange("abc"));
|
||||
waitForElement(() => within(form).getByText("7 characters remaining"));
|
||||
|
||||
act(() => rte.props.onChange({ html: "abcdefghijkl" }));
|
||||
act(() => rte.props.onChange("abcdefghijkl"));
|
||||
waitForElement(() => within(form).getByText("-2 characters remaining"));
|
||||
});
|
||||
|
||||
@@ -156,10 +156,10 @@ it("update from server upon specific char count error", async () => {
|
||||
{ muteNetworkErrors: true }
|
||||
);
|
||||
|
||||
act(() => rte.props.onChange({ html: "abc" }));
|
||||
act(() => rte.props.onChange("abc"));
|
||||
waitForElement(() => within(form).getByText("7 characters remaining"));
|
||||
|
||||
act(() => rte.props.onChange({ html: "abcdefgh" }));
|
||||
act(() => rte.props.onChange("abcdefgh"));
|
||||
waitForElement(() => within(form).getByText("2 characters remaining"));
|
||||
|
||||
await act(async () => form.props.onSubmit());
|
||||
@@ -168,7 +168,7 @@ it("update from server upon specific char count error", async () => {
|
||||
// Body submit error should be displayed.
|
||||
waitForElement(() => within(form).getByText(errorCode));
|
||||
|
||||
act(() => rte.props.onChange({ html: "abcde" }));
|
||||
act(() => rte.props.onChange("abcde"));
|
||||
|
||||
// Body submit error should disappear when form gets dirty.
|
||||
expect(within(form).queryByText(errorCode)).toBeNull();
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "../../fixtures";
|
||||
import create from "./create";
|
||||
|
||||
function createTestRenderer(
|
||||
async function createTestRenderer(
|
||||
resolver: any = {},
|
||||
options: { muteNetworkErrors?: boolean; status?: string } = {}
|
||||
) {
|
||||
@@ -84,7 +84,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
it("edit a comment", async () => {
|
||||
const testRenderer = createTestRenderer();
|
||||
const testRenderer = await createTestRenderer();
|
||||
|
||||
const comment = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID(`comment-${commentWithReplies.id}`)
|
||||
@@ -101,9 +101,9 @@ it("edit a comment", async () => {
|
||||
act(() =>
|
||||
testRenderer.root
|
||||
.findByProps({
|
||||
inputId: `comments-editCommentForm-rte-${commentWithReplies.id}`,
|
||||
inputID: `comments-editCommentForm-rte-${commentWithReplies.id}`,
|
||||
})
|
||||
.props.onChange({ html: "Edited!" })
|
||||
.props.onChange("Edited!")
|
||||
);
|
||||
|
||||
act(() => {
|
||||
@@ -125,7 +125,10 @@ it("edit a comment", async () => {
|
||||
});
|
||||
|
||||
it("edit a comment and handle non-published comment state", async () => {
|
||||
const testRenderer = createTestRenderer({}, { status: "SYSTEM_WITHHELD" });
|
||||
const testRenderer = await createTestRenderer(
|
||||
{},
|
||||
{ status: "SYSTEM_WITHHELD" }
|
||||
);
|
||||
|
||||
const comment = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID(`comment-${commentWithReplies.id}`)
|
||||
@@ -137,9 +140,9 @@ it("edit a comment and handle non-published comment state", async () => {
|
||||
act(() =>
|
||||
testRenderer.root
|
||||
.findByProps({
|
||||
inputId: `comments-editCommentForm-rte-${commentWithReplies.id}`,
|
||||
inputID: `comments-editCommentForm-rte-${commentWithReplies.id}`,
|
||||
})
|
||||
.props.onChange({ html: "Edited!" })
|
||||
.props.onChange("Edited!")
|
||||
);
|
||||
|
||||
act(() => {
|
||||
@@ -171,7 +174,7 @@ it("edit a comment and handle non-published comment state", async () => {
|
||||
});
|
||||
|
||||
it("cancel edit", async () => {
|
||||
const testRenderer = createTestRenderer();
|
||||
const testRenderer = await createTestRenderer();
|
||||
|
||||
const comment = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID(`comment-${commentWithReplies.id}`)
|
||||
@@ -187,7 +190,7 @@ it("cancel edit", async () => {
|
||||
});
|
||||
|
||||
it("shows expiry message", async () => {
|
||||
const testRenderer = createTestRenderer();
|
||||
const testRenderer = await createTestRenderer();
|
||||
|
||||
const comment = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID(`comment-${commentWithReplies.id}`)
|
||||
@@ -210,7 +213,7 @@ it("shows expiry message", async () => {
|
||||
});
|
||||
|
||||
it("edit a comment and handle server error", async () => {
|
||||
const testRenderer = createTestRenderer(
|
||||
const testRenderer = await createTestRenderer(
|
||||
{
|
||||
Mutation: {
|
||||
editComment: sinon.stub().callsFake(() => {
|
||||
@@ -232,9 +235,9 @@ it("edit a comment and handle server error", async () => {
|
||||
act(() =>
|
||||
testRenderer.root
|
||||
.findByProps({
|
||||
inputId: `comments-editCommentForm-rte-${commentWithReplies.id}`,
|
||||
inputID: `comments-editCommentForm-rte-${commentWithReplies.id}`,
|
||||
})
|
||||
.props.onChange({ html: "Edited!" })
|
||||
.props.onChange("Edited!")
|
||||
);
|
||||
|
||||
act(() => {
|
||||
|
||||
@@ -100,7 +100,7 @@ it("post a comment", async () => {
|
||||
},
|
||||
});
|
||||
|
||||
rte.props.onChange({ html: "<b>Hello world!</b>" });
|
||||
rte.props.onChange("<b>Hello world!</b>");
|
||||
|
||||
timekeeper.freeze(new Date(baseComment.createdAt));
|
||||
form.props.onSubmit();
|
||||
@@ -150,7 +150,7 @@ const postACommentAndHandleNonPublishedComment = async (
|
||||
},
|
||||
});
|
||||
|
||||
rte.props.onChange({ html: "<b>Hello world!</b>" });
|
||||
rte.props.onChange("<b>Hello world!</b>");
|
||||
form.props.onSubmit();
|
||||
|
||||
// Test after server response.
|
||||
@@ -172,7 +172,7 @@ it("post a comment and handle non-visible comment state (dismiss by click)", asy
|
||||
|
||||
it("post a comment and handle non-visible comment state (dismiss by typing)", async () =>
|
||||
await postACommentAndHandleNonPublishedComment((form, rte) => {
|
||||
rte.props.onChange({ html: "Typing..." });
|
||||
rte.props.onChange("Typing...");
|
||||
}));
|
||||
|
||||
it("post a comment and handle server error", async () => {
|
||||
@@ -188,7 +188,7 @@ it("post a comment and handle server error", async () => {
|
||||
{ muteNetworkErrors: true }
|
||||
);
|
||||
|
||||
rte.props.onChange({ html: "<b>Hello world!</b>" });
|
||||
rte.props.onChange("<b>Hello world!</b>");
|
||||
form.props.onSubmit();
|
||||
|
||||
// Look for internal error being displayed.
|
||||
@@ -244,7 +244,7 @@ it("handle moderation nudge error", async () => {
|
||||
{ muteNetworkErrors: true }
|
||||
);
|
||||
|
||||
rte.props.onChange({ html: "<b>Hello world!</b>" });
|
||||
rte.props.onChange("<b>Hello world!</b>");
|
||||
form.props.onSubmit();
|
||||
|
||||
// Look for internal error being displayed.
|
||||
@@ -291,7 +291,7 @@ it("handle disabled commenting error", async () => {
|
||||
{ muteNetworkErrors: true }
|
||||
);
|
||||
|
||||
rte.props.onChange({ html: "abc" });
|
||||
rte.props.onChange("abc");
|
||||
form.props.onSubmit();
|
||||
await waitForElement(() => within(form).getByText("commenting disabled"));
|
||||
|
||||
@@ -319,7 +319,7 @@ it("handle story closed", async () => {
|
||||
{ muteNetworkErrors: true }
|
||||
);
|
||||
|
||||
rte.props.onChange({ html: "abc" });
|
||||
rte.props.onChange("abc");
|
||||
form.props.onSubmit();
|
||||
|
||||
// Change the story that we return to be closed.
|
||||
|
||||
@@ -98,9 +98,9 @@ it("post a reply", async () => {
|
||||
act(() =>
|
||||
testRenderer.root
|
||||
.findByProps({
|
||||
inputId: "comments-replyCommentForm-rte-comment-with-deepest-replies-3",
|
||||
inputID: "comments-replyCommentForm-rte-comment-with-deepest-replies-3",
|
||||
})
|
||||
.props.onChange({ html: "<b>Hello world!</b>" })
|
||||
.props.onChange("<b>Hello world!</b>")
|
||||
);
|
||||
|
||||
timekeeper.freeze(new Date(baseComment.createdAt));
|
||||
|
||||
@@ -115,7 +115,7 @@ it("post a reply", async () => {
|
||||
expect(within(comment).toJSON()).toMatchSnapshot("open reply form");
|
||||
|
||||
// Write reply .
|
||||
act(() => rte.props.onChange({ html: "<b>Hello world!</b>" }));
|
||||
act(() => rte.props.onChange("<b>Hello world!</b>"));
|
||||
|
||||
timekeeper.freeze(new Date(baseComment.createdAt));
|
||||
act(() => {
|
||||
@@ -170,7 +170,7 @@ it("post a reply and handle non-visible comment state", async () => {
|
||||
});
|
||||
|
||||
// Write reply .
|
||||
act(() => rte.props.onChange({ html: "<b>Hello world!</b>" }));
|
||||
act(() => rte.props.onChange("<b>Hello world!</b>"));
|
||||
act(() => {
|
||||
form.props.onSubmit();
|
||||
});
|
||||
@@ -199,7 +199,7 @@ it("post a reply and handle server error", async () => {
|
||||
);
|
||||
|
||||
// Write reply .
|
||||
act(() => rte.props.onChange({ html: "<b>Hello world!</b>" }));
|
||||
act(() => rte.props.onChange("<b>Hello world!</b>"));
|
||||
|
||||
act(() => {
|
||||
form.props.onSubmit();
|
||||
@@ -261,7 +261,7 @@ it("handle moderation nudge error", async () => {
|
||||
{ muteNetworkErrors: true }
|
||||
);
|
||||
|
||||
act(() => rte.props.onChange({ html: "<b>Hello world!</b>" }));
|
||||
act(() => rte.props.onChange("<b>Hello world!</b>"));
|
||||
act(() => {
|
||||
form.props.onSubmit();
|
||||
});
|
||||
@@ -302,7 +302,7 @@ it("handle disabled commenting error", async () => {
|
||||
{ muteNetworkErrors: true }
|
||||
);
|
||||
|
||||
act(() => rte.props.onChange({ html: "abc" }));
|
||||
act(() => rte.props.onChange("abc"));
|
||||
act(() => {
|
||||
form.props.onSubmit();
|
||||
});
|
||||
@@ -342,7 +342,7 @@ it("handle story closed error", async () => {
|
||||
{ muteNetworkErrors: true }
|
||||
);
|
||||
|
||||
rte.props.onChange({ html: "abc" });
|
||||
rte.props.onChange("abc");
|
||||
form.props.onSubmit();
|
||||
|
||||
// Change the story that we return to be closed.
|
||||
|
||||
@@ -7,7 +7,7 @@ import { act, wait, waitForElement, within } from "coral-framework/testHelpers";
|
||||
import { commenters, settings, stories } from "../../fixtures";
|
||||
import create from "./create";
|
||||
|
||||
function createTestRenderer(
|
||||
async function createTestRenderer(
|
||||
resolver: any = {},
|
||||
options: { muteNetworkErrors?: boolean } = {}
|
||||
) {
|
||||
@@ -72,7 +72,7 @@ function createTestRenderer(
|
||||
|
||||
it("render popup", async () => {
|
||||
const commentID = stories[0].comments.edges[0].node.id;
|
||||
const { testRenderer } = createTestRenderer();
|
||||
const { testRenderer } = await createTestRenderer();
|
||||
const comment = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID(`comment-${commentID}`)
|
||||
);
|
||||
@@ -88,7 +88,7 @@ it("render popup", async () => {
|
||||
|
||||
it("close popup", async () => {
|
||||
const commentID = stories[0].comments.edges[0].node.id;
|
||||
const { testRenderer } = createTestRenderer();
|
||||
const { testRenderer } = await createTestRenderer();
|
||||
const comment = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID(`comment-${commentID}`)
|
||||
);
|
||||
@@ -109,7 +109,7 @@ it("close popup", async () => {
|
||||
|
||||
it("render popup expanded", async () => {
|
||||
const commentID = stories[0].comments.edges[0].node.id;
|
||||
const { testRenderer } = createTestRenderer();
|
||||
const { testRenderer } = await createTestRenderer();
|
||||
const comment = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID(`comment-${commentID}`)
|
||||
);
|
||||
@@ -135,7 +135,7 @@ it("render popup expanded", async () => {
|
||||
|
||||
it("report comment as offensive", async () => {
|
||||
const commentID = stories[0].comments.edges[0].node.id;
|
||||
const { testRenderer, resolvers } = createTestRenderer();
|
||||
const { testRenderer, resolvers } = await createTestRenderer();
|
||||
const comment = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID(`comment-${commentID}`)
|
||||
);
|
||||
@@ -186,7 +186,7 @@ it("report comment as offensive", async () => {
|
||||
|
||||
it("dont agree with comment", async () => {
|
||||
const commentID = stories[0].comments.edges[0].node.id;
|
||||
const { testRenderer, resolvers } = createTestRenderer();
|
||||
const { testRenderer, resolvers } = await createTestRenderer();
|
||||
const comment = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID(`comment-${commentID}`)
|
||||
);
|
||||
@@ -244,7 +244,7 @@ it("dont agree with comment", async () => {
|
||||
|
||||
it("report comment as offensive and handle server error", async () => {
|
||||
const commentID = stories[0].comments.edges[0].node.id;
|
||||
const { testRenderer } = createTestRenderer(
|
||||
const { testRenderer } = await createTestRenderer(
|
||||
{
|
||||
Mutation: {
|
||||
createCommentFlag: sinon.stub().callsFake(() => {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import sinon from "sinon";
|
||||
|
||||
import { GQLRTEConfiguration } from "coral-framework/schema";
|
||||
import { act, waitForElement, within } from "coral-framework/testHelpers";
|
||||
import waitForRTE from "coral-stream/test/helpers/waitForRTE";
|
||||
|
||||
import { commenters, settings, stories } from "../../fixtures";
|
||||
import create from "./create";
|
||||
|
||||
async function createTestRenderer(
|
||||
resolver: any,
|
||||
rteConfig: GQLRTEConfiguration,
|
||||
options: { muteNetworkErrors?: boolean } = {}
|
||||
) {
|
||||
const resolvers = {
|
||||
...resolver,
|
||||
Query: {
|
||||
settings: sinon.stub().returns({
|
||||
...settings,
|
||||
rte: rteConfig,
|
||||
}),
|
||||
viewer: sinon.stub().returns(commenters[0]),
|
||||
stream: sinon.stub().callsFake((_: any, variables: any) => {
|
||||
expectAndFail(variables.id).toBe(stories[0].id);
|
||||
return stories[0];
|
||||
}),
|
||||
...resolver.Query,
|
||||
},
|
||||
};
|
||||
|
||||
const { testRenderer, context } = create({
|
||||
// Set this to true, to see graphql responses.
|
||||
logNetwork: false,
|
||||
muteNetworkErrors: options.muteNetworkErrors,
|
||||
resolvers,
|
||||
initLocalState: (localRecord) => {
|
||||
localRecord.setValue(stories[0].id, "storyID");
|
||||
},
|
||||
});
|
||||
|
||||
const tabPane = await waitForElement(() =>
|
||||
within(testRenderer.root).getByTestID("current-tab-pane")
|
||||
);
|
||||
|
||||
const rte = await waitForRTE(tabPane, "Post a comment");
|
||||
|
||||
return {
|
||||
testRenderer,
|
||||
context,
|
||||
tabPane,
|
||||
rte,
|
||||
};
|
||||
}
|
||||
|
||||
it("disabled rte formatting", async () => {
|
||||
await act(async () => {
|
||||
const { rte } = await createTestRenderer(
|
||||
{},
|
||||
{
|
||||
enabled: false,
|
||||
spoiler: true,
|
||||
strikethrough: true,
|
||||
}
|
||||
);
|
||||
expect(within(rte).queryByTitle("Bold")).toBeNull();
|
||||
expect(within(rte).queryByTitle("Italic")).toBeNull();
|
||||
expect(within(rte).queryByTitle("Blockquote")).toBeNull();
|
||||
expect(within(rte).queryByTitle("Bulleted List")).toBeNull();
|
||||
expect(within(rte).queryByTitle("Strikethrough")).toBeNull();
|
||||
expect(within(rte).queryByTitle("Spoiler")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("enable basic rte formatting", async () => {
|
||||
await act(async () => {
|
||||
const { rte } = await createTestRenderer(
|
||||
{},
|
||||
{
|
||||
enabled: true,
|
||||
spoiler: false,
|
||||
strikethrough: false,
|
||||
}
|
||||
);
|
||||
expect(within(rte).queryByTitle("Bold")).not.toBeNull();
|
||||
expect(within(rte).queryByTitle("Italic")).not.toBeNull();
|
||||
expect(within(rte).queryByTitle("Blockquote")).not.toBeNull();
|
||||
expect(within(rte).queryByTitle("Bulleted List")).not.toBeNull();
|
||||
expect(within(rte).queryByTitle("Strikethrough")).toBeNull();
|
||||
expect(within(rte).queryByTitle("Spoiler")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("enable strike formatting", async () => {
|
||||
await act(async () => {
|
||||
const { rte } = await createTestRenderer(
|
||||
{},
|
||||
{
|
||||
enabled: true,
|
||||
strikethrough: true,
|
||||
spoiler: false,
|
||||
}
|
||||
);
|
||||
expect(within(rte).queryByTitle("Bold")).not.toBeNull();
|
||||
expect(within(rte).queryByTitle("Italic")).not.toBeNull();
|
||||
expect(within(rte).queryByTitle("Blockquote")).not.toBeNull();
|
||||
expect(within(rte).queryByTitle("Bulleted List")).not.toBeNull();
|
||||
expect(within(rte).queryByTitle("Strikethrough")).not.toBeNull();
|
||||
expect(within(rte).queryByTitle("Spoiler")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("enable spoiler formatting", async () => {
|
||||
await act(async () => {
|
||||
const { rte } = await createTestRenderer(
|
||||
{},
|
||||
{
|
||||
enabled: true,
|
||||
strikethrough: false,
|
||||
spoiler: true,
|
||||
}
|
||||
);
|
||||
expect(within(rte).queryByTitle("Bold")).not.toBeNull();
|
||||
expect(within(rte).queryByTitle("Italic")).not.toBeNull();
|
||||
expect(within(rte).queryByTitle("Blockquote")).not.toBeNull();
|
||||
expect(within(rte).queryByTitle("Bulleted List")).not.toBeNull();
|
||||
expect(within(rte).queryByTitle("Strikethrough")).toBeNull();
|
||||
expect(within(rte).queryByText("Spoiler")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("enable all formatting", async () => {
|
||||
await act(async () => {
|
||||
const { rte } = await createTestRenderer(
|
||||
{},
|
||||
{
|
||||
enabled: true,
|
||||
strikethrough: true,
|
||||
spoiler: true,
|
||||
}
|
||||
);
|
||||
expect(within(rte).queryByTitle("Bold")).not.toBeNull();
|
||||
expect(within(rte).queryByTitle("Italic")).not.toBeNull();
|
||||
expect(within(rte).queryByTitle("Blockquote")).not.toBeNull();
|
||||
expect(within(rte).queryByTitle("Bulleted List")).not.toBeNull();
|
||||
expect(within(rte).queryByTitle("Strikethrough")).not.toBeNull();
|
||||
expect(within(rte).queryByText("Spoiler")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,11 @@ export const settings = createFixture<GQLSettings>({
|
||||
},
|
||||
multisite: false,
|
||||
featureFlags: [],
|
||||
rte: {
|
||||
enabled: true,
|
||||
strikethrough: false,
|
||||
spoiler: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const site = createFixture<GQLSite>({
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import RTE from "@coralproject/rte";
|
||||
import {
|
||||
act,
|
||||
findParentWithType,
|
||||
waitForElement,
|
||||
within,
|
||||
} from "coral-framework/testHelpers";
|
||||
import { ReactTestInstance } from "react-test-renderer";
|
||||
|
||||
/**
|
||||
* waitForRTE returns an promise that resolves to the instance of `RTE`.
|
||||
*
|
||||
* @param instance The instance to look within
|
||||
* @param label The label of the RTE
|
||||
* @returns Promise of RTE TestInstance
|
||||
*/
|
||||
export default function waitForRTE(instance: ReactTestInstance, label: string) {
|
||||
return act(() =>
|
||||
waitForElement(
|
||||
() =>
|
||||
findParentWithType(
|
||||
within(instance).getByLabelText(label),
|
||||
// We'll use the RTE component here as an exception because the
|
||||
// jsdom does not support all of what is needed for rendering the
|
||||
// Rich Text Editor.
|
||||
RTE
|
||||
)!
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import waitForRTE from "./waitForRTE";
|
||||
|
||||
export default async function waitForCommentsTabInit(
|
||||
testRenderer: ReactTestRenderer
|
||||
) {
|
||||
// Wait for RTE initialization.
|
||||
await waitForRTE(testRenderer.root, "Post a comment");
|
||||
return;
|
||||
}
|
||||
@@ -1,36 +1,5 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
// JSDOM is already injected from Jest.
|
||||
|
||||
declare const global: any;
|
||||
|
||||
const jsdom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
const { window } = jsdom;
|
||||
|
||||
// tiny shim for getSelection for the RTE.
|
||||
|
||||
function copyProps(src: any, target: any) {
|
||||
const props = Object.getOwnPropertyNames(src)
|
||||
.filter((prop) => typeof target[prop] === "undefined")
|
||||
.reduce(
|
||||
(result, prop) => ({
|
||||
...result,
|
||||
[prop]: Object.getOwnPropertyDescriptor(src, prop),
|
||||
}),
|
||||
{}
|
||||
);
|
||||
Object.defineProperties(target, props);
|
||||
}
|
||||
|
||||
global.window = window;
|
||||
global.document = (window as any).document;
|
||||
global.navigator = {
|
||||
userAgent: "node.js",
|
||||
};
|
||||
|
||||
copyProps(window, global);
|
||||
|
||||
global.window.getSelection = () =>
|
||||
({
|
||||
addRange() {},
|
||||
removeAllRanges() {},
|
||||
} as any);
|
||||
global.window.resizeTo = () => {};
|
||||
// We replace `resizeTo` JSDOM implentation - which just returns an error -
|
||||
// with a noop.
|
||||
window.resizeTo = () => {};
|
||||
|
||||
@@ -5,12 +5,12 @@ jest.mock("react-transition-group", () => ({
|
||||
}));
|
||||
|
||||
jest.mock("react-dom", () => ({
|
||||
...require.requireActual("react-dom"),
|
||||
...jest.requireActual("react-dom"),
|
||||
createPortal: (node: any) => node,
|
||||
}));
|
||||
|
||||
jest.mock("popper.js", () => {
|
||||
const PopperJS = require.requireActual("popper.js");
|
||||
const PopperJS = jest.requireActual("popper.js");
|
||||
|
||||
return class Popper {
|
||||
public static placements = PopperJS.placements;
|
||||
|
||||
@@ -13,6 +13,7 @@ const failPatterns: PatternMap = {
|
||||
"You called act(async () => ...) without await",
|
||||
"OptimisticResponse warnings":
|
||||
"`optimisticResponse` to match structure of server response",
|
||||
"Missing translation": "en-US translation for key",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -32,21 +33,37 @@ const originalError = global.console.error;
|
||||
const originalWarn = global.console.warn;
|
||||
const originalLog = global.console.log;
|
||||
|
||||
function argToString(arg: any): string {
|
||||
if (typeof arg === "string") {
|
||||
return arg;
|
||||
}
|
||||
if (arg.toString !== undefined) {
|
||||
return arg.toString();
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getMatchingPatterns(patterns: PatternMap, args: any[]) {
|
||||
const str = args
|
||||
.map((a) => (typeof a === "string" ? a : JSON.stringify(a)))
|
||||
.join(" ");
|
||||
const matchedPatterns: string[] = [];
|
||||
Object.keys(patterns).forEach((k) => {
|
||||
const matching =
|
||||
typeof patterns[k] === "string"
|
||||
? str.includes(patterns[k] as string)
|
||||
: str.match(patterns[k]);
|
||||
if (matching !== false && matching !== null) {
|
||||
matchedPatterns.push(k);
|
||||
}
|
||||
});
|
||||
return matchedPatterns;
|
||||
try {
|
||||
const str = args.map((a) => argToString(a)).join(" ");
|
||||
const matchedPatterns: string[] = [];
|
||||
Object.keys(patterns).forEach((k) => {
|
||||
const matching =
|
||||
typeof patterns[k] === "string"
|
||||
? str.includes(patterns[k] as string)
|
||||
: str.match(patterns[k]);
|
||||
if (matching !== false && matching !== null) {
|
||||
matchedPatterns.push(k);
|
||||
}
|
||||
});
|
||||
return matchedPatterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function createMockImplementation(originalFunction: (...args: any[]) => void) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent, LabelHTMLAttributes } from "react";
|
||||
|
||||
import styles from "./Label.css";
|
||||
@@ -7,10 +8,15 @@ interface Props extends LabelHTMLAttributes<any> {
|
||||
component?: "legend" | "p";
|
||||
}
|
||||
|
||||
const Label: FunctionComponent<Props> = ({ children, component, ...rest }) => {
|
||||
const Label: FunctionComponent<Props> = ({
|
||||
children,
|
||||
component,
|
||||
className,
|
||||
...rest
|
||||
}) => {
|
||||
const Component = component || "label";
|
||||
return (
|
||||
<Component {...rest} className={styles.root}>
|
||||
<Component {...rest} className={cn(styles.root, className)}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
|
||||
@@ -67,3 +67,8 @@ export const DEFAULT_SESSION_DURATION = 90 * TIME.DAY;
|
||||
* same comment repeatedly.
|
||||
*/
|
||||
export const COMMENT_REPEAT_POST_DURATION = 6 * TIME.MINUTE;
|
||||
|
||||
/**
|
||||
* SPOILER_CLASSNAME is the classname that is attached to spoilers.
|
||||
*/
|
||||
export const SPOILER_CLASSNAME = "coral-rte-spoiler";
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`allows anchor links 1`] = `
|
||||
<body>
|
||||
<a
|
||||
href="http://test.com"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
http://test.com
|
||||
</a>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`allows anchor tags and counts them correctly 1`] = `
|
||||
"
|
||||
<div>
|
||||
<a href=\\"https://mozilla.org/\\" target=\\"_blank\\" rel=\\"noopener noreferrer\\">https://mozilla.org/</a>
|
||||
<a href=\\"https://mozilla.org/\\" target=\\"_blank\\" rel=\\"noopener noreferrer\\">https://mozilla.org/</a>
|
||||
</div>
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`allows blockquote 1`] = `
|
||||
<body>
|
||||
<blockquote>
|
||||
bulleted list
|
||||
</blockquote>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`allows bolded tags 1`] = `
|
||||
<body>
|
||||
A
|
||||
<b>
|
||||
bolded comment!
|
||||
</b>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`allows bolded tags 2`] = `
|
||||
<body>
|
||||
A
|
||||
<strong>
|
||||
bolded comment!
|
||||
</strong>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`allows bulleted list 1`] = `
|
||||
<body>
|
||||
<ul>
|
||||
<li>
|
||||
bulleted list
|
||||
</li>
|
||||
</ul>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`allows italic tags 1`] = `
|
||||
<body>
|
||||
<em>
|
||||
italic!
|
||||
</em>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`allows italic tags 2`] = `
|
||||
<body>
|
||||
<i>
|
||||
italic!
|
||||
</i>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`allows mailto links 1`] = `
|
||||
<body>
|
||||
<a
|
||||
href="mailto:email@example.com"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
email@example.com
|
||||
</a>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`allows spoiler 1`] = `
|
||||
<body>
|
||||
<span
|
||||
class="coral-rte-spoiler"
|
||||
>
|
||||
Spoiler
|
||||
</span>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`allows strikthrough 1`] = `
|
||||
<body>
|
||||
<s>
|
||||
strikethrough
|
||||
</s>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`sanitizes out attributes not allowed 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
Test
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`sanitizes out tags not allowed 1`] = `<body />`;
|
||||
|
||||
exports[`sanitizes out tags not allowed 2`] = `<body />`;
|
||||
|
||||
exports[`sanitizes without features enabled 1`] = `
|
||||
<body>
|
||||
|
||||
|
||||
<div>
|
||||
|
||||
|
||||
|
||||
|
||||
Strong
|
||||
|
||||
|
||||
Strong
|
||||
|
||||
|
||||
Italic
|
||||
|
||||
|
||||
Italic
|
||||
|
||||
|
||||
|
||||
|
||||
Blockquote
|
||||
|
||||
|
||||
Strike
|
||||
|
||||
|
||||
Spoiler
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</body>
|
||||
`;
|
||||
@@ -0,0 +1,26 @@
|
||||
import getHTMLPlainText from "./getHTMLPlainText";
|
||||
|
||||
const example1 =
|
||||
'<p>Meat & Fish<br></p><ul><li>Skinless white meat<br></li><li>Lean cuts of red meat<br></li><li><div>Oily fish<br></div><ul><li>Tuna<br></li><li>Salmon<br></li><li>Mackerel<br></li></ul></li><li>Luncheon meat<br></li></ul><div><b>Bold</b><br></div><div><br></div><div>Italic<br></div><div><br></div><blockquote><div>Blockquote</div></blockquote><div><span class="coral-rte-spoiler">Spoiler</span></div>';
|
||||
|
||||
it("transforms HTML to text", () => {
|
||||
expect(getHTMLPlainText(example1)).toMatchInlineSnapshot(`
|
||||
"Meat & Fish
|
||||
Skinless white meat
|
||||
Lean cuts of red meat
|
||||
Oily fish
|
||||
Tuna
|
||||
Salmon
|
||||
Mackerel
|
||||
|
||||
|
||||
Luncheon meat
|
||||
|
||||
Bold
|
||||
|
||||
Italic
|
||||
|
||||
Blockquote
|
||||
Spoiler"
|
||||
`);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* getHTMLPlainText returns text representation of html.
|
||||
*
|
||||
* @param html string
|
||||
*/
|
||||
export default function getHTMLPlainText(html: string): string {
|
||||
const htmlWithNewLine = html.replace(
|
||||
/((<\/(ul|li|blockquote)>)|(<\/(div|p)>\s*(<[^/]))|(<br>(?!\s*<\/(div|p|li|blockquote))))/g,
|
||||
"\n$1"
|
||||
);
|
||||
|
||||
let textContent: string;
|
||||
|
||||
if (process.env.WEBPACK !== "true") {
|
||||
// textContent is not fully implemented in JSDOM, so we use `striptags` inste ad.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
textContent = require("striptags")(htmlWithNewLine);
|
||||
} else {
|
||||
const divElement = document.createElement("div");
|
||||
divElement.innerHTML = htmlWithNewLine;
|
||||
textContent = divElement.textContent || "";
|
||||
}
|
||||
return textContent.trimRight();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import removeTrailingEmptyLines from "./removeTrailingEmptyLines";
|
||||
|
||||
function createNodeWithHTML(html: string) {
|
||||
const el = document.createElement("div");
|
||||
el.innerHTML = html;
|
||||
return el;
|
||||
}
|
||||
|
||||
it("trims HTML", () => {
|
||||
const examples = [
|
||||
createNodeWithHTML(
|
||||
"<div>a</div><div><br></div><div><br></div><div><br></div><div> <br></div>"
|
||||
),
|
||||
createNodeWithHTML(
|
||||
"<div>a<br></div><div><br></div><div><b>b</b><br></div>"
|
||||
),
|
||||
];
|
||||
|
||||
examples.forEach((e) => removeTrailingEmptyLines(e));
|
||||
expect(examples[0].innerHTML).toMatchInlineSnapshot(`"<div>a</div>"`);
|
||||
expect(examples[1].innerHTML).toMatchInlineSnapshot(
|
||||
`"<div>a<br></div><div><br></div><div><b>b</b><br></div>"`
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* removeTrailingEmptyLines removes empty lines from HTMLElement.
|
||||
*/
|
||||
export default function removeTrailingEmptyLines(element: HTMLElement) {
|
||||
while (element.lastElementChild) {
|
||||
let content: string;
|
||||
if (process.env.WEBPACK === "true") {
|
||||
content = element.lastElementChild?.textContent || "";
|
||||
} else {
|
||||
// textContent is not fully implemented in JSDOM, so we use `innerHTML` and `striptags` instead.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
content = require("striptags")(element.lastElementChild.innerHTML);
|
||||
}
|
||||
if (content.trim() === "") {
|
||||
element.removeChild(element.lastElementChild);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import { SPOILER_CLASSNAME } from "coral-common/constants";
|
||||
|
||||
import { ALL_FEATURES, createSanitize } from "./sanitize";
|
||||
|
||||
const window = new JSDOM("", {}).window;
|
||||
|
||||
it("sanitizes out tags not allowed", () => {
|
||||
const sanitize = createSanitize(window as any, {
|
||||
features: ALL_FEATURES,
|
||||
});
|
||||
expect(
|
||||
sanitize("<script type=\"text/javascript\">alert('ok');</script>")
|
||||
).toMatchSnapshot();
|
||||
|
||||
expect(sanitize("<script src=malicious-code.js></script>")).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("sanitizes out attributes not allowed", () => {
|
||||
const sanitize = createSanitize(window as any, {
|
||||
features: ALL_FEATURES,
|
||||
});
|
||||
expect(sanitize('<div id="test">Test</div>')).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("sanitizes without features enabled", () => {
|
||||
const sanitize = createSanitize(window as any);
|
||||
expect(
|
||||
sanitize(
|
||||
`
|
||||
<div>
|
||||
<ul>
|
||||
<li><b>Strong<b></li>
|
||||
<li><strong>Strong<strong></li>
|
||||
<li><i>Italic<i></li>
|
||||
<li><em>Italic<em></li>
|
||||
</ul>
|
||||
<blockquote>Blockquote</blockquote>
|
||||
<s>Strike</s>
|
||||
<span class="${SPOILER_CLASSNAME}">Spoiler</span>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("allows anchor links", () => {
|
||||
const sanitize = createSanitize(window as any);
|
||||
expect(
|
||||
sanitize('<a href="http://test.com">This is a link</a>')
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("allows mailto links", () => {
|
||||
const sanitize = createSanitize(window as any);
|
||||
expect(
|
||||
sanitize('<a href="mailto:email@example.com">email@example.com</a>')
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("allows anchor tags and counts them correctly", () => {
|
||||
const sanitize = createSanitize(window as any);
|
||||
const el = sanitize(
|
||||
`
|
||||
<div>
|
||||
<a href="https://mozilla.org/">Mozilla</a>
|
||||
<a href="https://mozilla.org/">Mozilla</a>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
|
||||
expect(el.innerHTML).toMatchSnapshot();
|
||||
expect(el.getElementsByTagName("a").length).toEqual(2);
|
||||
});
|
||||
|
||||
it("allows bolded tags", () => {
|
||||
const sanitize = createSanitize(window as any, {
|
||||
features: {
|
||||
bold: true,
|
||||
},
|
||||
});
|
||||
expect(sanitize("A <b>bolded comment!</b>")).toMatchSnapshot();
|
||||
expect(sanitize("A <strong>bolded comment!</strong>")).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("allows italic tags", () => {
|
||||
const sanitize = createSanitize(window as any, {
|
||||
features: {
|
||||
italic: true,
|
||||
},
|
||||
});
|
||||
expect(sanitize("<em>italic!</em>")).toMatchSnapshot();
|
||||
expect(sanitize("<i>italic!</i>")).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("allows bulleted list", () => {
|
||||
const sanitize = createSanitize(window as any, {
|
||||
features: {
|
||||
bulletList: true,
|
||||
},
|
||||
});
|
||||
expect(sanitize("<ul><li>bulleted list</li></ul>")).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("allows blockquote", () => {
|
||||
const sanitize = createSanitize(window as any, {
|
||||
features: {
|
||||
blockquote: true,
|
||||
},
|
||||
});
|
||||
expect(sanitize("<blockquote>bulleted list</blockquote>")).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("allows strikthrough", () => {
|
||||
const sanitize = createSanitize(window as any, {
|
||||
features: {
|
||||
strikethrough: true,
|
||||
},
|
||||
});
|
||||
expect(sanitize("<s>strikethrough</s>")).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("allows spoiler", () => {
|
||||
const sanitize = createSanitize(window as any, {
|
||||
features: {
|
||||
spoiler: true,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
sanitize(`<span class="${SPOILER_CLASSNAME}">Spoiler</span>`)
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import createDOMPurify, { DOMPurifyI } from "dompurify";
|
||||
|
||||
import { SPOILER_CLASSNAME } from "coral-common/constants";
|
||||
|
||||
// TODO: Reaching directly into coral-framework for the types. Maybe having
|
||||
// types in coral-common instead? 🤔
|
||||
import { GQLRTEConfiguration } from "../../client/framework/schema/__generated__/types";
|
||||
|
||||
export interface RTEFeatures {
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
blockquote?: boolean;
|
||||
bulletList?: boolean;
|
||||
strikethrough?: boolean;
|
||||
spoiler?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ALL_FEATURES is a predefined map of RTEFeatures with all
|
||||
* features turned on
|
||||
*/
|
||||
export const ALL_FEATURES: RTEFeatures = {
|
||||
bold: true,
|
||||
italic: true,
|
||||
blockquote: true,
|
||||
bulletList: true,
|
||||
strikethrough: true,
|
||||
spoiler: true,
|
||||
};
|
||||
|
||||
const MAILTO_PROTOCOL = "mailto:";
|
||||
|
||||
/**
|
||||
* convertGQLRTEConfigToRTEFeatures turns the
|
||||
* RTE configuration from the GraphQL Schema to
|
||||
* RTEFeatures.
|
||||
*/
|
||||
export function convertGQLRTEConfigToRTEFeatures(
|
||||
config: Partial<GQLRTEConfiguration>
|
||||
): RTEFeatures {
|
||||
return {
|
||||
bold: config.enabled,
|
||||
italic: config.enabled,
|
||||
blockquote: config.enabled,
|
||||
bulletList: config.enabled,
|
||||
strikethrough: config.enabled && config.strikethrough,
|
||||
spoiler: config.enabled && config.spoiler,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that each anchor tag has a "target" and "rel" attributes set, and
|
||||
* strip the "href" attribute from all non-anchor tags.
|
||||
*/
|
||||
const sanitizeAnchor = (node: Element) => {
|
||||
if (node.nodeName === "A") {
|
||||
// Ensure we wrap all the links with the target + rel set.
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener noreferrer");
|
||||
|
||||
// Ensure that all the links have the same link as they do text.
|
||||
let href = node.getAttribute("href");
|
||||
if (href) {
|
||||
if (node.textContent !== href) {
|
||||
// remove "mailto:" prefix from link text
|
||||
const url = new URL(href);
|
||||
if (url.protocol === MAILTO_PROTOCOL) {
|
||||
href = href.replace(url.protocol, "");
|
||||
}
|
||||
}
|
||||
node.textContent = href;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Further restrict the use of attributes.
|
||||
*/
|
||||
const sanitizeAttributes = (features: RTEFeatures, node: Element) => {
|
||||
// Only achor tags can have the `href` attribute.
|
||||
if (node.nodeName !== "A" && node.getAttribute("href")) {
|
||||
node.removeAttribute("href");
|
||||
}
|
||||
if (features.spoiler) {
|
||||
// Only allow <span class="SPOILER_CLASSNAME"> as our spoiler tag.
|
||||
if (
|
||||
node.nodeName === "SPAN" &&
|
||||
node.classList.contains(SPOILER_CLASSNAME)
|
||||
) {
|
||||
// Remove other classes.
|
||||
node.className = SPOILER_CLASSNAME;
|
||||
} else {
|
||||
node.removeAttribute("class");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function createPurifyConfig(features: RTEFeatures) {
|
||||
const ALLOWED_TAGS = ["a", "br", "div", "p"];
|
||||
const ALLOWED_ATTR = [
|
||||
// Allow href tags for anchor tags.
|
||||
"href",
|
||||
];
|
||||
|
||||
if (features.bold) {
|
||||
ALLOWED_TAGS.push("b", "strong");
|
||||
}
|
||||
if (features.italic) {
|
||||
ALLOWED_TAGS.push("i", "em");
|
||||
}
|
||||
if (features.blockquote) {
|
||||
ALLOWED_TAGS.push("blockquote");
|
||||
}
|
||||
if (features.bulletList) {
|
||||
ALLOWED_TAGS.push("ul", "li");
|
||||
}
|
||||
if (features.strikethrough) {
|
||||
ALLOWED_TAGS.push("s");
|
||||
}
|
||||
if (features.spoiler) {
|
||||
ALLOWED_TAGS.push("span");
|
||||
ALLOWED_ATTR.push("class");
|
||||
}
|
||||
|
||||
return {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR,
|
||||
ALLOW_DATA_ATTR: false,
|
||||
// `ALLOW_ARIA_ATTR` not typed as for v2.0.1.
|
||||
ALLOW_ARIA_ATTR: false,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SanitizeOptions {
|
||||
/** Allow overriding parts of the config */
|
||||
config?: any;
|
||||
/** normalize makes sure that neighboring text nodes are merged */
|
||||
normalize?: boolean;
|
||||
/** modify allows accessing the purify instance to e.g. add hooks */
|
||||
modify?: (purify: DOMPurifyI) => void;
|
||||
/** enable individual features. If not set, all are disabled */
|
||||
features?: RTEFeatures;
|
||||
}
|
||||
|
||||
export type Sanitize = (source: Node | string) => HTMLElement;
|
||||
|
||||
export function createSanitize(
|
||||
window: Window,
|
||||
options?: SanitizeOptions
|
||||
): Sanitize {
|
||||
// Initializing JSDOM and DOMPurify
|
||||
const purify = createDOMPurify(window);
|
||||
const features = options?.features || {};
|
||||
|
||||
// Setting our DOMPurify config.
|
||||
purify.setConfig({
|
||||
...createPurifyConfig(features),
|
||||
// Always return the DOM to the caller of sanitize.
|
||||
RETURN_DOM: true,
|
||||
...options?.config,
|
||||
});
|
||||
purify.addHook(
|
||||
"afterSanitizeAttributes",
|
||||
sanitizeAttributes.bind(null, features)
|
||||
);
|
||||
purify.addHook("afterSanitizeAttributes", sanitizeAnchor);
|
||||
if (options?.normalize) {
|
||||
purify.addHook("afterSanitizeElements", (n) => {
|
||||
if (
|
||||
n.nodeType === Node.TEXT_NODE &&
|
||||
n.previousSibling?.nodeType === Node.TEXT_NODE
|
||||
) {
|
||||
// Merge text node sublings together.
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
n.parentNode?.normalize();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (options?.modify) {
|
||||
options.modify(purify);
|
||||
}
|
||||
|
||||
return purify.sanitize.bind(purify);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`allows anchor links 1`] = `
|
||||
Object {
|
||||
"body": "<a href=\\"http://test.com\\" target=\\"_blank\\" rel=\\"noopener noreferrer\\">http://test.com</a>",
|
||||
"linkCount": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`allows anchor tags and counts them correctly 1`] = `
|
||||
"
|
||||
<div>
|
||||
<a href=\\"https://mozilla.org/\\" target=\\"_blank\\" rel=\\"noopener noreferrer\\">https://mozilla.org/</a>
|
||||
<a href=\\"https://mozilla.org/\\" target=\\"_blank\\" rel=\\"noopener noreferrer\\">https://mozilla.org/</a>
|
||||
</div>
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`allows mailto links 1`] = `
|
||||
Object {
|
||||
"body": "<a href=\\"mailto:email@example.com\\" target=\\"_blank\\" rel=\\"noopener noreferrer\\">email@example.com</a>",
|
||||
"linkCount": 1,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`sanitizes out attributes not allowed 1`] = `
|
||||
Object {
|
||||
"body": "<div>Test</div>",
|
||||
"linkCount": 0,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`sanitizes out tags not allowed 1`] = `
|
||||
Object {
|
||||
"body": "",
|
||||
"linkCount": 0,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`sanitizes out tags not allowed 2`] = `
|
||||
Object {
|
||||
"body": "",
|
||||
"linkCount": 0,
|
||||
}
|
||||
`;
|
||||
@@ -1,64 +0,0 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import { createPurify, sanitizeCommentBody } from "./purify";
|
||||
|
||||
const window = new JSDOM("", {}).window;
|
||||
const DOMPurify = createPurify(window as any);
|
||||
|
||||
it("sanitizes out tags not allowed", () => {
|
||||
expect(
|
||||
sanitizeCommentBody(
|
||||
DOMPurify,
|
||||
"<script type=\"text/javascript\">alert('ok');</script>"
|
||||
)
|
||||
).toMatchSnapshot();
|
||||
|
||||
expect(
|
||||
sanitizeCommentBody(DOMPurify, "<script src=malicious-code.js></script>")
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("sanitizes out attributes not allowed", () => {
|
||||
expect(
|
||||
sanitizeCommentBody(DOMPurify, '<div id="test">Test</div>')
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("allows anchor links", () => {
|
||||
expect(
|
||||
sanitizeCommentBody(
|
||||
DOMPurify,
|
||||
'<a href="http://test.com">This is a link</a>'
|
||||
)
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("allows mailto links", () => {
|
||||
expect(
|
||||
sanitizeCommentBody(
|
||||
DOMPurify,
|
||||
'<a href="mailto:email@example.com">email@example.com</a>'
|
||||
)
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("allows anchor tags and counts them correctly", () => {
|
||||
const { body, linkCount } = sanitizeCommentBody(
|
||||
DOMPurify,
|
||||
`
|
||||
<div>
|
||||
<a href="https://mozilla.org/">Mozilla</a>
|
||||
<a href="https://mozilla.org/">Mozilla</a>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
|
||||
expect(body).toMatchSnapshot();
|
||||
expect(linkCount).toEqual(2);
|
||||
});
|
||||
|
||||
it("allows bolded tags", () => {
|
||||
const input = "A <b>bolded comment!</b>";
|
||||
const { body } = sanitizeCommentBody(DOMPurify, input);
|
||||
expect(body).toEqual(input);
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import createDOMPurify from "dompurify";
|
||||
|
||||
type DOMPurify = ReturnType<typeof createDOMPurify>;
|
||||
const MAILTO_PROTOCOL = "mailto:";
|
||||
|
||||
export function createPurify(window: Window, returnDOM = true) {
|
||||
// Initializing JSDOM and DOMPurify
|
||||
const purify = createDOMPurify(window);
|
||||
|
||||
// Setting our DOMPurify config.
|
||||
purify.setConfig({
|
||||
// Only forward anchor tags, bold, italics, blockquote, breaks, divs, and
|
||||
// spans.
|
||||
ALLOWED_TAGS: ["a", "b", "i", "blockquote", "br", "div", "span"],
|
||||
// Only allow href tags for anchor tags.
|
||||
ALLOWED_ATTR: ["href"],
|
||||
// Always return the DOM to the caller of sanitize.
|
||||
RETURN_DOM: returnDOM,
|
||||
});
|
||||
|
||||
// Ensure that each anchor tag has a "target" and "rel" attributes set, and
|
||||
// strip the "href" attribute from all non-anchor tags.
|
||||
purify.addHook("afterSanitizeAttributes", (node) => {
|
||||
if (node.nodeName === "A") {
|
||||
// Ensure we wrap all the links with the target + rel set.
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener noreferrer");
|
||||
|
||||
// Ensure that all the links have the same link as they do text.
|
||||
let href = node.getAttribute("href");
|
||||
if (href) {
|
||||
if (node.textContent !== href) {
|
||||
// remove "mailto:" prefix from link text
|
||||
const url = new URL(href);
|
||||
if (url.protocol === MAILTO_PROTOCOL) {
|
||||
href = href.replace(url.protocol, "");
|
||||
}
|
||||
}
|
||||
node.textContent = href;
|
||||
}
|
||||
} else {
|
||||
// The only tag that's allowed attributes is the "A" tag.
|
||||
node.removeAttribute("href");
|
||||
}
|
||||
});
|
||||
|
||||
return purify;
|
||||
}
|
||||
|
||||
export function sanitizeCommentBody(purify: DOMPurify, source: string) {
|
||||
// Sanitize and return the HTMLBodyElement for the parsed source.
|
||||
const sanitized = purify.sanitize(source, { RETURN_DOM: true });
|
||||
|
||||
// Count the total number of anchor links in the sanitized output, this is the
|
||||
// number of links.
|
||||
const linkCount = sanitized.getElementsByTagName("a").length;
|
||||
|
||||
return {
|
||||
body: sanitized.innerHTML,
|
||||
linkCount,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import striptags from "striptags";
|
||||
|
||||
import getHTMLPlainText from "coral-common/helpers/getHTMLPlainText";
|
||||
import { reconstructTenantURL } from "coral-server/app/url";
|
||||
import { sendToPerspective } from "coral-server/services/perspective";
|
||||
|
||||
@@ -94,7 +93,7 @@ export class PerspectiveCoralEventListener
|
||||
operation: "comments:suggestscore",
|
||||
locale: ctx.tenant.locale,
|
||||
body: {
|
||||
text: striptags(revision.body),
|
||||
text: getHTMLPlainText(revision.body),
|
||||
commentID: comment.id,
|
||||
commentParentID: comment.parentID,
|
||||
commentStatus: status,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import striptags from "striptags";
|
||||
|
||||
import getHTMLPlainText from "coral-common/helpers/getHTMLPlainText";
|
||||
import { reconstructTenantURL } from "coral-server/app/url";
|
||||
import GraphContext from "coral-server/graph/context";
|
||||
import { Comment, getLatestRevision } from "coral-server/models/comment";
|
||||
@@ -94,8 +93,7 @@ export default class SlackPublishEvent {
|
||||
);
|
||||
const commentLink = getURLWithCommentID(this.story.url, this.comment.id);
|
||||
|
||||
// Replace HTML link breaks with newlines.
|
||||
const body = striptags(getLatestRevision(this.comment).body);
|
||||
const body = getHTMLPlainText(getLatestRevision(this.comment).body);
|
||||
return [
|
||||
{
|
||||
type: "section",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { defaultRTEConfiguration } from "coral-server/models/settings";
|
||||
import {
|
||||
retrieveAnnouncementIfEnabled,
|
||||
Tenant,
|
||||
@@ -29,4 +30,5 @@ export const Settings: GQLSettingsTypeResolver<Tenant> = {
|
||||
return sites.edges.length > 1;
|
||||
},
|
||||
webhookEvents: () => Object.values(GQLWEBHOOK_EVENT_NAME),
|
||||
rte: ({ rte = defaultRTEConfiguration }) => rte,
|
||||
};
|
||||
|
||||
@@ -1508,6 +1508,22 @@ type Site {
|
||||
createdAt: Time!
|
||||
}
|
||||
|
||||
type RTEConfiguration {
|
||||
"""
|
||||
enabled when true turns on basic RTE features including
|
||||
bold, italic, quote, and bullet list.
|
||||
"""
|
||||
enabled: Boolean!
|
||||
"""
|
||||
strikethrough when true turns on the strikethrough feature.
|
||||
"""
|
||||
strikethrough: Boolean!
|
||||
"""
|
||||
spoiler when true turns on the spoiler feature.
|
||||
"""
|
||||
spoiler: Boolean!
|
||||
}
|
||||
|
||||
"""
|
||||
Settings stores the global settings for a given Tenant.
|
||||
"""
|
||||
@@ -1669,6 +1685,11 @@ type Settings {
|
||||
multisite is whether multiple sites exist for this tenant.
|
||||
"""
|
||||
multisite: Boolean!
|
||||
|
||||
"""
|
||||
rte is the configuration of the Rich-Text-Editor.
|
||||
"""
|
||||
rte: RTEConfiguration!
|
||||
}
|
||||
|
||||
################################################################################
|
||||
@@ -4138,6 +4159,26 @@ input NewCommentersConfigurationInput {
|
||||
approvedCommentsThreshold: Int
|
||||
}
|
||||
|
||||
|
||||
"""
|
||||
RTEConfigurationInput specifies the configuration for the rte.
|
||||
"""
|
||||
input RTEConfigurationInput {
|
||||
"""
|
||||
enabled when true turns on basic RTE features including
|
||||
bold, italic, quote, and bullet list.
|
||||
"""
|
||||
enabled: Boolean!
|
||||
"""
|
||||
strikethrough when true turns on the strikethrough feature.
|
||||
"""
|
||||
strikethrough: Boolean!
|
||||
"""
|
||||
spoiler when true turns on the spoiler feature.
|
||||
"""
|
||||
spoiler: Boolean!
|
||||
}
|
||||
|
||||
"""
|
||||
SettingsInput is the partial type of the Settings type for performing mutations.
|
||||
"""
|
||||
@@ -4257,8 +4298,14 @@ input SettingsInput {
|
||||
newCommenters is the configuration for how new commenters comments are treated.
|
||||
"""
|
||||
newCommenters: NewCommentersConfigurationInput
|
||||
|
||||
"""
|
||||
rte is the configuration of the Rich-Text-Editor.
|
||||
"""
|
||||
rte: RTEConfigurationInput
|
||||
}
|
||||
|
||||
|
||||
"""
|
||||
UpdateSettingsInput provides the input for the updateSettings Mutation.
|
||||
"""
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
GQLMODERATION_MODE,
|
||||
GQLOIDCAuthIntegration,
|
||||
GQLPerspectiveExternalIntegration,
|
||||
GQLRTEConfiguration,
|
||||
GQLSettings,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
@@ -221,4 +222,12 @@ export type Settings = GlobalModerationSettings &
|
||||
* newCommenters is the configuration for how new commenters comments are treated.
|
||||
*/
|
||||
newCommenters: NewCommentersConfiguration;
|
||||
|
||||
rte?: GQLRTEConfiguration;
|
||||
};
|
||||
|
||||
export const defaultRTEConfiguration: GQLRTEConfiguration = {
|
||||
enabled: true,
|
||||
spoiler: false,
|
||||
strikethrough: false,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import { DeepPartial, Sub } from "coral-common/types";
|
||||
import { isBeforeDate } from "coral-common/utils";
|
||||
import { dotize } from "coral-common/utils/dotize";
|
||||
import {
|
||||
defaultRTEConfiguration,
|
||||
generateSigningSecret,
|
||||
Settings,
|
||||
SigningSecretResource,
|
||||
@@ -263,6 +264,7 @@ export async function createTenant(
|
||||
slack: {
|
||||
channels: [],
|
||||
},
|
||||
rte: defaultRTEConfiguration,
|
||||
};
|
||||
|
||||
// Create the new Tenant by merging it together with the defaults.
|
||||
|
||||
@@ -11,19 +11,27 @@ import {
|
||||
|
||||
export const commentLength: IntermediateModerationPhase = ({
|
||||
tenant,
|
||||
htmlStripped,
|
||||
bodyText,
|
||||
}): IntermediatePhaseResult | void => {
|
||||
const length = htmlStripped.trim().length;
|
||||
const length = bodyText.length;
|
||||
let min: number | null = null;
|
||||
let max: number | null = null;
|
||||
if (tenant.charCount && tenant.charCount.enabled) {
|
||||
if (!isNil(tenant.charCount.min)) {
|
||||
if (length < tenant.charCount.min) {
|
||||
throw new CommentBodyTooShortError(tenant.charCount.min);
|
||||
}
|
||||
min = tenant.charCount.min;
|
||||
}
|
||||
if (!isNil(tenant.charCount.max)) {
|
||||
if (length > tenant.charCount.max) {
|
||||
throw new CommentBodyExceedsMaxLengthError(tenant.charCount.max);
|
||||
}
|
||||
max = tenant.charCount.max;
|
||||
}
|
||||
}
|
||||
if (!min) {
|
||||
// Comment body should have at least 1 character.
|
||||
min = 1;
|
||||
}
|
||||
if (length < min) {
|
||||
throw new CommentBodyTooShortError(min);
|
||||
}
|
||||
if (max && length > max) {
|
||||
throw new CommentBodyExceedsMaxLengthError(max);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -163,7 +163,7 @@ async function processPhase(
|
||||
mongo,
|
||||
action,
|
||||
comment,
|
||||
htmlStripped,
|
||||
bodyText,
|
||||
author,
|
||||
tenant,
|
||||
story,
|
||||
@@ -179,9 +179,7 @@ async function processPhase(
|
||||
body:
|
||||
// Depending on the selected format, the comment body could be in an
|
||||
// HTML or HTML stripped format.
|
||||
phase.format === GQLCOMMENT_BODY_FORMAT.HTML
|
||||
? comment.body
|
||||
: htmlStripped,
|
||||
phase.format === GQLCOMMENT_BODY_FORMAT.HTML ? comment.body : bodyText,
|
||||
// We're casting this to a `string | null` here because it's more
|
||||
// actionable to get a `null` rather than an undefined value in a
|
||||
// request.
|
||||
|
||||
@@ -1,19 +1,76 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import { memoize } from "lodash";
|
||||
|
||||
import { createPurify, sanitizeCommentBody } from "coral-common/utils/purify";
|
||||
import removeTrailingEmptyLines from "coral-common/helpers/removeTrailingEmptyLines";
|
||||
import {
|
||||
convertGQLRTEConfigToRTEFeatures,
|
||||
createSanitize,
|
||||
} from "coral-common/helpers/sanitize";
|
||||
import { defaultRTEConfiguration } from "coral-server/models/settings";
|
||||
import {
|
||||
IntermediateModerationPhase,
|
||||
IntermediatePhaseResult,
|
||||
} from "coral-server/services/comments/pipeline";
|
||||
|
||||
import { GQLRTEConfiguration } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
// Initializing JSDOM and DOMPurify
|
||||
const window = new JSDOM("", {}).window;
|
||||
const DOMPurify = createPurify(window as any);
|
||||
|
||||
/**
|
||||
* config2CacheKey returns a stable cache key from a `GQLRTEConfiguration` object.
|
||||
*/
|
||||
function config2CacheKey(config: GQLRTEConfiguration) {
|
||||
let ret = "";
|
||||
if (config.enabled) {
|
||||
ret += "e";
|
||||
}
|
||||
if (config.spoiler) {
|
||||
ret += "sp";
|
||||
}
|
||||
if (config.strikethrough) {
|
||||
ret += "st";
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
const createSanitizeMemoized = memoize(
|
||||
(config: GQLRTEConfiguration) => {
|
||||
return createSanitize(window as any, {
|
||||
features: convertGQLRTEConfigToRTEFeatures(config),
|
||||
});
|
||||
},
|
||||
// cache key resolver.
|
||||
config2CacheKey
|
||||
);
|
||||
|
||||
function sanitizeCommentBody(
|
||||
config: GQLRTEConfiguration = defaultRTEConfiguration,
|
||||
source: string
|
||||
) {
|
||||
const sanitize = createSanitizeMemoized(config);
|
||||
|
||||
// Sanitize and return the HTMLBodyElement for the parsed source.
|
||||
const sanitized = sanitize(source);
|
||||
|
||||
// Count the total number of anchor links in the sanitized output, this is the
|
||||
// number of links.
|
||||
const linkCount = sanitized.getElementsByTagName("a").length;
|
||||
|
||||
// Remove empty trailing lines.
|
||||
removeTrailingEmptyLines(sanitized);
|
||||
|
||||
return {
|
||||
body: sanitized.innerHTML,
|
||||
linkCount,
|
||||
};
|
||||
}
|
||||
|
||||
export const purify: IntermediateModerationPhase = async ({
|
||||
comment,
|
||||
tenant,
|
||||
}): Promise<IntermediatePhaseResult | void> => {
|
||||
const { body, linkCount } = sanitizeCommentBody(DOMPurify, comment.body);
|
||||
const { body, linkCount } = sanitizeCommentBody(tenant.rte, comment.body);
|
||||
|
||||
return {
|
||||
body,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import striptags from "striptags";
|
||||
|
||||
import getHTMLPlainText from "coral-common/helpers/getHTMLPlainText";
|
||||
import { RepeatPostCommentError } from "coral-server/errors";
|
||||
import { ACTION_TYPE } from "coral-server/models/action/comment";
|
||||
import { getLatestRevision } from "coral-server/models/comment/helpers";
|
||||
@@ -16,14 +15,14 @@ import {
|
||||
|
||||
export const repeatPost: IntermediateModerationPhase = async ({
|
||||
mongo,
|
||||
htmlStripped,
|
||||
bodyText,
|
||||
tenant,
|
||||
author,
|
||||
nudge,
|
||||
redis,
|
||||
log,
|
||||
}): Promise<IntermediatePhaseResult | void> => {
|
||||
if (!htmlStripped) {
|
||||
if (!bodyText) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -43,13 +42,16 @@ export const repeatPost: IntermediateModerationPhase = async ({
|
||||
return;
|
||||
}
|
||||
|
||||
const revision = striptags(getLatestRevision(lastComment).body);
|
||||
const revision = getHTMLPlainText(
|
||||
getLatestRevision(lastComment).body
|
||||
).trim();
|
||||
const compareTo = bodyText.trim();
|
||||
|
||||
// Calculate the comment similarity. At the moment, we only do a string
|
||||
// comparison, so it's either completely equal (they match) or the
|
||||
// similarity can't be determined (null). This gives us room in the future
|
||||
// to include a percentage matching.
|
||||
const similarity = revision.trim() === htmlStripped.trim() ? 1 : null;
|
||||
const similarity = revision === compareTo ? 1 : null;
|
||||
|
||||
if (similarity) {
|
||||
log.trace({ similarity }, "comment contains repeat content");
|
||||
|
||||
@@ -24,16 +24,16 @@ export const toxic: IntermediateModerationPhase = async ({
|
||||
tenant,
|
||||
nudge,
|
||||
log,
|
||||
htmlStripped,
|
||||
bodyText,
|
||||
config,
|
||||
// FEATURE_FLAG:DISABLE_WARN_USER_OF_TOXIC_COMMENT
|
||||
comment: { body },
|
||||
}: Pick<
|
||||
ModerationPhaseContext,
|
||||
// FEATURE_FLAG:DISABLE_WARN_USER_OF_TOXIC_COMMENT
|
||||
"tenant" | "nudge" | "log" | "htmlStripped" | "comment" | "config"
|
||||
"tenant" | "nudge" | "log" | "bodyText" | "comment" | "config"
|
||||
>): Promise<IntermediatePhaseResult | void> => {
|
||||
if (!htmlStripped) {
|
||||
if (!bodyText) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export const toxic: IntermediateModerationPhase = async ({
|
||||
operation: "comments:analyze",
|
||||
locale: tenant.locale,
|
||||
body: {
|
||||
text: htmlStripped,
|
||||
text: bodyText,
|
||||
doNotStore,
|
||||
model,
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@ const list = new WordList();
|
||||
export const wordList: IntermediateModerationPhase = ({
|
||||
tenant,
|
||||
comment,
|
||||
htmlStripped,
|
||||
bodyText,
|
||||
}): IntermediatePhaseResult | void => {
|
||||
// If there isn't a body, there can't be a bad word!
|
||||
if (!comment.body) {
|
||||
@@ -29,7 +29,7 @@ export const wordList: IntermediateModerationPhase = ({
|
||||
// has pre-mod enabled or not. If the comment was rejected based on the
|
||||
// wordList, then reject it, otherwise if the moderation setting is
|
||||
// premod, set it to `premod`.
|
||||
if (list.test(tenant, "banned", htmlStripped)) {
|
||||
if (list.test(tenant, "banned", bodyText)) {
|
||||
// Add the flag related to Trust to the comment.
|
||||
return {
|
||||
status: GQLCOMMENT_STATUS.REJECTED,
|
||||
@@ -48,7 +48,7 @@ export const wordList: IntermediateModerationPhase = ({
|
||||
|
||||
// If the wordList has matched the suspect word filter and we haven't disabled
|
||||
// auto-flagging suspect words, then we should flag the comment!
|
||||
if (list.test(tenant, "suspect", htmlStripped)) {
|
||||
if (list.test(tenant, "suspect", bodyText)) {
|
||||
return {
|
||||
actions: [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Db } from "mongodb";
|
||||
import striptags from "striptags";
|
||||
|
||||
import getHTMLPlainText from "coral-common/helpers/getHTMLPlainText";
|
||||
import { Promiseable, RequireProperty } from "coral-common/types";
|
||||
import { Config } from "coral-server/config";
|
||||
import { Logger } from "coral-server/logger";
|
||||
@@ -74,9 +74,9 @@ export interface ModerationPhaseContextInput {
|
||||
|
||||
export interface ModerationPhaseContext extends ModerationPhaseContextInput {
|
||||
/**
|
||||
* htmlStripped is the HTML stripped version of the comment body.
|
||||
* bodyText is a text version of the comment body.
|
||||
*/
|
||||
htmlStripped: string;
|
||||
bodyText: string;
|
||||
}
|
||||
|
||||
export type RootModerationPhase = (
|
||||
@@ -116,9 +116,9 @@ export const compose = (
|
||||
tags: [],
|
||||
};
|
||||
|
||||
// Strip the tags from the comment body so that filters that can't process
|
||||
// Get text representation of the comment body so that filters that can't process
|
||||
// HTML can reuse it.
|
||||
const htmlStripped = striptags(final.body);
|
||||
const bodyText = getHTMLPlainText(final.body);
|
||||
|
||||
// Loop over all the moderation phases and see if we've resolved the status.
|
||||
for (const phase of phases) {
|
||||
@@ -129,7 +129,7 @@ export const compose = (
|
||||
body: final.body,
|
||||
},
|
||||
tags: final.tags,
|
||||
htmlStripped,
|
||||
bodyText,
|
||||
metadata: final.metadata,
|
||||
});
|
||||
if (result) {
|
||||
|
||||
@@ -52,7 +52,7 @@ type Request =
|
||||
locale: LanguageCode;
|
||||
body: {
|
||||
/**
|
||||
* text is htmlStripped version of the comment text.
|
||||
* text respresentation of the comment html body.
|
||||
*/
|
||||
text: string;
|
||||
doNotStore: boolean;
|
||||
@@ -64,7 +64,7 @@ type Request =
|
||||
locale: LanguageCode;
|
||||
body: {
|
||||
/**
|
||||
* text is htmlStripped version of the comment text.
|
||||
* text respresentation of the comment html body.
|
||||
*/
|
||||
text: string;
|
||||
commentID: string;
|
||||
|
||||
@@ -1176,6 +1176,17 @@ configure-general-staff-input =
|
||||
.placeholder = E.g. Staff
|
||||
configure-general-staff-preview = Preview
|
||||
|
||||
configure-general-rte-title = Rich-text comments
|
||||
configure-general-rte-express = Give your community more ways to express themselves beyond plain text with rich-text formatting.
|
||||
configure-general-rte-richTextComments = Rich-text comments
|
||||
configure-general-rte-onBasicFeatures = On - bold, italics, block quotes, and bulletted lists
|
||||
configure-general-rte-additional = Additional rich-text options
|
||||
configure-general-rte-strikethrough = Strikethrough
|
||||
configure-general-rte-spoiler = Spoiler
|
||||
configure-general-rte-spoilerDesc =
|
||||
Words and phrases formatted as Spoiler are hidden behind a
|
||||
dark background until the reader chooses to reveal the text.
|
||||
|
||||
configure-account-features-title = Commenter account management features
|
||||
configure-account-features-explanation =
|
||||
You can enable and disable certain features for your commenters to use
|
||||
|
||||
@@ -75,6 +75,14 @@ comments-rte-italic =
|
||||
comments-rte-blockquote =
|
||||
.title = Blockquote
|
||||
|
||||
comments-rte-bulletedList =
|
||||
.title = Bulleted List
|
||||
|
||||
comments-rte-strikethrough =
|
||||
.title = Strikethrough
|
||||
|
||||
comments-rte-spoiler = Spoiler
|
||||
|
||||
comments-remainingCharacters = { $remaining } characters remaining
|
||||
|
||||
comments-postCommentFormFake-signInAndJoin = Sign in and Join the Conversation
|
||||
|
||||
Reference in New Issue
Block a user