mirror of
https://github.com/wassname/talk.git
synced 2026-09-10 12:43:11 +08:00
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:
@@ -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`
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
@@ -0,0 +1,2 @@
|
||||
// TODO: (@cvle) Extract useful common types into its own package.
|
||||
export { Diff, Omit, Overwrite, PropTypesOf } from "talk-ui/types";
|
||||
Reference in New Issue
Block a user