Merge client into next (#1709)

* Merge client
* Add linting script
* Rename serve to start:development
* Move error harmonization and handling to network layer
* Show Comment Stream
* Added initial test
This commit is contained in:
Kiwi
2018-06-27 22:06:30 +00:00
committed by Wyatt Johnson
parent 68794d5919
commit 65c8da0f34
122 changed files with 21057 additions and 42 deletions
+8
View File
@@ -0,0 +1,8 @@
# Framework
All our client targets (e.g. stream, admin, ...) are based functionality provided by this framework.
## What should be inside `framework`
- Code that are specific to a certain target (e.g. stream, admin, ...) must not live here.
- Code that are shared by different targets should be put in `framework`
+3
View File
@@ -0,0 +1,3 @@
# Lib
This folder contains functionality of integral parts of our technology stack.
@@ -0,0 +1,33 @@
import { LocalizationProvider } from "fluent-react/compat";
import { MessageContext } from "fluent/compat";
import React, { StatelessComponent } from "react";
import { Environment } from "relay-runtime";
export interface TalkContext {
// relayEnvironment for our relay framework.
relayEnvironment: Environment;
// localMessages for our i18n framework.
localeMessages: MessageContext[];
}
const { Provider, Consumer } = React.createContext<TalkContext>({} as any);
/**
* Allows consuming the provided context using the React Context API.
*/
export const TalkContextConsumer = Consumer;
/**
* In addition to just providing the context, TalkContextProvider also
* renders the `LocalizationProvider` with the appropite data.
*/
export const TalkContextProvider: StatelessComponent<{
value: TalkContext;
}> = ({ value, children }) => (
<Provider value={value}>
<LocalizationProvider messages={value.localeMessages}>
{children}
</LocalizationProvider>
</Provider>
);
@@ -0,0 +1,55 @@
import { noop } from "lodash";
import { Environment, Network, RecordSource, Store } from "relay-runtime";
import { generateMessages, LocalesData, negotiateLanguages } from "../i18n";
import { fetchQuery } from "../network";
import { TalkContext } from "./TalkContext";
interface CreateContextArguments {
// Locales that the user accepts, usually `navigator.languages`.
userLocales: ReadonlyArray<string>;
// Locales data that is returned by our `locales-loader`.
localesData: LocalesData;
// Init will be called after the context has been created.
init?: ((context: TalkContext) => void | Promise<void>);
}
/**
* `createContext` manages the dependencies of our framework
* and returns a `TalkContext` that can be passed to the
* `TalkContextProvider`.
*/
export default async function createContext({
init = noop,
userLocales,
localesData,
}: CreateContextArguments): Promise<TalkContext> {
// Initialize Relay.
const relayEnvironment = new Environment({
network: Network.create(fetchQuery),
store: new Store(new RecordSource()),
});
// Initialize i18n.
const locales = negotiateLanguages(userLocales, localesData);
if (process.env.NODE_ENV !== "production") {
// tslint:disable:next-line: no-console
console.log(`Negotiated locales ${JSON.stringify(locales)}`);
}
const localeMessages = await generateMessages(locales, localesData);
// Assemble context.
const context = {
relayEnvironment,
localeMessages,
};
// Run custom initializations.
await init(context);
return context;
}
@@ -0,0 +1,3 @@
export * from "./TalkContext";
export { default as createContext } from "./createContext";
export { default as withContext } from "./withContext";
@@ -0,0 +1,23 @@
import * as React from "react";
import { hoistStatics, InferableComponentEnhancer } from "recompose";
import { TalkContext, TalkContextConsumer } from "./TalkContext";
/**
* withContext is a HOC wrapper around `TalkContextConsumer`.
* `propsCallback` must be provided which accepts the `TalkContext`
* and returns the props the should be injected.
*/
function withContext<T>(
propsCallback: (context: TalkContext) => T
): InferableComponentEnhancer<T> {
return hoistStatics<T>(
<U extends T>(WrappedComponent: React.ComponentType<U>) => (props: any) => (
<TalkContextConsumer>
{context => <WrappedComponent {...props} {...propsCallback(context)} />}
</TalkContextConsumer>
)
);
}
export default withContext;
@@ -0,0 +1,79 @@
import { mapValues, once } from "lodash";
import { ReactNode } from "react";
import { VALIDATION_REQUIRED, VALIDATION_TOO_SHORT } from "../messages";
/**
* ValidationError represents all possible string values
* that is responded by the server.
*/
type ValidationError = "TOO_SHORT";
/**
* InvalidArgsMap as responded by the server.
*/
interface InvalidArgsMap {
[key: string]: ValidationError;
}
/**
* The localized version of `InvalidArgsMap`.
*/
interface InvalidArgsMapLocalilzed {
[key: string]: ReactNode;
}
/**
* Shape of the `BadUserInput` extension.
*/
interface BadUserInputExtension {
code: "BAD_USER_INPUT";
exception: {
invalidArgs: InvalidArgsMap;
};
}
/**
* Map server `ValidationError` to a translation message.
*/
const validationMap = {
TOO_SHORT: VALIDATION_TOO_SHORT,
REQUIRED: VALIDATION_REQUIRED,
};
/**
* BadUserInputError wraps the `BAD_USER_INPUT` error returned from the
* server.
*/
export default class BadUserInputError extends Error {
// Keep origin of original server response.
public readonly origin: BadUserInputExtension;
constructor(error: BadUserInputExtension) {
super("BadUserInputError");
// Maintains proper stack trace for where our error was thrown.
if (Error.captureStackTrace) {
Error.captureStackTrace(this, BadUserInputError);
}
this.origin = error;
}
get invalidArgs(): InvalidArgsMap {
return this.origin.exception.invalidArgs;
}
get invalidArgsLocalized(): InvalidArgsMapLocalilzed {
return this.computeInvalidArgsLocalized();
}
// Perform localization and memoize result.
private computeInvalidArgsLocalized = once(() => {
return mapValues(this.invalidArgs, v => {
if (v in validationMap) {
return validationMap[v]();
}
return v;
});
});
}
@@ -0,0 +1,25 @@
export interface GraphQLErrorItem {
message: string;
locations: Array<{
line: number;
column: number;
}>;
}
/**
* Graphql wraps graphql errors at the network layer.
*/
export default class GraphQLError extends Error {
// Original error.
public readonly origin: GraphQLErrorItem[];
constructor(origin: GraphQLErrorItem[]) {
super(origin.map(o => o.message).join(" "));
// Maintains proper stack trace for where our error was thrown.
if (Error.captureStackTrace) {
Error.captureStackTrace(this, GraphQLError);
}
this.origin = origin;
}
}
@@ -0,0 +1,5 @@
export { default as NetworkError } from "./networkError";
export { default as UnknownServerError } from "./unknownServerError";
export { default as BadUserInputError } from "./badUserInputError";
export { default as GraphQLError } from "./graphqlError";
export * from "./graphqlError";
@@ -0,0 +1,18 @@
/**
* NetworkError wraps errors at the network layer.
*/
export default class NetworkError extends Error {
// Original error.
public readonly origin: Error;
constructor(origin: Error) {
// Pass remaining arguments (including vendor specific ones) to parent constructor.
super(origin.message);
// Maintains proper stack trace for where our error was thrown.
if (Error.captureStackTrace) {
Error.captureStackTrace(this, NetworkError);
}
this.origin = origin;
}
}
@@ -0,0 +1,26 @@
/**
* Shape of the `UnknownError` extension.
*/
interface UnknownErrorExtension {
code: string;
}
/**
* UnknownServerError wraps any error returned from the
* server that we don't know of.
*/
export default class UnknownServerError extends Error {
// Keep origin of original server response.
public origin: UnknownErrorExtension;
constructor(msg: string, error: UnknownErrorExtension) {
super(msg);
// Maintains proper stack trace for where our error was thrown.
if (Error.captureStackTrace) {
Error.captureStackTrace(this, UnknownServerError);
}
this.origin = error;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { FormApi } from "final-form";
import { ReactNode } from "react";
type ErrorsObject<T> = { [K in keyof T]?: ReactNode };
/**
* A version of FormProps["onSubmit"] with support for Generic Types.
*/
export type OnSubmit<T> = (
values: T,
form: FormApi
) => ErrorsObject<T> | Promise<ErrorsObject<T> | void> | void;
+103
View File
@@ -0,0 +1,103 @@
import "fluent-intl-polyfill/compat";
import { negotiateLanguages as negotiate } from "fluent-langneg/compat";
import { MessageContext } from "fluent/compat";
export interface BundledLocales {
[locale: string]: string;
}
export interface LoadableLocales {
[locale: string]: (() => Promise<string>);
}
/**
* This type describes the shape of the generated code from our `locales-loader`.
* Please check `./src/loaders` and the webpack config for more information.
*/
export interface LocalesData {
readonly defaultLocale: string;
readonly fallbackLocale: string;
readonly availableLocales: ReadonlyArray<string>;
readonly bundled: BundledLocales;
readonly loadables: LoadableLocales;
}
/**
* negotiateLanguages accepts `userLocales` which usually comes from
* `navigator.languages` and the locales `data` as generated by
* the `locales-loader` and returns an array of matching languages.
*/
export function negotiateLanguages(
userLocales: ReadonlyArray<string>,
data: LocalesData
) {
// Choose locale that is best for the user.
const languages = negotiate(userLocales, data.availableLocales, {
defaultLocale: data.defaultLocale,
strategy: "lookup",
});
if (data.fallbackLocale && languages[0] !== data.fallbackLocale) {
// Use default locale as fallback in case we have
// missing keys.
languages.push(data.fallbackLocale);
}
return languages;
}
// Don't warn in production.
let decorateWarnMissing = (cx: MessageContext) => cx;
// Warn about missing locales if we are not in production.
if (process.env.NODE_ENV !== "production") {
decorateWarnMissing = (() => {
const warnings: string[] = [];
return (cx: MessageContext) => {
const original = cx.hasMessage;
cx.hasMessage = (id: string) => {
const result = original.apply(cx, [id]);
if (!result) {
const warn = `${cx.locales} translation for key "${id}" not found`;
if (!warnings.includes(warn)) {
// tslint:disable:next-line: no-console
console.warn(warn);
warnings.push(warn);
}
}
return result;
};
return cx;
};
})();
}
/**
* Given a locales array and the `data` from the `locales-loader`,
* generateMessages returns an Array of MessageContext as a Promise.
* This array is meant to be consumed by `react-fluent`.
*
* Use it in conjunction with `negotiateLanguages`.
*/
export async function generateMessages(
locales: ReadonlyArray<string>,
data: LocalesData
): Promise<MessageContext[]> {
const promises = [];
for (const locale of locales) {
const cx = new MessageContext(locale);
if (locale in data.bundled) {
cx.addMessages(data.bundled[locale]);
promises.push(decorateWarnMissing(cx));
} else if (locale in data.loadables) {
const content = await data.loadables[locale]();
cx.addMessages(content);
promises.push(decorateWarnMissing(cx));
} else {
throw Error(`Locale ${locale} not available`);
}
}
return await Promise.all(promises);
}
@@ -0,0 +1,19 @@
import { Localized } from "fluent-react/compat";
import React from "react";
/**
* This file contains localization messages that are shared by
* different parts of the framework.
*/
export const VALIDATION_REQUIRED = () => (
<Localized id="framework-validation-required">
<span>This field is required.</span>
</Localized>
);
export const VALIDATION_TOO_SHORT = () => (
<Localized id="framework-validation-too-short">
<span>This field is too short.</span>
</Localized>
);
@@ -0,0 +1,61 @@
import { FetchFunction } from "relay-runtime";
import {
BadUserInputError,
GraphQLError,
NetworkError,
UnknownServerError,
} from "../errors";
// Normalize errors.
function getError(errors: Error[]): Error {
if (errors.length > 1) {
// Multiple errors are GraphQL errors.
// TODO: (cvle) Is this assumption correct?
return new GraphQLError(errors as any);
}
const err = errors[0] as Error;
if ((err as any).extensions) {
if ((err as any).code === "BAD_USER_INPUT") {
return new BadUserInputError((err as any).extensions);
}
return new UnknownServerError(err.message, (err as any).extensions);
}
// No extensions == GraphQL error.
// TODO: (cvle) harmonize with server.
return new GraphQLError(errors as any);
}
/**
* fetchQuery is a simple implementation of the `FetchFunction`
* required by Relay. It'll return a `NetworkError` on failure.
*/
const fetchQuery: FetchFunction = async (operation, variables) => {
try {
const response = await fetch("/api/tenant/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query: operation.text,
variables,
}),
});
if (response.status >= 500) {
throw new Error(`${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data.errors) {
throw getError(data.errors);
}
return data;
} catch (err) {
if (err instanceof TypeError) {
throw new NetworkError(err);
}
throw err;
}
};
export default fetchQuery;
@@ -0,0 +1 @@
export { default as fetchQuery } from "./fetchQuery";
@@ -0,0 +1,40 @@
import React, { Component } from "react";
import { QueryRenderer } from "react-relay";
import { CacheConfig, GraphQLTaggedNode, RerunParam } from "relay-runtime";
import { TalkContextConsumer } from "../bootstrap/TalkContext";
// Taken from relay types and added Generic support for Variables and Response
export interface QueryRendererProps<V, R> {
cacheConfig?: CacheConfig;
query?: GraphQLTaggedNode | null;
render(readyState: ReadyState<R>): React.ReactElement<any> | undefined | null;
variables: V;
rerunParamExperimental?: RerunParam;
}
// Taken from relay types and added Generic support for Variables and Response
export interface ReadyState<R> {
error: Error | undefined | null;
props: R | undefined | null;
retry?(): void;
}
/**
* TalkQueryRenderer is a wrappper around Relay's `QueryRenderer`.
* It supplies the `environment` from the context and has better
* generics type support.
*/
class TalkQueryRenderer<V, R> extends Component<QueryRendererProps<V, R>> {
public render() {
return (
<TalkContextConsumer>
{({ relayEnvironment }) => (
<QueryRenderer environment={relayEnvironment} {...this.props} />
)}
</TalkContextConsumer>
);
}
}
export default TalkQueryRenderer;
@@ -0,0 +1,67 @@
import { commitMutation } from "react-relay";
import { Environment, MutationConfig } from "relay-runtime";
import { Omit } from "talk-framework/types";
/**
* Like `MutationConfig` but omits `onCompleted` and `onError`
* because we are going to use a Promise API.
*/
export type MutationPromiseConfig<T, U> = Omit<
MutationConfig<T, U>,
"onCompleted" | "onError"
>;
// Extract the payload from the response,
function getPayload(response: { [key: string]: any }): any {
const keys = Object.keys(response);
if (keys.length !== 1) {
return response;
}
return response[keys[0]];
}
/**
* Normalizes response and error from `commitMutationPromise`.
* Meaning `response` will directly contain the payload
* and errors are wrapped inside of application specific
* error instances.
*/
export async function commitMutationPromiseNormalized<R, V>(
environment: Environment,
config: MutationPromiseConfig<R, V>
): Promise<R> {
try {
const response = await commitMutationPromise(environment, config);
return getPayload(response);
} catch (e) {
throw e;
}
}
/**
* Like `commitMutation` of the Relay API but returns a Promise.
*/
export function commitMutationPromise<R, V>(
environment: Environment,
config: MutationPromiseConfig<R, V>
): Promise<R> {
return new Promise((resolve, reject) => {
commitMutation(environment, {
...config,
onCompleted: (response, errors) => {
if (errors) {
// This should not happen, as the network layer
// will throw on errors which should result to
// `onError` rather than `onCompleted``.
reject(errors);
return;
}
resolve(getPayload(response));
},
onError: error => {
reject(error);
},
});
});
}
@@ -0,0 +1,23 @@
import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime";
/**
* Creates a Record and retain it forever.
* This means that the garbage collector will
* not remove the record on the next run.
*
* See https://github.com/facebook/relay/issues/1656#issuecomment-380519761
*/
export default function createAndRetain(
environment: Environment,
source: RecordSourceProxy,
id: string,
type: string
): RecordProxy {
const result = source.create(id, type);
environment.retain({
dataID: id,
node: { selections: [] },
variables: {},
});
return result;
}
@@ -0,0 +1,39 @@
import * as React from "react";
import { compose, hoistStatics, InferableComponentEnhancer } from "recompose";
import { Environment } from "relay-runtime";
import { withContext } from "../bootstrap";
/**
* createMutationContainer creates a HOC that
* injects a property with the name specified in `propName`
* and the signature (input: I) => Promise<R>. Calling
* this will call the specified `commit` callback with
* the Relay `environment` provided by the context.
*/
function createMutationContainer<T extends string, I, R>(
propName: T,
commit: (environment: Environment, input: I) => Promise<R>
): InferableComponentEnhancer<{ [P in T]: (input: I) => Promise<R> }> {
return compose(
withContext(({ relayEnvironment }) => ({ relayEnvironment })),
hoistStatics((WrappedComponent: React.ComponentType<any>) => {
class CreateMutationContainer extends React.Component<any> {
private commit = (input: I) => {
return commit(this.props.relayEnvironment, input);
};
public render() {
const { relayEnvironment: _, ...rest } = this.props;
const inject = {
[propName]: this.commit,
};
return <WrappedComponent {...rest} {...inject} />;
}
}
return CreateMutationContainer as React.ComponentType<any>;
})
);
}
export default createMutationContainer;
@@ -0,0 +1,14 @@
export { default as withFragmentContainer } from "./withFragmentContainer";
export { default as withPaginationContainer } from "./withPaginationContainer";
export { default as withRefetchContainer } from "./withRefetchContainer";
export { default as withLocalStateContainer } from "./withLocalStateContainer";
export * from "./withLocalStateContainer";
export { default as QueryRenderer } from "./QueryRenderer";
export * from "./QueryRenderer";
export { default as createMutationContainer } from "./createMutationContainer";
export { default as createAndRetain } from "./createAndRetain";
export {
commitMutationPromise,
commitMutationPromiseNormalized,
} from "./commitMutationPromise";
export { graphql } from "react-relay";
@@ -0,0 +1,12 @@
import { createFragmentContainer, GraphQLTaggedNode } from "react-relay";
import { InferableComponentEnhancerWithProps } from "recompose";
/**
* withFragmentContainer is a curried version of `createFragmentContainers`
* from Relay.
*/
export default <T>(
fragmentSpec: GraphQLTaggedNode
): InferableComponentEnhancerWithProps<T, { [P in keyof T]: any }> => (
component: React.ComponentType<any>
) => createFragmentContainer(component, fragmentSpec) as any;
@@ -0,0 +1,71 @@
import * as React from "react";
import { compose, hoistStatics, InferableComponentEnhancer } from "recompose";
import { CSelector, CSnapshot, Environment } from "relay-runtime";
import { withContext } from "../bootstrap";
interface Props {
relayEnvironment: Environment;
}
/**
* The Root Record of Client-Side Schema Extension must be of this type.
*/
export const LOCAL_TYPE = "Local";
/**
* The Root Record of Client-Side Schema Extension must have this id.
*/
export const LOCAL_ID = "client:root.local";
/**
* withLocalStateContainer allows for subscribing to local state
* that has been added using Client-Side Schema Extensions.
* The `fragmentSpec` must be a `Fragment` on the `LOCAL_TYPE` which
* must have the `LOCAL_ID`.
*/
function withLocalStateContainer<T>(
fragmentSpec: any
): InferableComponentEnhancer<{ local: T }> {
return compose(
withContext(({ relayEnvironment }) => ({ relayEnvironment })),
hoistStatics((WrappedComponent: React.ComponentType<any>) => {
class LocalStateContainer extends React.Component<Props, any> {
constructor(props: Props) {
super(props);
const fragment = fragmentSpec.data().default;
if (fragment.kind !== "Fragment") {
throw new Error("Expected fragment");
}
if (fragment.type !== LOCAL_TYPE) {
throw new Error(
`Type must be "Local" in "Fragment ${fragment.name}"`
);
}
const selector: CSelector<any> = {
dataID: LOCAL_ID,
node: { selections: fragment.selections },
variables: {},
};
const snapshot = props.relayEnvironment.lookup(selector);
props.relayEnvironment.subscribe(snapshot, this.updateSnapshot);
this.state = {
data: snapshot.data,
};
}
private updateSnapshot = (snapshot: CSnapshot<any>) => {
this.setState({ data: snapshot.data });
};
public render() {
const { relayEnvironment: _, ...rest } = this.props;
return <WrappedComponent {...rest} local={this.state.data} />;
}
}
return LocalStateContainer as React.ComponentType<any>;
})
);
}
export default withLocalStateContainer;
@@ -0,0 +1,20 @@
import {
ConnectionConfig,
createPaginationContainer,
GraphQLTaggedNode,
RelayPaginationProp,
} from "react-relay";
import { InferableComponentEnhancerWithProps } from "recompose";
/**
* withPaginationContainer is a curried version of `createPaginationContainers`
* from Relay.
*/
export default <T, InnerProps>(
fragmentSpec: GraphQLTaggedNode,
connectionConfig: ConnectionConfig<InnerProps>
): InferableComponentEnhancerWithProps<
T & { relay: RelayPaginationProp },
{ [P in keyof T]: any }
> => (component: React.ComponentType<any>) =>
createPaginationContainer(component, fragmentSpec, connectionConfig) as any;
@@ -0,0 +1,19 @@
import {
createRefetchContainer,
GraphQLTaggedNode,
RelayRefetchProp,
} from "react-relay";
import { InferableComponentEnhancerWithProps } from "recompose";
/**
* withRefetchContainer is a curried version of `createRefetchContainers`
* from Relay.
*/
export default <T>(
fragmentSpec: GraphQLTaggedNode,
refetchQuery: GraphQLTaggedNode
): InferableComponentEnhancerWithProps<
T & { relay: RelayRefetchProp },
{ [P in keyof T]: any }
> => (component: React.ComponentType<any>) =>
createRefetchContainer(component, fragmentSpec, refetchQuery) as any;
@@ -0,0 +1,12 @@
import { createValidator } from "./validation";
describe("createValidator", () => {
it("should report error when condition is unmet", () => {
const truthy = createValidator(v => !!v, "must be truthy");
expect(truthy(false, {})).toBe("must be truthy");
});
it("should NOT report error when condition is met", () => {
const truthy = createValidator(v => !!v, "must be truthy");
expect(truthy(true, {})).toBe(undefined);
});
});
@@ -0,0 +1,33 @@
import { ReactNode } from "react";
import { VALIDATION_REQUIRED } from "./messages";
type Validator<T, V> = (v: T, values: V) => ReactNode;
/**
* createValidator returns a Validator that returns given `error` when `condition` is falsey.
*/
export function createValidator<T = any, V = any>(
condition: (v: T, values: V) => boolean,
error: ReactNode
): Validator<T, V> {
return (v, values) => (condition(v, values) ? undefined : error);
}
/**
* composeValidators returns a Validator that chains the given validators
* and runs them in sequence until one validator fails and returns an error.
*/
export function composeValidators<T = any, V = any>(
...validators: Array<Validator<T, V>>
) {
return (v: T, values: V) =>
validators.reduce(
(error, validator) => error || validator(v, values),
undefined
);
}
/**
* required is a Validator that checks that the value is truthy.
*/
export const required = createValidator(v => !!v, VALIDATION_REQUIRED());
+2
View File
@@ -0,0 +1,2 @@
// TODO: (@cvle) Extract useful common types into its own package.
export { Diff, Omit, Overwrite, PropTypesOf } from "talk-ui/types";