[next] Save Comment Draft + Pym Storage (#1843)

* Implement pym storage

* Save comment draft + test

* Apply suggestions

* Use class for PymStorage implementation

* Add some comments
This commit is contained in:
Kiwi
2018-09-06 19:07:17 +02:00
committed by Wyatt Johnson
parent 0477d456a2
commit 53e548d77a
36 changed files with 727 additions and 283 deletions
+3
View File
@@ -7,6 +7,7 @@ import {
withClickEvent,
withEventEmitter,
withIOSSafariWidthWorkaround,
withPymStorage,
withSetCommentID,
} from "./decorators";
import PymControl from "./PymControl";
@@ -29,6 +30,8 @@ export function createPymControl(config: CreatePymControlConfig) {
withClickEvent,
withSetCommentID,
withEventEmitter(config.eventEmitter),
withPymStorage(localStorage, "localStorage"),
withPymStorage(sessionStorage, "sessionStorage"),
];
const query = qs.stringify({
@@ -0,0 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`withPymStorage should handle handle errors 1`] = `"[{\\"key\\":\\"pymStorage.localStorage.error\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"0\\\\\\",\\\\\\"error\\\\\\":\\\\\\"error\\\\\\"}\\"}]"`;
exports[`withPymStorage should handle unknown method 1`] = `"[{\\"key\\":\\"pymStorage.localStorage.error\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"0\\\\\\",\\\\\\"error\\\\\\":\\\\\\"Unknown method unknown\\\\\\"}\\"}]"`;
exports[`withPymStorage should set, get and remove item 1`] = `
Object {
"talkPymStorage:key": "test",
}
`;
exports[`withPymStorage should set, get and remove item 2`] = `Object {}`;
exports[`withPymStorage should set, get and remove item 3`] = `"[{\\"key\\":\\"pymStorage.localStorage.response\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"0\\\\\\"}\\"},{\\"key\\":\\"pymStorage.localStorage.response\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"1\\\\\\",\\\\\\"result\\\\\\":\\\\\\"test\\\\\\"}\\"},{\\"key\\":\\"pymStorage.localStorage.response\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"2\\\\\\"}\\"}]"`;
+2 -4
View File
@@ -1,11 +1,9 @@
import pym from "pym.js";
export type CleanupCallback = () => void;
export type Decorator = (pym: pym.Parent) => CleanupCallback | void;
export { Decorator, CleanupCallback } from "./types";
export { default as withAutoHeight } from "./withAutoHeight";
export { default as withClickEvent } from "./withClickEvent";
export { default as withSetCommentID } from "./withSetCommentID";
export { default as withEventEmitter } from "./withEventEmitter";
export { default as withPymStorage } from "./withPymStorage";
export {
default as withIOSSafariWidthWorkaround,
} from "./withIOSSafariWidthWorkaround";
@@ -0,0 +1,4 @@
import pym from "pym.js";
export type CleanupCallback = () => void;
export type Decorator = (pym: pym.Parent) => CleanupCallback | void;
@@ -1,4 +1,4 @@
import { Decorator } from "./";
import { Decorator } from "./types";
const withAutoHeight: Decorator = pym => {
// Resize parent iframe height when child height changes
@@ -1,4 +1,4 @@
import { Decorator } from "./";
import { Decorator } from "./types";
const withClickEvent: Decorator = pym => {
const handleClick = () => pym.sendMessage("click", "");
@@ -1,6 +1,6 @@
import { EventEmitter2 } from "eventemitter2";
import { Decorator } from "./";
import { Decorator } from "./types";
const withEventEmitter = (eventEmitter: EventEmitter2): Decorator => pym => {
// Pass events from iframe to the event emitter.
@@ -1,4 +1,4 @@
import { Decorator } from "./";
import { Decorator } from "./types";
const withIOSSafariWidthWorkaround: Decorator = pym => {
// Workaround: IOS Safari ignores `width` but respects `min-width` value.
@@ -0,0 +1,104 @@
import sinon from "sinon";
import withPymStorage from "./withPymStorage";
// tslint:disable:max-classes-per-file
class FakeStorage {
public store: Record<string, string> = {};
public setItem(key: string, value: string) {
this.store[key] = value;
}
public removeItem(key: string) {
delete this.store[key];
}
public getItem(key: string) {
return this.store[key];
}
}
class PymStub {
public listeners: Record<string, ((msg: string) => void)> = {};
public messages: Array<{ key: string; value: string }> = [];
public type: string;
constructor(type: string) {
this.type = type;
}
public onMessage(key: string, callback: (msg: string) => void) {
this.listeners[key] = callback;
}
public sendMessage(key: string, value: string) {
this.messages.push({ key, value });
}
}
describe("withPymStorage", () => {
it("should set, get and remove item", () => {
const pym = new PymStub("localStorage");
const storage = new FakeStorage();
withPymStorage(storage as any, "localStorage", "talkPymStorage:")(
pym as any
);
pym.listeners["pymStorage.localStorage.request"](
JSON.stringify({
id: "0",
method: "setItem",
parameters: { key: "key", value: "test" },
})
);
expect(storage.store).toMatchSnapshot();
pym.listeners["pymStorage.localStorage.request"](
JSON.stringify({
id: "1",
method: "getItem",
parameters: { key: "key" },
})
);
pym.listeners["pymStorage.localStorage.request"](
JSON.stringify({
id: "2",
method: "removeItem",
parameters: { key: "key" },
})
);
expect(storage.store).toMatchSnapshot();
expect(JSON.stringify(pym.messages)).toMatchSnapshot();
});
it("should handle unknown method", () => {
const pym = new PymStub("localStorage");
const storage = new FakeStorage();
withPymStorage(storage as any, "localStorage", "talkPymStorage:")(
pym as any
);
pym.listeners["pymStorage.localStorage.request"](
JSON.stringify({
id: "0",
method: "unknown",
parameters: {},
})
);
expect(JSON.stringify(pym.messages)).toMatchSnapshot();
});
it("should handle handle errors", () => {
const pym = new PymStub("localStorage");
const storage = new FakeStorage();
sinon
.mock(storage)
.expects("getItem")
.throws("error");
withPymStorage(storage as any, "localStorage", "talkPymStorage:")(
pym as any
);
pym.listeners["pymStorage.localStorage.request"](
JSON.stringify({
id: "0",
method: "getItem",
parameters: {},
})
);
expect(JSON.stringify(pym.messages)).toMatchSnapshot();
});
});
@@ -0,0 +1,52 @@
import { Decorator } from "./types";
const withPymStorage = (
storage: Storage,
type: "localStorage" | "sessionStorage",
prefix = "talkPymStorage:"
): Decorator => pym => {
pym.onMessage(`pymStorage.${type}.request`, (msg: any) => {
const { id, method, parameters } = JSON.parse(msg);
const { key, value } = parameters;
const prefixedKey = `${prefix}${key}`;
// Variable for the method return value.
let result;
const sendError = (error: string) => {
// tslint:disable-next-line:no-console
console.error(error);
pym.sendMessage(
`pymStorage.${type}.error`,
JSON.stringify({ id, error })
);
};
try {
switch (method) {
case "setItem":
result = storage.setItem(prefixedKey, value);
break;
case "getItem":
result = storage.getItem(prefixedKey);
break;
case "removeItem":
result = storage.removeItem(prefixedKey);
break;
default:
sendError(`Unknown method ${method}`);
return;
}
} catch (err) {
sendError(err.toString());
return;
}
pym.sendMessage(
`pymStorage.${type}.response`,
JSON.stringify({ id, result })
);
});
};
export default withPymStorage;
@@ -1,7 +1,7 @@
import qs from "query-string";
import { buildURL } from "../utils";
import { Decorator } from "./";
import { Decorator } from "./types";
function getCurrentCommentID() {
return qs.parse(location.search).commentID;
@@ -8,6 +8,7 @@ import { Environment } from "relay-runtime";
import { PostMessageService } from "talk-framework/lib/postMessage";
import { RestClient } from "talk-framework/lib/rest";
import { PymStorage } from "talk-framework/lib/storage";
import { UIContext } from "talk-ui/components";
import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside";
@@ -21,12 +22,18 @@ export interface TalkContext {
/** formatter for timeago. */
timeagoFormatter?: Formatter;
/** Session Storage */
/** Local Storage */
localStorage: Storage;
/** Session storage */
sessionStorage: Storage;
/** Local Storage over pym */
pymLocalStorage?: PymStorage;
/** Session storage over pym */
pymSessionStorage?: PymStorage;
/** media query values for testing purposes */
mediaQueryValues?: MediaQueryMatchers;
@@ -8,6 +8,7 @@ import { Environment, Network, RecordSource, Store } from "relay-runtime";
import { LOCAL_ID } from "talk-framework/lib/relay";
import {
createLocalStorage,
createPymStorage,
createSessionStorage,
} from "talk-framework/lib/storage";
@@ -118,6 +119,8 @@ export default async function createContext({
postMessage: new PostMessageService(),
localStorage: createLocalStorage(),
sessionStorage: createSessionStorage(),
pymLocalStorage: pym && createPymStorage(pym, "localStorage"),
pymSessionStorage: pym && createPymStorage(pym, "sessionStorage"),
};
// Run custom initializations.
@@ -38,6 +38,10 @@ class InMemoryStorage implements Storage {
public removeItem(key: string) {
delete this.storage[key];
}
public toString() {
return JSON.stringify(this.storage);
}
}
export default function createInMemoryStorage() {
@@ -0,0 +1,100 @@
import createPymStorage from "./PymStorage";
class PymStub {
public listeners: Record<string, ((msg: string) => void)> = {};
public messages: Array<{ key: string; value: string }> = [];
public type: string;
constructor(type: string) {
this.type = type;
}
public onMessage(key: string, callback: (msg: string) => void) {
this.listeners[key] = callback;
}
public sendMessage(key: string, value: string) {
this.messages.push({ key, value });
}
}
describe("PymStorage", () => {
it("should set item", () => {
const pym = new PymStub("localStorage");
const storage = createPymStorage(pym as any, "localStorage");
const promise = storage.setItem("test", "value");
const { key, value } = pym.messages.pop()!;
expect(key).toBe(`pymStorage.localStorage.request`);
const { id, method, parameters } = JSON.parse(value);
expect(method).toBe("setItem");
expect(parameters).toEqual({ key: "test", value: "value" });
pym.listeners["pymStorage.localStorage.response"](JSON.stringify({ id }));
expect(promise).resolves.toBeUndefined();
});
it("should remove item", () => {
const pym = new PymStub("localStorage");
const storage = createPymStorage(pym as any, "localStorage");
const promise = storage.removeItem("test");
const { key, value } = pym.messages.pop()!;
expect(key).toBe(`pymStorage.localStorage.request`);
const { id, method, parameters } = JSON.parse(value);
expect(method).toBe("removeItem");
expect(parameters).toEqual({ key: "test" });
pym.listeners["pymStorage.localStorage.response"](JSON.stringify({ id }));
expect(promise).resolves.toBeUndefined();
});
it("should get item", () => {
const pym = new PymStub("localStorage");
const storage = createPymStorage(pym as any, "localStorage");
const promise = storage.getItem("test");
const { key, value } = pym.messages.pop()!;
expect(key).toBe(`pymStorage.localStorage.request`);
const { id, method, parameters } = JSON.parse(value);
expect(method).toBe("getItem");
expect(parameters).toEqual({ key: "test" });
pym.listeners["pymStorage.localStorage.response"](
JSON.stringify({ id, result: "value" })
);
expect(promise).resolves.toBe("value");
});
describe("on error", () => {
it("should reject set item", () => {
const pym = new PymStub("localStorage");
const storage = createPymStorage(pym as any, "localStorage");
const promise = storage.setItem("test", "value");
const { key, value } = pym.messages.pop()!;
expect(key).toBe(`pymStorage.localStorage.request`);
const { id } = JSON.parse(value);
pym.listeners["pymStorage.localStorage.error"](
JSON.stringify({ id, error: "error" })
);
expect(promise).rejects.toThrow(new Error("error"));
});
it("should reject remove item", () => {
const pym = new PymStub("localStorage");
const storage = createPymStorage(pym as any, "localStorage");
const promise = storage.removeItem("test");
const { key, value } = pym.messages.pop()!;
expect(key).toBe(`pymStorage.localStorage.request`);
const { id } = JSON.parse(value);
pym.listeners["pymStorage.localStorage.error"](
JSON.stringify({ id, error: "error" })
);
expect(promise).rejects.toThrow(new Error("error"));
});
it("should reject get item", () => {
const pym = new PymStub("localStorage");
const storage = createPymStorage(pym as any, "localStorage");
const promise = storage.getItem("test");
const { key, value } = pym.messages.pop()!;
expect(key).toBe(`pymStorage.localStorage.request`);
const { id } = JSON.parse(value);
pym.listeners["pymStorage.localStorage.error"](
JSON.stringify({ id, error: "error" })
);
expect(promise).rejects.toThrow(new Error("error"));
});
});
});
@@ -0,0 +1,94 @@
import { Child, Parent } from "pym.js";
import uuid from "uuid/v4";
type Pym = Child | Parent;
export interface PymStorage {
/**
* value = storage[key]
*/
getItem(key: string): Promise<string | null>;
/**
* delete storage[key]
*/
removeItem(key: string): Promise<void>;
/**
* storage[key] = value
*/
setItem(key: string, value: string): Promise<void>;
}
class PymStorageImpl implements PymStorage {
/** Instance to pym */
private pym: Pym;
/** Requested storage type */
private type: string;
/** A Map of requestID => {resolve, reject} */
private requests: Record<
string,
{ resolve: ((v: any) => void); reject: ((v: any) => void) }
> = {};
/** Requests method with parameters over pym. */
private call<T>(
method: string,
parameters: { key: string; value?: string }
): Promise<T> {
const id = uuid();
return new Promise((resolve, reject) => {
this.requests[id] = { resolve, reject };
this.pym.sendMessage(
`pymStorage.${this.type}.request`,
JSON.stringify({ id, method, parameters })
);
});
}
/** Listen to pym responses */
private listen() {
// Receive successful responses.
this.pym.onMessage(`pymStorage.${this.type}.response`, (msg: string) => {
const { id, result } = JSON.parse(msg);
this.requests[id].resolve(result);
delete this.requests[id];
});
// Receive error responses.
this.pym.onMessage(`pymStorage.${this.type}.error`, (msg: string) => {
const { id, error } = JSON.parse(msg);
this.requests[id].reject(new Error(error));
delete this.requests[id];
});
}
constructor(pym: Pym, type: string) {
this.pym = pym;
this.type = type;
this.listen();
}
public setItem(key: string, value: string) {
return this.call<void>("setItem", { key, value });
}
public getItem(key: string) {
return this.call<string | null>("getItem", { key });
}
public removeItem(key: string) {
return this.call<void>("removeItem", { key });
}
}
/**
* Creates a storage that put requests onto pym.
* This is the counterpart of `connectStorageToPym`.
* @param {string} pym pym
* @return {Object} storage
*/
export default function createPymStorage(
pym: Pym,
type: "localStorage" | "sessionStorage"
): PymStorage {
return new PymStorageImpl(pym, type);
}
@@ -1,3 +1,4 @@
export { default as createInMemoryStorage } from "./InMemoryStorage";
export { default as createLocalStorage } from "./LocalStorage";
export { default as createSessionStorage } from "./SessionStorage";
export { default as createPymStorage, PymStorage } from "./PymStorage";
@@ -0,0 +1,21 @@
import { PymStorage } from "talk-framework/lib/storage";
export class FakeStorage implements PymStorage {
public store: Record<string, string> = {};
public setItem(key: string, value: string) {
this.store[key] = value;
return Promise.resolve();
}
public removeItem(key: string) {
delete this.store[key];
return Promise.resolve();
}
public getItem(key: string) {
return Promise.resolve(this.store[key]);
}
}
export default function createFakePymStorage() {
return new FakeStorage();
}
@@ -4,6 +4,7 @@ export {
} from "./createRelayEnvironment";
export { default as createFluentBundle } from "./createFluentBundle";
export { default as createSinonStub } from "./createSinonStub";
export { default as createFakePymStorage } from "./createFakePymStorage";
export {
default as removeFragmentRefs,
NoFragmentRefs,
@@ -1,6 +1,7 @@
import { FormState } from "final-form";
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Field, Form } from "react-final-form";
import { Field, Form, FormSpy } from "react-final-form";
import { OnSubmit } from "talk-framework/lib/form";
import { required } from "talk-framework/lib/validation";
@@ -22,10 +23,12 @@ interface FormProps {
export interface PostCommentFormProps {
onSubmit: OnSubmit<FormProps>;
onChange?: (state: FormState) => void;
initialValues?: FormProps;
}
const PostCommentForm: StatelessComponent<PostCommentFormProps> = props => (
<Form onSubmit={props.onSubmit}>
<Form onSubmit={props.onSubmit} initialValues={props.initialValues}>
{({ handleSubmit, submitting }) => (
<form
autoComplete="off"
@@ -33,6 +36,7 @@ const PostCommentForm: StatelessComponent<PostCommentFormProps> = props => (
className={styles.root}
id="comments-postCommentForm-form"
>
<FormSpy onChange={props.onChange} />
<HorizontalGutter>
<Field name="body" validate={required}>
{({ input, meta }) => (
@@ -213,7 +213,7 @@ exports[`when use is logged in renders correctly 1`] = `
<withContext(createMutationContainer(withContext(createMutationContainer(withContext(createMutationContainer(withContext(withLocalStateContainer(Relay(UserBoxContainer)))))))))
user={Object {}}
/>
<withContext(createMutationContainer(PostCommentFormContainer))
<withContext(withContext(createMutationContainer(PostCommentFormContainer)))
assetID="asset-id"
/>
</withPropsOnChange(HorizontalGutter)>
@@ -0,0 +1,99 @@
import { shallow } from "enzyme";
import { noop } from "lodash";
import React from "react";
import sinon from "sinon";
import { PropTypesOf } from "talk-framework/types";
import { timeout } from "talk-common/utils";
import { createFakePymStorage } from "talk-framework/testHelpers";
import { PostCommentFormContainer } from "./PostCommentFormContainer";
const contextKey = "postCommentFormBody";
it("renders correctly", async () => {
const props: PropTypesOf<typeof PostCommentFormContainer> = {
// tslint:disable-next-line:no-empty
createComment: (() => {}) as any,
assetID: "asset-id",
pymSessionStorage: createFakePymStorage(),
};
const wrapper = shallow(<PostCommentFormContainer {...props} />);
await timeout();
wrapper.update();
expect(wrapper).toMatchSnapshot();
});
it("renders with initialValues", async () => {
const props: PropTypesOf<typeof PostCommentFormContainer> = {
// tslint:disable-next-line:no-empty
createComment: (() => {}) as any,
assetID: "asset-id",
pymSessionStorage: createFakePymStorage(),
};
await props.pymSessionStorage.setItem(contextKey, "Hello World!");
const wrapper = shallow(<PostCommentFormContainer {...props} />);
await timeout();
wrapper.update();
expect(wrapper).toMatchSnapshot();
});
it("save values", async () => {
const props: PropTypesOf<typeof PostCommentFormContainer> = {
// tslint:disable-next-line:no-empty
createComment: (() => {}) as any,
assetID: "asset-id",
pymSessionStorage: createFakePymStorage(),
};
await props.pymSessionStorage.setItem(contextKey, "Hello World!");
const wrapper = shallow(<PostCommentFormContainer {...props} />);
await timeout();
wrapper.update();
wrapper
.first()
.props()
.onChange({ values: { body: "changed" } });
expect(await props.pymSessionStorage.getItem(contextKey)).toBe("changed");
});
it("creates a comment", async () => {
const assetID = "asset-id";
const input = { body: "Hello World!" };
const createCommentStub = sinon.stub();
const form = { reset: noop };
const formMock = sinon.mock(form);
formMock
.expects("reset")
.withArgs({})
.once();
const props: PropTypesOf<typeof PostCommentFormContainer> = {
// tslint:disable-next-line:no-empty
createComment: createCommentStub,
assetID,
pymSessionStorage: createFakePymStorage(),
};
await props.pymSessionStorage.setItem(contextKey, "Hello World!");
const wrapper = shallow(<PostCommentFormContainer {...props} />);
await timeout();
wrapper.update();
wrapper
.first()
.props()
.onSubmit(input, form);
expect(
createCommentStub.calledWith({
assetID,
...input,
})
).toBeTruthy();
await timeout();
formMock.verify();
});
@@ -1,6 +1,8 @@
import React, { Component, ReactNode } from "react";
import React, { Component } from "react";
import { withContext } from "talk-framework/lib/bootstrap";
import { BadUserInputError } from "talk-framework/lib/errors";
import { PymStorage } from "talk-framework/lib/storage";
import { PropTypesOf } from "talk-framework/types";
import PostCommentForm, {
@@ -11,17 +13,48 @@ import { CreateCommentMutation, withCreateCommentMutation } from "../mutations";
interface InnerProps {
createComment: CreateCommentMutation;
assetID: string;
children?: ReactNode;
pymSessionStorage: PymStorage;
}
class PostCommentFormContainer extends Component<InnerProps> {
private onSubmit: PostCommentFormProps["onSubmit"] = async (input, form) => {
interface State {
initialValues?: PostCommentFormProps["initialValues"];
initialized: boolean;
}
const contextKey = "postCommentFormBody";
export class PostCommentFormContainer extends Component<InnerProps, State> {
public state: State = { initialized: false };
constructor(props: InnerProps) {
super(props);
this.init();
}
private async init() {
const body = await this.props.pymSessionStorage.getItem(contextKey);
if (body) {
this.setState({
initialValues: {
body,
},
});
}
this.setState({
initialized: true,
});
}
private handleOnSubmit: PostCommentFormProps["onSubmit"] = async (
input,
form
) => {
try {
await this.props.createComment({
assetID: this.props.assetID,
...input,
});
form.reset();
form.reset({});
} catch (error) {
if (error instanceof BadUserInputError) {
return error.invalidArgsLocalized;
@@ -31,11 +64,31 @@ class PostCommentFormContainer extends Component<InnerProps> {
}
return undefined;
};
private handleOnChange: PostCommentFormProps["onChange"] = state => {
if (state.values.body) {
this.props.pymSessionStorage.setItem(contextKey, state.values.body);
} else {
this.props.pymSessionStorage.removeItem(contextKey);
}
};
public render() {
return <PostCommentForm onSubmit={this.onSubmit} />;
if (!this.state.initialized) {
return null;
}
return (
<PostCommentForm
onSubmit={this.handleOnSubmit}
onChange={this.handleOnChange}
initialValues={this.state.initialValues}
/>
);
}
}
const enhanced = withCreateCommentMutation(PostCommentFormContainer);
const enhanced = withContext(({ pymSessionStorage }) => ({
pymSessionStorage,
}))(withCreateCommentMutation(PostCommentFormContainer));
export type PostCommentFormContainerProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -0,0 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<PostCommentForm
onChange={[Function]}
onSubmit={[Function]}
/>
`;
exports[`renders with initialValues 1`] = `
<PostCommentForm
initialValues={
Object {
"body": "Hello World!",
}
}
onChange={[Function]}
onSubmit={[Function]}
/>
`;
@@ -19,12 +19,14 @@ beforeEach(() => {
});
it("init local state", async () => {
initLocalState(environment, { localStorage: createInMemoryStorage() } as any);
await initLocalState(environment, {
localStorage: createInMemoryStorage(),
} as any);
await timeout();
expect(JSON.stringify(source.toJSON(), null, 2)).toMatchSnapshot();
});
it("set assetID from query", () => {
it("set assetID from query", async () => {
const assetID = "asset-id";
const previousLocation = location.toString();
const previousState = window.history.state;
@@ -33,12 +35,14 @@ it("set assetID from query", () => {
document.title,
`http://localhost/?assetID=${assetID}`
);
initLocalState(environment, { localStorage: createInMemoryStorage() } as any);
await initLocalState(environment, {
localStorage: createInMemoryStorage(),
} as any);
expect(source.get(LOCAL_ID)!.assetID).toBe(assetID);
window.history.replaceState(previousState, document.title, previousLocation);
});
it("set commentID from query", () => {
it("set commentID from query", async () => {
const commentID = "comment-id";
const previousLocation = location.toString();
const previousState = window.history.state;
@@ -47,16 +51,18 @@ it("set commentID from query", () => {
document.title,
`http://localhost/?commentID=${commentID}`
);
initLocalState(environment, { localStorage: createInMemoryStorage() } as any);
await initLocalState(environment, {
localStorage: createInMemoryStorage(),
} as any);
expect(source.get(LOCAL_ID)!.commentID).toBe(commentID);
window.history.replaceState(previousState, document.title, previousLocation);
});
it("set authToken from localStorage", () => {
it("set authToken from localStorage", async () => {
const authToken = "auth-token";
const localStorage = createInMemoryStorage();
localStorage.setItem("authToken", authToken);
initLocalState(environment, { localStorage } as any);
await initLocalState(environment, { localStorage } as any);
expect(source.get(LOCAL_ID)!.authToken).toBe(authToken);
localStorage.removeItem("authToken");
});
@@ -22,6 +22,8 @@ export default async function initLocalState(
environment: Environment,
{ localStorage }: TalkContext
) {
const authToken = await localStorage.getItem("authToken");
commitLocalUpdate(environment, s => {
// TODO: (cvle) move local, auth token and network initialization to framework.
const root = s.getRoot();
@@ -31,7 +33,7 @@ export default async function initLocalState(
root.setLinkedRecord(localRecord, "local");
// Set auth token
localRecord.setValue(localStorage.getItem("authToken") || "", "authToken");
localRecord.setValue(authToken || "", "authToken");
// Set initial auth revision, this is increment whenenver auth state might have changed.
localRecord.setValue(0, "authRevision");
@@ -194,10 +194,10 @@ exports[`post a comment 1`] = `
</span>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:24:00.002Z"
title="2018-07-06T18:24:00.002Z"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:24:00.002Z
2018-07-06T18:24:00.000Z
</time>
</div>
<div
+59
View File
@@ -0,0 +1,59 @@
import { IResolvers } from "graphql-tools";
import React from "react";
import TestRenderer from "react-test-renderer";
import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import { PostMessageService } from "talk-framework/lib/postMessage";
import { RestClient } from "talk-framework/lib/rest";
import { createInMemoryStorage } from "talk-framework/lib/storage";
import { createFakePymStorage } from "talk-framework/testHelpers";
import AppContainer from "talk-stream/containers/AppContainer";
import createEnvironment from "./createEnvironment";
import createFluentBundle from "./createFluentBundle";
import createNodeMock from "./createNodeMock";
interface CreateParams {
logNetwork?: boolean;
resolvers: IResolvers<any, any>;
initLocalState?: (
local: RecordProxy,
source: RecordSourceProxy,
environment: Environment
) => void;
}
export default function create(params: CreateParams) {
const environment = createEnvironment({
// Set this to true, to see graphql responses.
logNetwork: params.logNetwork,
resolvers: params.resolvers,
initLocalState: (localRecord, source, env) => {
localRecord.setValue(0, "authRevision");
if (params.initLocalState) {
params.initLocalState(localRecord, source, env);
}
},
});
const context: TalkContext = {
relayEnvironment: environment,
localeBundles: [createFluentBundle()],
localStorage: createInMemoryStorage(),
sessionStorage: createInMemoryStorage(),
pymLocalStorage: createFakePymStorage(),
pymSessionStorage: createFakePymStorage(),
rest: new RestClient("http://localhost/api"),
postMessage: new PostMessageService(),
};
const testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>,
{ createNodeMock }
);
return { context, testRenderer };
}
+5 -30
View File
@@ -1,17 +1,9 @@
import React from "react";
import TestRenderer, { ReactTestRenderer } from "react-test-renderer";
import { ReactTestRenderer } from "react-test-renderer";
import { timeout } from "talk-common/utils";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import { PostMessageService } from "talk-framework/lib/postMessage";
import { RestClient } from "talk-framework/lib/rest";
import { createInMemoryStorage } from "talk-framework/lib/storage";
import { createSinonStub } from "talk-framework/testHelpers";
import AppContainer from "talk-stream/containers/AppContainer";
import createEnvironment from "./createEnvironment";
import createFluentBundle from "./createFluentBundle";
import createNodeMock from "./createNodeMock";
import create from "./create";
import { assets, comments } from "./fixtures";
let testRenderer: ReactTestRenderer;
@@ -68,31 +60,14 @@ beforeEach(() => {
},
};
const environment = createEnvironment({
({ testRenderer } = create({
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
initLocalState: (localRecord, source) => {
localRecord.setValue(0, "authRevision");
initLocalState: localRecord => {
localRecord.setValue(assetStub.id, "assetID");
},
});
const context: TalkContext = {
relayEnvironment: environment,
localeBundles: [createFluentBundle()],
localStorage: createInMemoryStorage(),
sessionStorage: createInMemoryStorage(),
rest: new RestClient("http://localhost/api"),
postMessage: new PostMessageService(),
};
testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>,
{ createNodeMock }
);
}));
});
it("renders comment stream", async () => {
@@ -1,19 +1,10 @@
import React from "react";
import TestRenderer, { ReactTestRenderer } from "react-test-renderer";
import { RecordProxy } from "relay-runtime";
import { ReactTestRenderer } from "react-test-renderer";
import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import { PostMessageService } from "talk-framework/lib/postMessage";
import { RestClient } from "talk-framework/lib/rest";
import { createInMemoryStorage } from "talk-framework/lib/storage";
import { createSinonStub } from "talk-framework/testHelpers";
import AppContainer from "talk-stream/containers/AppContainer";
import createEnvironment from "./createEnvironment";
import createFluentBundle from "./createFluentBundle";
import createNodeMock from "./createNodeMock";
import create from "./create";
import { assets, comments } from "./fixtures";
let testRenderer: ReactTestRenderer;
@@ -50,32 +41,15 @@ beforeEach(() => {
},
};
const environment = createEnvironment({
({ testRenderer } = create({
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
initLocalState: (localRecord: RecordProxy) => {
localRecord.setValue(0, "authRevision");
initLocalState: localRecord => {
localRecord.setValue(assetStub.id, "assetID");
localRecord.setValue(commentStub.id, "commentID");
},
});
const context: TalkContext = {
relayEnvironment: environment,
localeBundles: [createFluentBundle()],
localStorage: createInMemoryStorage(),
sessionStorage: createInMemoryStorage(),
rest: new RestClient("http://localhost/api"),
postMessage: new PostMessageService(),
};
testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>,
{ createNodeMock }
);
}));
});
it("renders permalink view", async () => {
@@ -1,17 +1,8 @@
import React from "react";
import TestRenderer, { ReactTestRenderer } from "react-test-renderer";
import { RecordProxy } from "relay-runtime";
import { ReactTestRenderer } from "react-test-renderer";
import { timeout } from "talk-common/utils";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import { PostMessageService } from "talk-framework/lib/postMessage";
import { RestClient } from "talk-framework/lib/rest";
import { createInMemoryStorage } from "talk-framework/lib/storage";
import AppContainer from "talk-stream/containers/AppContainer";
import createEnvironment from "./createEnvironment";
import createFluentBundle from "./createFluentBundle";
import createNodeMock from "./createNodeMock";
import create from "./create";
let testRenderer: ReactTestRenderer;
beforeEach(() => {
@@ -22,32 +13,15 @@ beforeEach(() => {
},
};
const environment = createEnvironment({
({ testRenderer } = create({
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
initLocalState: (localRecord: RecordProxy) => {
localRecord.setValue(0, "authRevision");
initLocalState: localRecord => {
localRecord.setValue("unknown-asset-id", "assetID");
localRecord.setValue("unknown-comment-id", "commentID");
},
});
const context: TalkContext = {
relayEnvironment: environment,
localeBundles: [createFluentBundle()],
localStorage: createInMemoryStorage(),
sessionStorage: createInMemoryStorage(),
rest: new RestClient("http://localhost/api"),
postMessage: new PostMessageService(),
};
testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>,
{ createNodeMock }
);
}));
});
it("renders permalink view with unknown asset", async () => {
@@ -1,19 +1,10 @@
import React from "react";
import TestRenderer, { ReactTestRenderer } from "react-test-renderer";
import { RecordProxy } from "relay-runtime";
import { ReactTestRenderer } from "react-test-renderer";
import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import { PostMessageService } from "talk-framework/lib/postMessage";
import { RestClient } from "talk-framework/lib/rest";
import { createInMemoryStorage } from "talk-framework/lib/storage";
import { createSinonStub } from "talk-framework/testHelpers";
import AppContainer from "talk-stream/containers/AppContainer";
import createEnvironment from "./createEnvironment";
import createFluentBundle from "./createFluentBundle";
import createNodeMock from "./createNodeMock";
import create from "./create";
import { assets, comments } from "./fixtures";
let testRenderer: ReactTestRenderer;
@@ -47,32 +38,15 @@ beforeEach(() => {
},
};
const environment = createEnvironment({
({ testRenderer } = create({
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
initLocalState: (localRecord: RecordProxy) => {
initLocalState: localRecord => {
localRecord.setValue(assetStub.id, "assetID");
localRecord.setValue("unknown-comment-id", "commentID");
localRecord.setValue(0, "authRevision");
},
});
const context: TalkContext = {
relayEnvironment: environment,
localeBundles: [createFluentBundle()],
localStorage: createInMemoryStorage(),
sessionStorage: createInMemoryStorage(),
rest: new RestClient("http://localhost/api"),
postMessage: new PostMessageService(),
};
testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>,
{ createNodeMock }
);
}));
});
it("renders permalink view with unknown comment", async () => {
@@ -1,19 +1,10 @@
import React from "react";
import TestRenderer, { ReactTestRenderer } from "react-test-renderer";
import { RecordProxy } from "relay-runtime";
import { ReactTestRenderer } from "react-test-renderer";
import timekeeper from "timekeeper";
import { timeout } from "talk-common/utils";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import { PostMessageService } from "talk-framework/lib/postMessage";
import { RestClient } from "talk-framework/lib/rest";
import { createInMemoryStorage } from "talk-framework/lib/storage";
import { createSinonStub } from "talk-framework/testHelpers";
import AppContainer from "talk-stream/containers/AppContainer";
import createEnvironment from "./createEnvironment";
import createFluentBundle from "./createFluentBundle";
import createNodeMock from "./createNodeMock";
import create from "./create";
import { assets, users } from "./fixtures";
let testRenderer: ReactTestRenderer;
@@ -58,31 +49,14 @@ beforeEach(() => {
},
};
const environment = createEnvironment({
({ testRenderer } = create({
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
initLocalState: (localRecord: RecordProxy) => {
initLocalState: localRecord => {
localRecord.setValue(assets[0].id, "assetID");
localRecord.setValue(0, "authRevision");
},
});
const context: TalkContext = {
relayEnvironment: environment,
localeBundles: [createFluentBundle()],
localStorage: createInMemoryStorage(),
sessionStorage: createInMemoryStorage(),
rest: new RestClient("http://localhost/api"),
postMessage: new PostMessageService(),
};
testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>,
{ createNodeMock }
);
}));
});
it("renders comment stream", async () => {
@@ -92,25 +66,26 @@ it("renders comment stream", async () => {
});
it("post a comment", async () => {
// Wait for loading.
await timeout();
testRenderer.root
.findByProps({ inputId: "comments-postCommentForm-field" })
.props.onChange({ html: "<strong>Hello world!</strong>" });
timekeeper.freeze(new Date("2018-07-06T18:24:00.002Z"));
timekeeper.freeze(new Date("2018-07-06T18:24:00.000Z"));
testRenderer.root
.findByProps({ id: "comments-postCommentForm-form" })
.props.onSubmit();
timekeeper.reset();
// Test optimistic response.
expect(testRenderer.toJSON()).toMatchSnapshot();
// Wait for loading.
await timeout();
// Travel to the time where the "timeout" has executed.
timekeeper.travel(new Date("2018-07-06T18:24:01.002Z"));
// Test after server response.
expect(testRenderer.toJSON()).toMatchSnapshot();
timekeeper.reset();
});
@@ -1,18 +1,9 @@
import React from "react";
import TestRenderer, { ReactTestRenderer } from "react-test-renderer";
import { RecordProxy } from "relay-runtime";
import { ReactTestRenderer } from "react-test-renderer";
import { timeout } from "talk-common/utils";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import { PostMessageService } from "talk-framework/lib/postMessage";
import { RestClient } from "talk-framework/lib/rest";
import { createInMemoryStorage } from "talk-framework/lib/storage";
import { createSinonStub } from "talk-framework/testHelpers";
import AppContainer from "talk-stream/containers/AppContainer";
import createEnvironment from "./createEnvironment";
import createFluentBundle from "./createFluentBundle";
import createNodeMock from "./createNodeMock";
import create from "./create";
import { assetWithReplies } from "./fixtures";
let testRenderer: ReactTestRenderer;
@@ -29,31 +20,14 @@ beforeEach(() => {
},
};
const environment = createEnvironment({
({ testRenderer } = create({
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
initLocalState: (localRecord: RecordProxy) => {
initLocalState: localRecord => {
localRecord.setValue(assetWithReplies.id, "assetID");
localRecord.setValue(0, "authRevision");
},
});
const context: TalkContext = {
relayEnvironment: environment,
localeBundles: [createFluentBundle()],
localStorage: createInMemoryStorage(),
sessionStorage: createInMemoryStorage(),
rest: new RestClient("http://localhost/api"),
postMessage: new PostMessageService(),
};
testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>,
{ createNodeMock }
);
}));
});
it("renders comment stream", async () => {
@@ -1,18 +1,9 @@
import React from "react";
import TestRenderer, { ReactTestRenderer } from "react-test-renderer";
import { RecordProxy } from "relay-runtime";
import { ReactTestRenderer } from "react-test-renderer";
import { timeout } from "talk-common/utils";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import { PostMessageService } from "talk-framework/lib/postMessage";
import { RestClient } from "talk-framework/lib/rest";
import { createInMemoryStorage } from "talk-framework/lib/storage";
import { createSinonStub } from "talk-framework/testHelpers";
import AppContainer from "talk-stream/containers/AppContainer";
import createEnvironment from "./createEnvironment";
import createFluentBundle from "./createFluentBundle";
import createNodeMock from "./createNodeMock";
import create from "./create";
import { assets } from "./fixtures";
let testRenderer: ReactTestRenderer;
@@ -26,31 +17,14 @@ beforeEach(() => {
},
};
const environment = createEnvironment({
({ testRenderer } = create({
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
initLocalState: (localRecord: RecordProxy) => {
initLocalState: localRecord => {
localRecord.setValue(assets[0].id, "assetID");
localRecord.setValue(0, "authRevision");
},
});
const context: TalkContext = {
relayEnvironment: environment,
localeBundles: [createFluentBundle()],
localStorage: createInMemoryStorage(),
sessionStorage: createInMemoryStorage(),
rest: new RestClient("http://localhost/api"),
postMessage: new PostMessageService(),
};
testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>,
{ createNodeMock }
);
}));
});
it("renders comment stream", async () => {
@@ -1,19 +1,10 @@
import React from "react";
import TestRenderer, { ReactTestRenderer } from "react-test-renderer";
import { RecordProxy } from "relay-runtime";
import { ReactTestRenderer } from "react-test-renderer";
import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import { PostMessageService } from "talk-framework/lib/postMessage";
import { RestClient } from "talk-framework/lib/rest";
import { createInMemoryStorage } from "talk-framework/lib/storage";
import { createSinonStub } from "talk-framework/testHelpers";
import AppContainer from "talk-stream/containers/AppContainer";
import createEnvironment from "./createEnvironment";
import createFluentBundle from "./createFluentBundle";
import createNodeMock from "./createNodeMock";
import create from "./create";
import { assets, comments } from "./fixtures";
let testRenderer: ReactTestRenderer;
@@ -85,31 +76,14 @@ beforeEach(() => {
},
};
const environment = createEnvironment({
({ testRenderer } = create({
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
initLocalState: (localRecord: RecordProxy) => {
initLocalState: localRecord => {
localRecord.setValue(assetStub.id, "assetID");
localRecord.setValue(0, "authRevision");
},
});
const context: TalkContext = {
relayEnvironment: environment,
localeBundles: [createFluentBundle()],
localStorage: createInMemoryStorage(),
sessionStorage: createInMemoryStorage(),
rest: new RestClient("http://localhost/api"),
postMessage: new PostMessageService(),
};
testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>,
{ createNodeMock }
);
}));
});
it("renders comment stream", async () => {