mirror of
https://github.com/wassname/talk.git
synced 2026-07-18 12:40:13 +08:00
[CORL-166] Live Updates on Mod Queues (#2368)
* feat: client implementation of subscriptions and modqueue live counts * fix: unit tests * feat: live status update in moderation * feat: live update of new comments in moderation * chore: View New instead of View More * feat: fade in transition for new comments * chore: turn websocket proxy back on * feat: initial server impl * fix: make it work :-) * fix: add box shadow * chore: make test subscriptions only support 1 top level field following the spec * fix: linting * feat: support clientID * fix: linting * feat: support commentStatusUpdated subscription * fix: disabled styles for approve and reject button * feat: show moderated by system and update flags * feat: support metrics recording on websocket connections * fix: handle when same comment enters but leaves again
This commit is contained in:
@@ -5,7 +5,7 @@ import { Child as PymChild } from "pym.js";
|
||||
import React, { Component, ComponentType } from "react";
|
||||
import { Formatter } from "react-timeago";
|
||||
import { Environment, RecordSource, Store } from "relay-runtime";
|
||||
import uuid from "uuid/v4";
|
||||
import uuid from "uuid/v1";
|
||||
|
||||
import { getBrowserInfo } from "coral-framework/lib/browserInfo";
|
||||
import { LOCAL_ID } from "coral-framework/lib/relay";
|
||||
@@ -21,7 +21,12 @@ import { RestClient } from "coral-framework/lib/rest";
|
||||
import { ClickFarAwayRegister } from "coral-ui/components/ClickOutside";
|
||||
|
||||
import { generateBundles, LocalesData, negotiateLanguages } from "../i18n";
|
||||
import { createNetwork, TokenGetter } from "../network";
|
||||
import {
|
||||
createManagedSubscriptionClient,
|
||||
createNetwork,
|
||||
ManagedSubscriptionClient,
|
||||
TokenGetter,
|
||||
} from "../network";
|
||||
import { PostMessageService } from "../postMessage";
|
||||
import { CoralContext, CoralContextProvider } from "./CoralContext";
|
||||
import SendPymReady from "./SendPymReady";
|
||||
@@ -48,6 +53,11 @@ interface CreateContextArguments {
|
||||
eventEmitter?: EventEmitter2;
|
||||
}
|
||||
|
||||
/** websocketURL points to our live graphql server */
|
||||
const websocketURL = `${location.protocol === "https:" ? "wss" : "ws"}://${
|
||||
location.hostname
|
||||
}:${location.port}/api/graphql/live`;
|
||||
|
||||
/**
|
||||
* timeagoFormatter integrates timeago into our translation
|
||||
* framework. It gets injected into the UIContext.
|
||||
@@ -87,8 +97,10 @@ function areWeInIframe() {
|
||||
}
|
||||
}
|
||||
|
||||
function createRelayEnvironment() {
|
||||
// Initialize Relay.
|
||||
function createRelayEnvironment(
|
||||
subscriptionClient: ManagedSubscriptionClient,
|
||||
clientID: string
|
||||
) {
|
||||
const source = new RecordSource();
|
||||
const tokenGetter: TokenGetter = () => {
|
||||
const localState = source.get(LOCAL_ID);
|
||||
@@ -98,14 +110,14 @@ function createRelayEnvironment() {
|
||||
return "";
|
||||
};
|
||||
const environment = new Environment({
|
||||
network: createNetwork(tokenGetter),
|
||||
network: createNetwork(subscriptionClient, tokenGetter, clientID),
|
||||
store: new Store(source),
|
||||
});
|
||||
return { environment, tokenGetter };
|
||||
return { environment, tokenGetter, subscriptionClient };
|
||||
}
|
||||
|
||||
function createRestClient(tokenGetter: () => string) {
|
||||
return new RestClient("/api", tokenGetter);
|
||||
function createRestClient(tokenGetter: () => string, clientID: string) {
|
||||
return new RestClient("/api", tokenGetter, clientID);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,6 +126,8 @@ function createRestClient(tokenGetter: () => string) {
|
||||
*/
|
||||
function createMangedCoralContextProvider(
|
||||
context: CoralContext,
|
||||
subscriptionClient: ManagedSubscriptionClient,
|
||||
clientID: string,
|
||||
initLocalState: InitLocalState
|
||||
) {
|
||||
const ManagedCoralContextProvider = class extends Component<
|
||||
@@ -135,25 +149,40 @@ function createMangedCoralContextProvider(
|
||||
// Clear session storage.
|
||||
this.state.context.sessionStorage.clear();
|
||||
|
||||
// Pause subscriptions.
|
||||
subscriptionClient.pause();
|
||||
|
||||
// Create a new context with a new Relay Environment.
|
||||
const {
|
||||
environment: newEnvironment,
|
||||
tokenGetter: newTokenGetter,
|
||||
} = createRelayEnvironment();
|
||||
} = createRelayEnvironment(subscriptionClient, clientID);
|
||||
|
||||
const newContext = {
|
||||
...this.state.context,
|
||||
relayEnvironment: newEnvironment,
|
||||
rest: createRestClient(newTokenGetter),
|
||||
rest: createRestClient(newTokenGetter, clientID),
|
||||
};
|
||||
|
||||
// Initialize local state.
|
||||
await initLocalState(newContext.relayEnvironment, newContext);
|
||||
|
||||
// Set new token for the websocket connection.
|
||||
// TODO: (cvle) dynamically reset when token changes.
|
||||
// ^ only necessary when we can prolong existing session using
|
||||
// a new token.
|
||||
subscriptionClient.setAccessToken(newTokenGetter());
|
||||
|
||||
// Propagate new context.
|
||||
this.setState({
|
||||
context: newContext,
|
||||
});
|
||||
this.setState(
|
||||
{
|
||||
context: newContext,
|
||||
},
|
||||
() => {
|
||||
// Resume subscriptions after context has changed.
|
||||
subscriptionClient.resume();
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
public render() {
|
||||
@@ -233,7 +262,18 @@ export default async function createManaged({
|
||||
const localStorage = resolveLocalStorage(pym);
|
||||
const sessionStorage = resolveSessionStorage(pym);
|
||||
|
||||
const { environment, tokenGetter } = createRelayEnvironment();
|
||||
/** clientID is sent to the server with every request */
|
||||
const clientID = uuid();
|
||||
|
||||
const subscriptionClient = createManagedSubscriptionClient(
|
||||
websocketURL,
|
||||
clientID
|
||||
);
|
||||
|
||||
const { environment, tokenGetter } = createRelayEnvironment(
|
||||
subscriptionClient,
|
||||
clientID
|
||||
);
|
||||
|
||||
// Assemble context.
|
||||
const context: CoralContext = {
|
||||
@@ -244,7 +284,7 @@ export default async function createManaged({
|
||||
pym,
|
||||
eventEmitter,
|
||||
registerClickFarAway,
|
||||
rest: createRestClient(tokenGetter),
|
||||
rest: createRestClient(tokenGetter, clientID),
|
||||
postMessage: new PostMessageService(),
|
||||
localStorage,
|
||||
sessionStorage,
|
||||
@@ -258,7 +298,18 @@ export default async function createManaged({
|
||||
// Initialize local state.
|
||||
await initLocalState(context.relayEnvironment, context);
|
||||
|
||||
// Set current token for the websocket connection.
|
||||
// TODO: (cvle) dynamically reset when token changes.
|
||||
// ^ only necessary when we can prolong existing session using
|
||||
// a new token.
|
||||
subscriptionClient.setAccessToken(tokenGetter());
|
||||
|
||||
// Returns a managed CoralContextProvider, that includes the above
|
||||
// context and handles context changes, e.g. when a user session changes.
|
||||
return createMangedCoralContextProvider(context, initLocalState);
|
||||
return createMangedCoralContextProvider(
|
||||
context,
|
||||
subscriptionClient,
|
||||
clientID,
|
||||
initLocalState
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Middleware } from "react-relay-network-modern/es";
|
||||
|
||||
import { CLIENT_ID_HEADER } from "coral-common/constants";
|
||||
|
||||
/**
|
||||
* Sets clientID on the header.
|
||||
*
|
||||
* @param clientID an identifier for this client.
|
||||
*/
|
||||
const clientIDMiddleware: (
|
||||
clientID: string
|
||||
) => Middleware = clientID => next => async req => {
|
||||
if (!req.fetchOpts.headers) {
|
||||
req.fetchOpts.headers = {};
|
||||
}
|
||||
req.fetchOpts.headers[CLIENT_ID_HEADER] = clientID;
|
||||
return next(req);
|
||||
};
|
||||
|
||||
export default clientIDMiddleware;
|
||||
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
CacheConfig,
|
||||
ConcreteBatch,
|
||||
Disposable,
|
||||
Variables,
|
||||
} from "react-relay-network-modern/es";
|
||||
import { SubscriptionClient } from "subscriptions-transport-ws";
|
||||
|
||||
import { ACCESS_TOKEN_PARAM, CLIENT_ID_PARAM } from "coral-common/constants";
|
||||
|
||||
/**
|
||||
* SubscriptionRequest containts the subscription
|
||||
* request data that comes from Relay.
|
||||
*/
|
||||
export interface SubscriptionRequest {
|
||||
operation: ConcreteBatch;
|
||||
variables: Variables;
|
||||
cacheConfig: CacheConfig;
|
||||
observer: any;
|
||||
subscribe: () => void;
|
||||
unsubscribe: (() => void) | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ManagedSubscriptionClient builts on top of `SubscriptionClient`
|
||||
* and manages the websocket connection economically. A connection is
|
||||
* only establish when there is at least 1 active susbcription and closes
|
||||
* when there is no more active subscriptions.
|
||||
*/
|
||||
export interface ManagedSubscriptionClient {
|
||||
/**
|
||||
* Susbcribe to a GraphQL subscription, this is usually called from
|
||||
* the SubscriptionFunction provided to Relay.
|
||||
*/
|
||||
subscribe(
|
||||
operation: ConcreteBatch,
|
||||
variables: Variables,
|
||||
cacheConfig: CacheConfig,
|
||||
observer: any
|
||||
): Disposable;
|
||||
/** Pauses all active subscriptions causing websocket connection to close. */
|
||||
pause(): void;
|
||||
/** Resume all subscriptions eventually causing websocket to start with new connection parameters */
|
||||
resume(): void;
|
||||
/** Sets access token and restarts the websocket connection */
|
||||
setAccessToken(accessToken: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ManagedSubscriptionClient
|
||||
* @param url url of the graphql live server
|
||||
* @param clientID a clientID that is provided to the graphql live server
|
||||
*/
|
||||
export default function createManagedSubscriptionClient(
|
||||
url: string,
|
||||
clientID: string
|
||||
): ManagedSubscriptionClient {
|
||||
const requests: SubscriptionRequest[] = [];
|
||||
let subscriptionClient: SubscriptionClient | null = null;
|
||||
let paused = false;
|
||||
let accessToken = "";
|
||||
|
||||
const closeClient = () => {
|
||||
if (subscriptionClient) {
|
||||
subscriptionClient.close();
|
||||
// Stop current retry attempt.
|
||||
// TODO: (cvle) This relies on internals.
|
||||
(subscriptionClient as any).clearMaxConnectTimeout();
|
||||
(subscriptionClient as any).clearTryReconnectTimeout();
|
||||
subscriptionClient = null;
|
||||
}
|
||||
};
|
||||
|
||||
const subscribe = (
|
||||
operation: ConcreteBatch,
|
||||
variables: Variables,
|
||||
cacheConfig: CacheConfig,
|
||||
observer: any
|
||||
) => {
|
||||
// Capture request into an `SubscriptionRequest` object.
|
||||
const request: Partial<SubscriptionRequest> = {
|
||||
operation,
|
||||
variables,
|
||||
cacheConfig,
|
||||
observer,
|
||||
};
|
||||
request.subscribe = () => {
|
||||
if (!subscriptionClient) {
|
||||
subscriptionClient = new SubscriptionClient(url, {
|
||||
reconnect: true,
|
||||
connectionParams: {
|
||||
[ACCESS_TOKEN_PARAM]: accessToken,
|
||||
[CLIENT_ID_PARAM]: clientID,
|
||||
},
|
||||
});
|
||||
}
|
||||
const subscription = subscriptionClient
|
||||
.request({
|
||||
operationName: operation.name,
|
||||
query: operation.text!,
|
||||
variables,
|
||||
})
|
||||
.subscribe({
|
||||
next({ data }) {
|
||||
observer.onNext({ data });
|
||||
},
|
||||
});
|
||||
request.unsubscribe = () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
};
|
||||
// Register the request.
|
||||
requests.push(request as SubscriptionRequest);
|
||||
|
||||
// Start susbcription if we are not paused.
|
||||
if (!paused) {
|
||||
request.subscribe();
|
||||
}
|
||||
return {
|
||||
dispose: () => {
|
||||
const i = requests.findIndex(r => r === request);
|
||||
if (i !== -1) {
|
||||
// Unsubscribe if available.
|
||||
if (request.unsubscribe) {
|
||||
request.unsubscribe();
|
||||
}
|
||||
// Remove from requests list.
|
||||
requests.splice(i, 1);
|
||||
|
||||
// Close client if there is no active subscription.
|
||||
if (
|
||||
subscriptionClient &&
|
||||
(requests.length === 0 || requests.every(r => !r.unsubscribe))
|
||||
) {
|
||||
closeClient();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
paused = true;
|
||||
// Unsubscribe from all active subscriptions.
|
||||
for (const r of requests) {
|
||||
if (r.unsubscribe) {
|
||||
r.unsubscribe();
|
||||
r.unsubscribe = null;
|
||||
}
|
||||
}
|
||||
// Close websocket conncetion.
|
||||
closeClient();
|
||||
};
|
||||
|
||||
const resume = () => {
|
||||
// Resume all subscriptions.
|
||||
for (const r of requests) {
|
||||
if (!r.unsubscribe) {
|
||||
r.subscribe();
|
||||
}
|
||||
}
|
||||
paused = false;
|
||||
};
|
||||
|
||||
const setAccessToken = (t: string) => {
|
||||
accessToken = t;
|
||||
if (!paused) {
|
||||
pause();
|
||||
resume();
|
||||
}
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
subscribe,
|
||||
pause,
|
||||
resume,
|
||||
setAccessToken,
|
||||
});
|
||||
}
|
||||
@@ -4,39 +4,69 @@ import {
|
||||
cacheMiddleware,
|
||||
RelayNetworkLayer,
|
||||
retryMiddleware,
|
||||
SubscribeFunction,
|
||||
urlMiddleware,
|
||||
} from "react-relay-network-modern/es";
|
||||
|
||||
import clientIDMiddleware from "./clientIDMiddleware";
|
||||
import { ManagedSubscriptionClient } from "./createManagedSubscriptionClient";
|
||||
import customErrorMiddleware from "./customErrorMiddleware";
|
||||
|
||||
export type TokenGetter = () => string;
|
||||
|
||||
const graphqlURL = "/api/graphql";
|
||||
|
||||
export default function createNetwork(tokenGetter: TokenGetter) {
|
||||
return new RelayNetworkLayer([
|
||||
customErrorMiddleware,
|
||||
cacheMiddleware({
|
||||
size: 100, // max 100 requests
|
||||
ttl: 900000, // 15 minutes
|
||||
clearOnMutation: true,
|
||||
}),
|
||||
urlMiddleware({
|
||||
url: req => Promise.resolve(graphqlURL),
|
||||
}),
|
||||
batchMiddleware({
|
||||
batchUrl: (requestMap: any) => Promise.resolve(graphqlURL),
|
||||
batchTimeout: 0,
|
||||
allowMutations: true,
|
||||
}),
|
||||
retryMiddleware({
|
||||
fetchTimeout: 15000,
|
||||
retryDelays: (attempt: number) => Math.pow(2, attempt + 4) * 100,
|
||||
// or simple array [3200, 6400, 12800, 25600, 51200, 102400, 204800, 409600],
|
||||
statusCodes: [500, 503, 504],
|
||||
}),
|
||||
authMiddleware({
|
||||
token: tokenGetter,
|
||||
}),
|
||||
]);
|
||||
function createSubscriptionFunction(
|
||||
subscriptionClient: ManagedSubscriptionClient
|
||||
): SubscribeFunction {
|
||||
const fn: SubscribeFunction = (
|
||||
operation,
|
||||
variables,
|
||||
cacheConfig,
|
||||
observer
|
||||
) => {
|
||||
return subscriptionClient.subscribe(
|
||||
operation,
|
||||
variables,
|
||||
cacheConfig,
|
||||
observer
|
||||
);
|
||||
};
|
||||
return fn;
|
||||
}
|
||||
|
||||
export default function createNetwork(
|
||||
subscriptionClient: ManagedSubscriptionClient,
|
||||
tokenGetter: TokenGetter,
|
||||
clientID: string
|
||||
) {
|
||||
return new RelayNetworkLayer(
|
||||
[
|
||||
customErrorMiddleware,
|
||||
cacheMiddleware({
|
||||
size: 100, // max 100 requests
|
||||
ttl: 900000, // 15 minutes
|
||||
clearOnMutation: true,
|
||||
}),
|
||||
urlMiddleware({
|
||||
url: () => Promise.resolve(graphqlURL),
|
||||
}),
|
||||
batchMiddleware({
|
||||
batchUrl: (requestMap: any) => Promise.resolve(graphqlURL),
|
||||
batchTimeout: 0,
|
||||
allowMutations: true,
|
||||
}),
|
||||
retryMiddleware({
|
||||
fetchTimeout: 15000,
|
||||
retryDelays: (attempt: number) => Math.pow(2, attempt + 4) * 100,
|
||||
// or simple array [3200, 6400, 12800, 25600, 51200, 102400, 204800, 409600],
|
||||
statusCodes: [500, 503, 504],
|
||||
}),
|
||||
authMiddleware({
|
||||
token: tokenGetter,
|
||||
}),
|
||||
clientIDMiddleware(clientID),
|
||||
],
|
||||
{ subscribeFn: createSubscriptionFunction(subscriptionClient) }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export { default as createNetwork, TokenGetter } from "./createNetwork";
|
||||
export { default as extractGraphQLError } from "./extractGraphQLError";
|
||||
export { default as extractError } from "./extractError";
|
||||
export {
|
||||
default as createManagedSubscriptionClient,
|
||||
ManagedSubscriptionClient,
|
||||
} from "./createManagedSubscriptionClient";
|
||||
|
||||
@@ -43,3 +43,11 @@ export { default as useRefetch } from "./useRefetch";
|
||||
export { default as useLoadMore } from "./useLoadMore";
|
||||
export { default as lookup } from "./lookup";
|
||||
export { default as useLocal } from "./useLocal";
|
||||
export {
|
||||
useSubscription,
|
||||
createSubscription,
|
||||
SubscriptionProp,
|
||||
SubscriptionVariables,
|
||||
withSubscription,
|
||||
combineDisposables,
|
||||
} from "./subscription";
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback } from "react";
|
||||
import { InferableComponentEnhancer } from "recompose";
|
||||
import { Disposable, Environment } from "relay-runtime";
|
||||
|
||||
import { CoralContext, useCoralContext } from "../bootstrap";
|
||||
|
||||
export type SubscriptionVariables<
|
||||
T extends { variables: any }
|
||||
> = T["variables"];
|
||||
export interface Subscription<N, V> {
|
||||
name: N;
|
||||
subscribe: (
|
||||
environment: Environment,
|
||||
variables: V,
|
||||
context: CoralContext
|
||||
) => Disposable;
|
||||
}
|
||||
|
||||
export type SubscriptionProp<
|
||||
T extends Subscription<any, any>
|
||||
> = T extends Subscription<any, infer V>
|
||||
? Parameters<T["subscribe"]>[1] extends undefined
|
||||
? () => Disposable
|
||||
: keyof Parameters<T["subscribe"]>[1] extends never
|
||||
? () => Disposable
|
||||
: (variables: V) => Disposable
|
||||
: never;
|
||||
|
||||
export function createSubscription<N extends string, V>(
|
||||
name: N,
|
||||
subscribe: (
|
||||
environment: Environment,
|
||||
variables: V,
|
||||
context: CoralContext
|
||||
) => Disposable
|
||||
): Subscription<N, V> {
|
||||
return {
|
||||
name,
|
||||
subscribe,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* useSubscription is a React Hook that
|
||||
* returns a callback to subscribes to a Subscription.
|
||||
*/
|
||||
export function useSubscription<V>(
|
||||
subscription: Subscription<any, V>
|
||||
): SubscriptionProp<typeof subscription> {
|
||||
const context = useCoralContext();
|
||||
return useCallback<SubscriptionProp<typeof subscription>>(
|
||||
((variables: V) => {
|
||||
context.eventEmitter.emit(`subscription.${subscription.name}`, variables);
|
||||
return subscription.subscribe(
|
||||
context.relayEnvironment,
|
||||
variables,
|
||||
context
|
||||
);
|
||||
}) as any,
|
||||
[context]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* withSubscription creates a HOC that injects the subscription as
|
||||
* a property.
|
||||
*
|
||||
* @deprecated use `useFetch` instead
|
||||
*/
|
||||
export function withSubscription<N extends string, V, R>(
|
||||
subscription: Subscription<N, V>
|
||||
): InferableComponentEnhancer<
|
||||
{ [P in N]: SubscriptionProp<typeof subscription> }
|
||||
> {
|
||||
return (BaseComponent: React.ComponentType<any>) => {
|
||||
{
|
||||
const sub = useSubscription(subscription);
|
||||
return props => {
|
||||
const finalProps = {
|
||||
...props,
|
||||
[subscription.name]: sub,
|
||||
};
|
||||
return <BaseComponent {...finalProps} />;
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines disposables into one.
|
||||
*/
|
||||
export function combineDisposables(...disposables: Disposable[]): Disposable {
|
||||
return {
|
||||
dispose: () => {
|
||||
disposables.forEach(d => d.dispose());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Overwrite } from "coral-framework/types";
|
||||
import { merge } from "lodash";
|
||||
|
||||
import { CLIENT_ID_HEADER } from "coral-common/constants";
|
||||
import { Overwrite } from "coral-framework/types";
|
||||
|
||||
import { extractError } from "./network";
|
||||
|
||||
const buildOptions = (inputOptions: RequestInit = {}) => {
|
||||
@@ -52,10 +54,12 @@ type PartialRequestInit = Overwrite<Partial<RequestInit>, { body?: any }> & {
|
||||
export class RestClient {
|
||||
public readonly uri: string;
|
||||
private tokenGetter?: () => string;
|
||||
private clientID?: string;
|
||||
|
||||
constructor(uri: string, tokenGetter?: () => string) {
|
||||
constructor(uri: string, tokenGetter?: () => string, clientID?: string) {
|
||||
this.uri = uri;
|
||||
this.tokenGetter = tokenGetter;
|
||||
this.clientID = clientID;
|
||||
}
|
||||
|
||||
public async fetch<T = {}>(
|
||||
@@ -71,6 +75,13 @@ export class RestClient {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (this.clientID) {
|
||||
opts = merge({}, opts, {
|
||||
headers: {
|
||||
[CLIENT_ID_HEADER]: this.clientID,
|
||||
},
|
||||
});
|
||||
}
|
||||
const response = await fetch(`${this.uri}${path}`, buildOptions(opts));
|
||||
return handleResp(response);
|
||||
}
|
||||
|
||||
@@ -12,12 +12,16 @@ import {
|
||||
GQLCOMMENT_STATUS,
|
||||
GQLLOCALES,
|
||||
GQLMODERATION_MODE,
|
||||
GQLMODERATION_QUEUE,
|
||||
GQLSTORY_STATUS,
|
||||
GQLUSER_AUTH_CONDITIONS,
|
||||
GQLUSER_ROLE,
|
||||
GQLUSER_STATUS,
|
||||
} from "./__generated__/types";
|
||||
|
||||
export type GQLMODERATION_QUEUE_RL = RelayEnumLiteral<
|
||||
typeof GQLMODERATION_QUEUE
|
||||
>;
|
||||
export type GQLUSER_ROLE_RL = RelayEnumLiteral<typeof GQLUSER_ROLE>;
|
||||
export type GQLUSER_STATUS_RL = RelayEnumLiteral<typeof GQLUSER_STATUS>;
|
||||
export type GQLCOMMENT_FLAG_DETECTED_REASON_RL = RelayEnumLiteral<
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { graphql, GraphQLSchema } from "graphql";
|
||||
import { graphql, GraphQLSchema, parse } from "graphql";
|
||||
import { IResolvers } from "graphql-tools";
|
||||
|
||||
import { SubscribeFunction } from "react-relay-network-modern/es";
|
||||
import {
|
||||
commitLocalUpdate,
|
||||
Environment,
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
ModerationNudgeError,
|
||||
} from "coral-framework/lib/errors";
|
||||
|
||||
import { SubscriptionHandler } from "./createSubscriptionHandler";
|
||||
|
||||
export interface CreateRelayEnvironmentNetworkParams {
|
||||
/** project name of graphql-config */
|
||||
projectName: string;
|
||||
@@ -33,6 +35,8 @@ export interface CreateRelayEnvironmentNetworkParams {
|
||||
logNetwork?: boolean;
|
||||
/** If enabled, graphql errors will be muted */
|
||||
muteNetworkErrors?: boolean;
|
||||
/** handler for subscriptions */
|
||||
subscriptionHandler?: SubscriptionHandler;
|
||||
}
|
||||
|
||||
export interface CreateRelayEnvironmentParams {
|
||||
@@ -89,6 +93,55 @@ function createFetch({
|
||||
};
|
||||
}
|
||||
|
||||
function resolveArguments(
|
||||
variables: Record<string, any> = {},
|
||||
args: any[] = []
|
||||
) {
|
||||
return args.reduce((res, a) => {
|
||||
const argName = a.name.value;
|
||||
const variableName = a.value.name.value;
|
||||
if (variableName in variables) {
|
||||
res[argName] = variables[variableName];
|
||||
}
|
||||
return res;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function createSubscribe(
|
||||
subscriptionHandler: SubscriptionHandler
|
||||
): SubscribeFunction {
|
||||
const fn: SubscribeFunction = (
|
||||
operation,
|
||||
variables,
|
||||
cacheConfig,
|
||||
observer
|
||||
) => {
|
||||
// TODO: (cvle) This could probably made less brittle to changes in the order of the
|
||||
// document AST.
|
||||
const subscriptionSelections = (parse(operation.text!) as any)
|
||||
.definitions[0].selectionSet.selections as any[];
|
||||
const sel = subscriptionSelections[0];
|
||||
const subscription = {
|
||||
field: sel.name.value,
|
||||
variables: resolveArguments(variables, sel.arguments),
|
||||
dispatch: (response: any) => {
|
||||
observer.onNext({
|
||||
data: {
|
||||
[sel.name.value]: response,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
subscriptionHandler.add(subscription);
|
||||
return {
|
||||
dispose: () => {
|
||||
subscriptionHandler.remove(subscription);
|
||||
},
|
||||
};
|
||||
};
|
||||
return fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* create Relay environment for tests environments.
|
||||
*/
|
||||
@@ -106,7 +159,10 @@ export default function createRelayEnvironment(
|
||||
wrapFetchWithLogger(createFetch({ schema }), {
|
||||
logResult: params.network.logNetwork,
|
||||
muteErrors: params.network.muteNetworkErrors,
|
||||
})
|
||||
}),
|
||||
params.network.subscriptionHandler
|
||||
? (createSubscribe(params.network.subscriptionHandler) as any)
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
const environment = new Environment({
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { GQLSubscription } from "coral-framework/schema";
|
||||
|
||||
import { DeepPartial } from "coral-framework/types";
|
||||
|
||||
export type SubscriptionVariables<
|
||||
T extends SubscriptionResolver<any, any>
|
||||
> = T extends SubscriptionResolver<infer V, any> ? V : any;
|
||||
|
||||
export type SubscriptionResponse<
|
||||
T extends SubscriptionResolver<any, any>
|
||||
> = T extends SubscriptionResolver<any, infer R> ? DeepPartial<R> : any;
|
||||
|
||||
/**
|
||||
* SubscriptionResolver matches the shape of Subscription
|
||||
* resolvers in the schema generated types.
|
||||
*/
|
||||
export interface SubscriptionResolver<V, R> {
|
||||
resolve?: (parent: any, args: V, context: any, info: any) => R;
|
||||
}
|
||||
|
||||
type SubscriptionField = keyof GQLSubscription;
|
||||
|
||||
/**
|
||||
* Subscription represents a subscription currently requested from the client.
|
||||
*/
|
||||
export interface Subscription<T extends SubscriptionResolver<any, any> = any> {
|
||||
/** field of the subscription field being requested */
|
||||
field: SubscriptionField;
|
||||
/** variables of the subscription field being requested */
|
||||
variables: SubscriptionVariables<T>;
|
||||
/** dispatch data to this subscription */
|
||||
dispatch(data: SubscriptionResponse<T>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* SubscriptionHandlerReadOnly enables to write tests with subscriptions.
|
||||
*/
|
||||
export interface SubscriptionHandlerReadOnly {
|
||||
/** List of current active subscriptions */
|
||||
readonly subscriptions: ReadonlyArray<Subscription>;
|
||||
/**
|
||||
* dispatch will look for subscriptions of the field `field` and
|
||||
* calls the `callback` for each of them. If `callback` returns data,
|
||||
* it'll be dispatched to that subscription.
|
||||
* @param field name of subscription field to look for.
|
||||
* @param callback callback is called for every subscription on this field.
|
||||
*/
|
||||
dispatch<T extends SubscriptionResolver<any, any> = any>(
|
||||
field: SubscriptionField,
|
||||
callback: (
|
||||
variables: SubscriptionVariables<T>
|
||||
) => SubscriptionResponse<T> | void
|
||||
): void;
|
||||
has(field: SubscriptionField): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SubscriptionHandler enables to write tests with subscriptions
|
||||
* and to manipulate the current list of subscriptions.
|
||||
*/
|
||||
export interface SubscriptionHandler extends SubscriptionHandlerReadOnly {
|
||||
add(subscription: Subscription): void;
|
||||
remove(subscription: Subscription): void;
|
||||
}
|
||||
|
||||
export default function createSubscriptionHandler(): SubscriptionHandler {
|
||||
const subscriptions: Subscription[] = [];
|
||||
const handler: SubscriptionHandler = {
|
||||
subscriptions,
|
||||
dispatch: (field, callback) => {
|
||||
subscriptions.forEach(s => {
|
||||
if (s.field === field) {
|
||||
const data = callback(s.variables as any);
|
||||
if (data) {
|
||||
s.dispatch(data);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
has: field => subscriptions.some(s => s.field === field),
|
||||
add: s => {
|
||||
subscriptions.push(s);
|
||||
},
|
||||
remove: s => {
|
||||
const index = subscriptions.findIndex(x => x === s);
|
||||
if (index !== -1) {
|
||||
subscriptions.splice(index, 1);
|
||||
}
|
||||
},
|
||||
};
|
||||
return handler;
|
||||
}
|
||||
@@ -18,6 +18,9 @@ import { createUUIDGenerator } from "coral-framework/testHelpers";
|
||||
|
||||
import createFluentBundle from "./createFluentBundle";
|
||||
import createRelayEnvironment from "./createRelayEnvironment";
|
||||
import createSubscriptionHandler, {
|
||||
SubscriptionHandlerReadOnly,
|
||||
} from "./createSubscriptionHandler";
|
||||
|
||||
export type Resolver<V, R> = (
|
||||
parent: any,
|
||||
@@ -66,6 +69,7 @@ export default function createTestRenderer<
|
||||
element: React.ReactNode,
|
||||
params: CreateTestRendererParams<T>
|
||||
) {
|
||||
const subscriptionHandler = createSubscriptionHandler();
|
||||
const environment = createRelayEnvironment({
|
||||
network: {
|
||||
// Set this to true, to see graphql responses.
|
||||
@@ -73,6 +77,7 @@ export default function createTestRenderer<
|
||||
resolvers: params.resolvers as IResolvers<any, any>,
|
||||
muteNetworkErrors: params.muteNetworkErrors,
|
||||
projectName: "tenant",
|
||||
subscriptionHandler,
|
||||
},
|
||||
initLocalState: (localRecord, source, env) => {
|
||||
if (params.initLocalState) {
|
||||
@@ -111,5 +116,9 @@ export default function createTestRenderer<
|
||||
{ createNodeMock }
|
||||
);
|
||||
});
|
||||
return { context, testRenderer: testRenderer! };
|
||||
return {
|
||||
context,
|
||||
testRenderer: testRenderer!,
|
||||
subscriptionHandler: subscriptionHandler as SubscriptionHandlerReadOnly,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,25 +11,21 @@ export default function matchText(
|
||||
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, " ");
|
||||
}
|
||||
|
||||
let a = pattern;
|
||||
let b = text;
|
||||
if (options.trim || options.trim === undefined) {
|
||||
a = typeof a === "string" ? a.trim() : a;
|
||||
b = b.trim();
|
||||
}
|
||||
if (options.collapseWhitespace || options.collapseWhitespace === undefined) {
|
||||
a = typeof a === "string" ? a.replace(/\s+/g, " ") : a;
|
||||
b = b.replace(/\s+/g, " ");
|
||||
}
|
||||
if (typeof a === "string") {
|
||||
if (options.exact || options.exact === undefined) {
|
||||
return a === b;
|
||||
}
|
||||
return text.toLowerCase().includes(pattern.toLowerCase());
|
||||
return b.toLowerCase().includes(a.toLowerCase());
|
||||
}
|
||||
return pattern.test(text);
|
||||
return a.test(b);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user