mirror of
https://github.com/wassname/talk.git
synced 2026-08-16 11:29:31 +08:00
[next] Moderate (#2118)
* fix: load .env before building / watching * feat: Implement AppBar, Brand, and SubBar * feat: add card ui component * feat: add modqueue components * feat: implement modqueue * feat: add translations * test: add unit tests * feat: single comment view * test: feature / integration tests for modqueue * test: fix remaining tests * feature: support TextMatchOptions * fix: remove body count marker * fix: remove accidently added package * feat: testHelper toJSON * chore: cleanup + comments * chore: better types * test: fix test * chore: refactor decision history test * chore: tiny fix * fix: adjust to recent server changes * fix: marking suspect and banned words * feat: added moderation queue edge to accept/reject comment payloads - Simplified moderationQueue returns - Simplified resolvers * feat: update counts * feat: added id's to moderation queue and settings * fix+test: test count changes, apply fix * chore: adapt to server change, and remove custom mutation handlers * fix: use common utils * fix: purify fix, babel fix * fix: workaround css treeshake issue and upgrade css plugins * fix: fixed snapshot * fix: support empty word lists * feat: separate client config
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { createContextHOC } from "talk-framework/helpers";
|
||||
import ensurePolyfill from "./ensurePolyfill";
|
||||
|
||||
export type IntersectionCallback = (entry: IntersectionObserverEntry) => void;
|
||||
export type Observe = (
|
||||
target: Element,
|
||||
callback: IntersectionCallback
|
||||
) => () => void;
|
||||
export interface IntersectionContext {
|
||||
observe: Observe;
|
||||
}
|
||||
|
||||
const { Provider, Consumer } = React.createContext<IntersectionContext>(
|
||||
{} as any
|
||||
);
|
||||
export const IntersectionConsumer = Consumer;
|
||||
|
||||
export class IntersectionProvider extends React.Component<any, any> {
|
||||
private observer: IntersectionObserver;
|
||||
private elements = new Map();
|
||||
private elementBuffer: Element[] = [];
|
||||
private unmounted = false;
|
||||
|
||||
public componentDidMount() {
|
||||
ensurePolyfill().then(() => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
this.observer = new IntersectionObserver(this.onIntersect, {
|
||||
root: this.props.node ? this.props.node : undefined,
|
||||
rootMargin: "0px",
|
||||
threshold: 0.25,
|
||||
});
|
||||
this.elementBuffer.forEach(element => this.observer.observe(element));
|
||||
this.elementBuffer = [];
|
||||
});
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.unmounted = true;
|
||||
}
|
||||
|
||||
private unobserve = (element: Element) => {
|
||||
this.elements.delete(element);
|
||||
if (!this.observer) {
|
||||
this.elementBuffer = this.elementBuffer.filter(e => e !== element);
|
||||
} else {
|
||||
this.observer.unobserve(element);
|
||||
}
|
||||
};
|
||||
|
||||
private observe: Observe = (element, callback) => {
|
||||
this.elements.set(element, callback);
|
||||
// this funny bit to handle react's lifecycle order and also wait
|
||||
// for polyfill.
|
||||
if (!this.observer) {
|
||||
this.elementBuffer.push(element);
|
||||
} else {
|
||||
this.observer.observe(element);
|
||||
}
|
||||
return () => this.unobserve(element);
|
||||
};
|
||||
|
||||
private onIntersect = (
|
||||
entries: IntersectionObserverEntry[],
|
||||
observer: IntersectionObserver
|
||||
) => {
|
||||
entries.forEach(entry => this.elements.get(entry.target)(entry));
|
||||
};
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<Provider value={{ observe: this.observe }}>
|
||||
{this.props.children}
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const withIntersectionContext = createContextHOC<IntersectionContext>(
|
||||
"withContext",
|
||||
IntersectionConsumer
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Loads intersection-observer polyfill if it doesn't exist.
|
||||
*/
|
||||
export default async function ensurePolyfill() {
|
||||
if (!(window as any).IntersectionObserver) {
|
||||
await import("intersection-observer");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
IntersectionProvider,
|
||||
Observe,
|
||||
withIntersectionContext,
|
||||
} from "./IntersectionContext";
|
||||
export { default as withInView } from "./withInView";
|
||||
export { default as ensurePolyfill } from "./ensurePolyfill";
|
||||
@@ -0,0 +1,81 @@
|
||||
import * as React from "react";
|
||||
import { DefaultingInferableComponentEnhancer, hoistStatics } from "recompose";
|
||||
|
||||
import { Observe, withIntersectionContext } from "./IntersectionContext";
|
||||
|
||||
interface InjectedProps {
|
||||
inView: boolean | undefined;
|
||||
intersectionRef: React.Ref<any>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
observe: Observe;
|
||||
}
|
||||
|
||||
interface State {
|
||||
inView: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* withInView provides a property `inView: boolean`
|
||||
* to indicate whether or not the referenced element is
|
||||
* in the current browser view.
|
||||
*/
|
||||
const withInView: DefaultingInferableComponentEnhancer<
|
||||
InjectedProps
|
||||
> = hoistStatics<InjectedProps>(
|
||||
<T extends InjectedProps>(BaseComponent: React.ComponentType<T>) => {
|
||||
class WithInView extends React.Component<Props, State> {
|
||||
private unobserve: (() => void) | null = null;
|
||||
|
||||
public state = {
|
||||
inView: undefined,
|
||||
};
|
||||
|
||||
private changeRef = (ref: any) => {
|
||||
if (this.unobserve) {
|
||||
this.unobserve();
|
||||
this.unobserve = null;
|
||||
}
|
||||
if (ref) {
|
||||
this.unobserve = this.props.observe(
|
||||
ref,
|
||||
({ intersectionRatio }: any) => {
|
||||
// Callback is called whenever we run observe.
|
||||
if (this.state.inView === undefined) {
|
||||
this.setState({ inView: intersectionRatio > 0 });
|
||||
} else {
|
||||
this.setState(s => ({
|
||||
inView: !s.inView,
|
||||
}));
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.unobserve) {
|
||||
this.unobserve();
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<BaseComponent
|
||||
{...this.props}
|
||||
inView={this.state.inView}
|
||||
intersectionRef={this.changeRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withIntersectionContext(({ observe }) => ({ observe }))(
|
||||
WithInView
|
||||
);
|
||||
return enhanced as React.ComponentType<any>;
|
||||
}
|
||||
);
|
||||
|
||||
export default withInView;
|
||||
@@ -25,8 +25,7 @@ export async function commitMutationPromiseNormalized<T extends OperationBase>(
|
||||
config: MutationPromiseConfig<T>
|
||||
): Promise<T["response"][keyof T["response"]]> {
|
||||
try {
|
||||
const response = await commitMutationPromise(environment, config);
|
||||
return extractPayload(response);
|
||||
return await commitMutationPromise(environment, config);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -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["aria-label"]) {
|
||||
return false;
|
||||
}
|
||||
return matchText(pattern, i.props["aria-label"], {
|
||||
collapseWhitespace: false,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export function getByLabelText(
|
||||
container: ReactTestInstance,
|
||||
pattern: TextMatchPattern,
|
||||
options?: TextMatchOptions
|
||||
) {
|
||||
return container.find(matcher(pattern, options));
|
||||
}
|
||||
|
||||
export function queryByLabelText(
|
||||
container: ReactTestInstance,
|
||||
pattern: TextMatchPattern,
|
||||
options?: TextMatchOptions
|
||||
) {
|
||||
try {
|
||||
return container.find(matcher(pattern, options));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function queryAllByLabelText(
|
||||
container: ReactTestInstance,
|
||||
pattern: TextMatchPattern,
|
||||
options?: TextMatchOptions
|
||||
) {
|
||||
try {
|
||||
return container.findAll(matcher(pattern, options));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getAllByLabelText(
|
||||
container: ReactTestInstance,
|
||||
pattern: TextMatchPattern,
|
||||
options?: TextMatchOptions
|
||||
) {
|
||||
return container.findAll(matcher(pattern, options));
|
||||
}
|
||||
@@ -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["data-test"]) {
|
||||
return false;
|
||||
}
|
||||
return matchText(pattern, i.props["data-test"], {
|
||||
collapseWhitespace: false,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export function getByTestID(
|
||||
container: ReactTestInstance,
|
||||
pattern: TextMatchPattern,
|
||||
options?: TextMatchOptions
|
||||
) {
|
||||
return container.find(matcher(pattern, options));
|
||||
}
|
||||
|
||||
export function queryByTestID(
|
||||
container: ReactTestInstance,
|
||||
pattern: TextMatchPattern,
|
||||
options?: TextMatchOptions
|
||||
) {
|
||||
try {
|
||||
return container.find(matcher(pattern, options));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function queryAllByTestID(
|
||||
container: ReactTestInstance,
|
||||
pattern: TextMatchPattern,
|
||||
options?: TextMatchOptions
|
||||
) {
|
||||
try {
|
||||
return container.findAll(matcher(pattern, options));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getAllByTestID(
|
||||
container: ReactTestInstance,
|
||||
pattern: TextMatchPattern,
|
||||
options?: TextMatchOptions
|
||||
) {
|
||||
return container.findAll(matcher(pattern, options));
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from "react";
|
||||
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") {
|
||||
return false;
|
||||
}
|
||||
if (!i.props.children) {
|
||||
return false;
|
||||
}
|
||||
const children = React.Children.toArray(i.props.children);
|
||||
for (const c of children) {
|
||||
if (typeof c === "string" && matchText(pattern, c, options)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export function getByText(
|
||||
container: ReactTestInstance,
|
||||
pattern: TextMatchPattern,
|
||||
options?: TextMatchOptions
|
||||
) {
|
||||
return container.find(matcher(pattern, options));
|
||||
}
|
||||
|
||||
export function queryByText(
|
||||
container: ReactTestInstance,
|
||||
pattern: TextMatchPattern,
|
||||
options?: TextMatchOptions
|
||||
) {
|
||||
try {
|
||||
return container.find(matcher(pattern, options));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function queryAllByText(
|
||||
container: ReactTestInstance,
|
||||
pattern: TextMatchPattern,
|
||||
options?: TextMatchOptions
|
||||
) {
|
||||
try {
|
||||
return container.findAll(matcher(pattern, options));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getAllByText(
|
||||
container: ReactTestInstance,
|
||||
pattern: TextMatchPattern,
|
||||
options?: TextMatchOptions
|
||||
) {
|
||||
return container.findAll(matcher(pattern, options));
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ReactTestInstance } from "react-test-renderer";
|
||||
|
||||
export default function getByTestID(id: string, instance: ReactTestInstance) {
|
||||
return instance.findByProps({ "data-test": id });
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from "react";
|
||||
import { ReactTestInstance } from "react-test-renderer";
|
||||
|
||||
export default function getByText(text: string, instance: ReactTestInstance) {
|
||||
return instance.find(i => {
|
||||
if (!i.props.children) {
|
||||
return false;
|
||||
}
|
||||
const children = React.Children.toArray(i.props.children);
|
||||
for (const c of children) {
|
||||
if (
|
||||
typeof c === "string" &&
|
||||
c.toLowerCase().includes(text.toLowerCase())
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,22 @@ export * from "./denormalize";
|
||||
export { default as replaceHistoryLocation } from "./replaceHistoryLocation";
|
||||
export { default as limitSnapshotTo } from "./limitSnapshotTo";
|
||||
export { default as inputPredicate } from "./inputPredicate";
|
||||
export { default as getByTestID } from "./getByTestID";
|
||||
export { default as getByText } from "./getByText";
|
||||
export {
|
||||
getByTestID,
|
||||
getAllByTestID,
|
||||
queryByTestID,
|
||||
queryAllByTestID,
|
||||
} from "./byTestID";
|
||||
export { getByText, getAllByText, queryByText, queryAllByText } from "./byText";
|
||||
export {
|
||||
getByLabelText,
|
||||
getAllByLabelText,
|
||||
queryByLabelText,
|
||||
queryAllByLabelText,
|
||||
} from "./byLabelText";
|
||||
export { default as within } from "./within";
|
||||
export { default as wait } from "./wait";
|
||||
export { default as waitForElement } from "./waitForElement";
|
||||
export { default as waitUntilThrow } from "./waitUntilThrow";
|
||||
export { default as matchText } from "./matchText";
|
||||
export { default as toJSON } from "./toJSON";
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
export interface TextMatchOptions {
|
||||
exact?: boolean; // defaults to true
|
||||
collapseWhitespace?: boolean; // defaults to true
|
||||
trim?: boolean; // defaults to true
|
||||
}
|
||||
|
||||
export type TextMatchPattern = string | RegExp;
|
||||
|
||||
export default function matchText(
|
||||
pattern: TextMatchPattern,
|
||||
text: string,
|
||||
options: TextMatchOptions = {}
|
||||
) {
|
||||
if (typeof pattern === "string") {
|
||||
let a = text;
|
||||
let b = pattern;
|
||||
if (options.trim || options.trim === undefined) {
|
||||
a = a.trim();
|
||||
b = b.trim();
|
||||
}
|
||||
if (
|
||||
options.collapseWhitespace ||
|
||||
options.collapseWhitespace === undefined
|
||||
) {
|
||||
a = a.replace(/\s+/g, " ");
|
||||
b = b.replace(/\s+/g, " ");
|
||||
}
|
||||
if (options.exact || options.exact === undefined) {
|
||||
return a === b;
|
||||
}
|
||||
return text.toLowerCase().includes(pattern.toLowerCase());
|
||||
}
|
||||
return pattern.test(text);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ReactTestInstance } from "react-test-renderer";
|
||||
|
||||
interface ReactTestRendererJSON {
|
||||
type: string;
|
||||
props: { [propName: string]: any };
|
||||
children: null | ReactTestRendererNode[];
|
||||
$$typeof?: symbol; // Optional because we add it with defineProperty().
|
||||
}
|
||||
type ReactTestRendererNode = ReactTestRendererJSON | string;
|
||||
|
||||
export function toJSONRecursive(
|
||||
inst: ReactTestInstance
|
||||
): ReactTestRendererNode[] | null {
|
||||
const { children: _, ...props }: any = inst.props || {};
|
||||
let renderedChildren = null;
|
||||
if (inst.children) {
|
||||
for (const child of inst.children) {
|
||||
const renderedChild =
|
||||
typeof child === "string" ? [child] : toJSONRecursive(child);
|
||||
if (renderedChild !== null) {
|
||||
if (renderedChildren === null) {
|
||||
renderedChildren = [...renderedChild];
|
||||
} else {
|
||||
renderedChildren.push(...renderedChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof inst.type === "string") {
|
||||
const json: ReactTestRendererJSON = {
|
||||
type: inst.type,
|
||||
props,
|
||||
children: renderedChildren,
|
||||
};
|
||||
Object.defineProperty(json, "$$typeof", {
|
||||
value: Symbol.for("react.test.json"),
|
||||
});
|
||||
return [json];
|
||||
}
|
||||
return renderedChildren;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a ReactTestInstance into JSON for snapshotting purposes.
|
||||
*/
|
||||
export default function toJSON(
|
||||
inst: ReactTestInstance
|
||||
): ReactTestRendererNode | ReactTestRendererNode[] | null {
|
||||
const result = toJSONRecursive(inst);
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
if (result.length === 1) {
|
||||
return result[0];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ReactTestInstance } from "react-test-renderer";
|
||||
|
||||
import wait from "./wait";
|
||||
|
||||
interface Options {
|
||||
timeout?: number;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
export default async function waitUntilThrow(
|
||||
callback: () => ReactTestInstance | null,
|
||||
options?: Options
|
||||
): Promise<void> {
|
||||
await wait(() => {
|
||||
expect(callback).toThrow();
|
||||
}, options);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ReactTestInstance } from "react-test-renderer";
|
||||
|
||||
import {
|
||||
getAllByLabelText,
|
||||
getByLabelText,
|
||||
queryAllByLabelText,
|
||||
queryByLabelText,
|
||||
} from "./byLabelText";
|
||||
import {
|
||||
getAllByTestID,
|
||||
getByTestID,
|
||||
queryAllByTestID,
|
||||
queryByTestID,
|
||||
} from "./byTestID";
|
||||
import { getAllByText, getByText, queryAllByText, queryByText } from "./byText";
|
||||
import toJSON from "./toJSON";
|
||||
|
||||
type Func0<R> = () => R;
|
||||
type Func1<A, R> = (a?: A) => R;
|
||||
type Func2<A, B, R> = (a: A, b?: B) => R;
|
||||
type Func3<A, B, C, R> = (a: A, b: B, c?: C) => R;
|
||||
|
||||
type RemoveFirstArgument<T, R> =
|
||||
T extends [any, any, any, any?] ? Func3<T[1], T[2], T[3], R> :
|
||||
T extends [any, any, any?] ? Func2<T[1], T[2], R> :
|
||||
T extends [any, any?] ? Func1<T[1], R> :
|
||||
T extends [any] ? Func0<R> :
|
||||
unknown
|
||||
;
|
||||
|
||||
// tslint:disable
|
||||
// @TODO: currently tslint fails to parse this: `...any[]`.
|
||||
function applyContainer<T extends [ReactTestInstance, ...any[]], R>(container: ReactTestInstance, fn: (...args: T) => R): RemoveFirstArgument<T, R> {
|
||||
return ((...args: any[]) => fn(...[container, ...args] as any)) as any;
|
||||
}
|
||||
// tslint:enable
|
||||
|
||||
export default function within(container: ReactTestInstance) {
|
||||
return {
|
||||
getByTestID: applyContainer(container, getByTestID),
|
||||
getAllByTestID: applyContainer(container, getAllByTestID),
|
||||
queryByTestID: applyContainer(container, queryByTestID),
|
||||
queryAllByTestID: applyContainer(container, queryAllByTestID),
|
||||
getByText: applyContainer(container, getByText),
|
||||
getAllByText: applyContainer(container, getAllByText),
|
||||
queryByText: applyContainer(container, queryByText),
|
||||
queryAllByText: applyContainer(container, queryAllByText),
|
||||
getByLabelText: applyContainer(container, getByLabelText),
|
||||
getAllByLabelText: applyContainer(container, getAllByLabelText),
|
||||
queryByLabelText: applyContainer(container, queryByLabelText),
|
||||
queryAllByLabelText: applyContainer(container, queryAllByLabelText),
|
||||
toJSON: () => toJSON(container),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user