[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:
Vinh
2019-06-21 17:01:07 +00:00
committed by Wyatt Johnson
parent 0e247ba383
commit 413f3e2f1e
111 changed files with 8230 additions and 5017 deletions
@@ -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);
}