From ccf91480da401cb765bdad6be87bd59ddeca4fa4 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 31 Aug 2018 15:09:00 +0200 Subject: [PATCH 01/26] Implement pym storage --- src/core/client/embed/Stream.ts | 3 + .../__snapshots__/withPymStorage.spec.ts.snap | 15 +++ src/core/client/embed/decorators/index.ts | 1 + .../embed/decorators/withPymStorage.spec.ts | 104 ++++++++++++++++++ .../client/embed/decorators/withPymStorage.ts | 52 +++++++++ .../framework/lib/bootstrap/TalkContext.tsx | 1 + .../framework/lib/bootstrap/createContext.tsx | 9 +- .../framework/lib/storage/InMemoryStorage.ts | 4 + .../framework/lib/storage/PymStorage.spec.ts | 100 +++++++++++++++++ .../framework/lib/storage/PymStorage.ts | 57 ++++++++++ .../__snapshots__/PymStorage.spec.ts.snap | 11 ++ .../client/framework/lib/storage/index.ts | 2 + .../client/framework/lib/storage/interface.ts | 14 +++ .../client/stream/local/initLocalState.ts | 4 +- 14 files changed, 374 insertions(+), 3 deletions(-) create mode 100644 src/core/client/embed/decorators/__snapshots__/withPymStorage.spec.ts.snap create mode 100644 src/core/client/embed/decorators/withPymStorage.spec.ts create mode 100644 src/core/client/embed/decorators/withPymStorage.ts create mode 100644 src/core/client/framework/lib/storage/PymStorage.spec.ts create mode 100644 src/core/client/framework/lib/storage/PymStorage.ts create mode 100644 src/core/client/framework/lib/storage/__snapshots__/PymStorage.spec.ts.snap create mode 100644 src/core/client/framework/lib/storage/interface.ts diff --git a/src/core/client/embed/Stream.ts b/src/core/client/embed/Stream.ts index ae47e450e..5486ea8a3 100644 --- a/src/core/client/embed/Stream.ts +++ b/src/core/client/embed/Stream.ts @@ -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({ diff --git a/src/core/client/embed/decorators/__snapshots__/withPymStorage.spec.ts.snap b/src/core/client/embed/decorators/__snapshots__/withPymStorage.spec.ts.snap new file mode 100644 index 000000000..6cc6e2817 --- /dev/null +++ b/src/core/client/embed/decorators/__snapshots__/withPymStorage.spec.ts.snap @@ -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\\\\\\"}\\"}]"`; diff --git a/src/core/client/embed/decorators/index.ts b/src/core/client/embed/decorators/index.ts index 6e2d45195..5a90f0280 100644 --- a/src/core/client/embed/decorators/index.ts +++ b/src/core/client/embed/decorators/index.ts @@ -6,6 +6,7 @@ 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"; diff --git a/src/core/client/embed/decorators/withPymStorage.spec.ts b/src/core/client/embed/decorators/withPymStorage.spec.ts new file mode 100644 index 000000000..60ab2ed0f --- /dev/null +++ b/src/core/client/embed/decorators/withPymStorage.spec.ts @@ -0,0 +1,104 @@ +import sinon from "sinon"; + +import withPymStorage from "./withPymStorage"; + +// tslint:disable:max-classes-per-file + +class FakeStorage { + public store: Record = {}; + + 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 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(); + }); +}); diff --git a/src/core/client/embed/decorators/withPymStorage.ts b/src/core/client/embed/decorators/withPymStorage.ts new file mode 100644 index 000000000..eab731d71 --- /dev/null +++ b/src/core/client/embed/decorators/withPymStorage.ts @@ -0,0 +1,52 @@ +import { Decorator } from "./"; + +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; diff --git a/src/core/client/framework/lib/bootstrap/TalkContext.tsx b/src/core/client/framework/lib/bootstrap/TalkContext.tsx index a77a14576..5b5880883 100644 --- a/src/core/client/framework/lib/bootstrap/TalkContext.tsx +++ b/src/core/client/framework/lib/bootstrap/TalkContext.tsx @@ -8,6 +8,7 @@ import { Environment } from "relay-runtime"; import { PostMessageService } from "talk-framework/lib/postMessage"; import { RestClient } from "talk-framework/lib/rest"; +import { Storage } from "talk-framework/lib/storage"; import { UIContext } from "talk-ui/components"; import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside"; diff --git a/src/core/client/framework/lib/bootstrap/createContext.tsx b/src/core/client/framework/lib/bootstrap/createContext.tsx index 325c2e54e..64241dd5a 100644 --- a/src/core/client/framework/lib/bootstrap/createContext.tsx +++ b/src/core/client/framework/lib/bootstrap/createContext.tsx @@ -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"; @@ -116,8 +117,12 @@ export default async function createContext({ registerClickFarAway, rest: new RestClient("/api", tokenGetter), postMessage: new PostMessageService(), - localStorage: createLocalStorage(), - sessionStorage: createSessionStorage(), + localStorage: pym + ? createPymStorage(pym, "localStorage") + : createLocalStorage(), + sessionStorage: pym + ? createPymStorage(pym, "sessionStorage") + : createSessionStorage(), }; // Run custom initializations. diff --git a/src/core/client/framework/lib/storage/InMemoryStorage.ts b/src/core/client/framework/lib/storage/InMemoryStorage.ts index 3c5b15d7c..ec4838b20 100644 --- a/src/core/client/framework/lib/storage/InMemoryStorage.ts +++ b/src/core/client/framework/lib/storage/InMemoryStorage.ts @@ -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() { diff --git a/src/core/client/framework/lib/storage/PymStorage.spec.ts b/src/core/client/framework/lib/storage/PymStorage.spec.ts new file mode 100644 index 000000000..7a9c9a805 --- /dev/null +++ b/src/core/client/framework/lib/storage/PymStorage.spec.ts @@ -0,0 +1,100 @@ +import createPymStorage from "./PymStorage"; + +class PymStub { + public listeners: Record 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")); + }); + }); +}); diff --git a/src/core/client/framework/lib/storage/PymStorage.ts b/src/core/client/framework/lib/storage/PymStorage.ts new file mode 100644 index 000000000..5075e6505 --- /dev/null +++ b/src/core/client/framework/lib/storage/PymStorage.ts @@ -0,0 +1,57 @@ +import { Child, Parent } from "pym.js"; +import uuid from "uuid/v4"; +import { Storage } from "./interface"; + +type Pym = Child | Parent; + +/** + * 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" +): Storage { + // A Map of requestID => {resolve, reject} + const requests: Record< + string, + { resolve: ((v: any) => void); reject: ((v: any) => void) } + > = {}; + + // Requests method with parameters over pym. + const call = ( + method: string, + parameters: { key: string; value?: string } + ): Promise => { + const id = uuid(); + return new Promise((resolve, reject) => { + requests[id] = { resolve, reject }; + pym.sendMessage( + `pymStorage.${type}.request`, + JSON.stringify({ id, method, parameters }) + ); + }); + }; + + // Receive successful responses. + pym.onMessage(`pymStorage.${type}.response`, (msg: string) => { + const { id, result } = JSON.parse(msg); + requests[id].resolve(result); + delete requests[id]; + }); + + // Receive error responses. + pym.onMessage(`pymStorage.${type}.error`, (msg: string) => { + const { id, error } = JSON.parse(msg); + requests[id].reject(new Error(error)); + delete requests[id]; + }); + + return { + setItem: (key: string, value: string) => call("setItem", { key, value }), + getItem: (key: string) => call("getItem", { key }), + removeItem: (key: string) => call("removeItem", { key }), + }; +} diff --git a/src/core/client/framework/lib/storage/__snapshots__/PymStorage.spec.ts.snap b/src/core/client/framework/lib/storage/__snapshots__/PymStorage.spec.ts.snap new file mode 100644 index 000000000..715a8260a --- /dev/null +++ b/src/core/client/framework/lib/storage/__snapshots__/PymStorage.spec.ts.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`connectStorageToPym should handle handle errors 1`] = `"[{\\"key\\":\\"pymStorage.localStorage.error\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"0\\\\\\",\\\\\\"error\\\\\\":\\\\\\"error\\\\\\"}\\"}]"`; + +exports[`connectStorageToPym should handle unknown method 1`] = `"[{\\"key\\":\\"pymStorage.localStorage.error\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"0\\\\\\",\\\\\\"error\\\\\\":\\\\\\"Unknown method unknown\\\\\\"}\\"}]"`; + +exports[`connectStorageToPym should set, get and remove item 1`] = `"{\\"talkPymStorage:key\\":\\"test\\"}"`; + +exports[`connectStorageToPym should set, get and remove item 2`] = `"{}"`; + +exports[`connectStorageToPym 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\\\\\\"}\\"}]"`; diff --git a/src/core/client/framework/lib/storage/index.ts b/src/core/client/framework/lib/storage/index.ts index a558f693b..82f213a46 100644 --- a/src/core/client/framework/lib/storage/index.ts +++ b/src/core/client/framework/lib/storage/index.ts @@ -1,3 +1,5 @@ export { default as createInMemoryStorage } from "./InMemoryStorage"; export { default as createLocalStorage } from "./LocalStorage"; export { default as createSessionStorage } from "./SessionStorage"; +export { default as createPymStorage } from "./PymStorage"; +export { Storage } from "./interface"; diff --git a/src/core/client/framework/lib/storage/interface.ts b/src/core/client/framework/lib/storage/interface.ts new file mode 100644 index 000000000..5a3a1b6c8 --- /dev/null +++ b/src/core/client/framework/lib/storage/interface.ts @@ -0,0 +1,14 @@ +export interface Storage { + /** + * 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; +} diff --git a/src/core/client/stream/local/initLocalState.ts b/src/core/client/stream/local/initLocalState.ts index 81ba25702..81b32a742 100644 --- a/src/core/client/stream/local/initLocalState.ts +++ b/src/core/client/stream/local/initLocalState.ts @@ -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"); From aa2b70fd8c89a2c9dce2aab92a196b16fabbc8ef Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Mon, 3 Sep 2018 12:16:01 +0200 Subject: [PATCH 02/26] Save comment draft + test --- .../framework/lib/bootstrap/TalkContext.tsx | 8 +- .../framework/lib/bootstrap/createContext.tsx | 10 +- .../framework/lib/storage/PymStorage.ts | 18 +++- .../__snapshots__/PymStorage.spec.ts.snap | 11 --- .../client/framework/lib/storage/index.ts | 3 +- .../client/framework/lib/storage/interface.ts | 14 --- .../testHelpers/createFakePymStorage.ts | 21 ++++ .../client/framework/testHelpers/index.ts | 1 + .../stream/components/PostCommentForm.tsx | 8 +- .../__snapshots__/Stream.spec.tsx.snap | 2 +- .../PostCommentFormContainer.spec.tsx | 99 +++++++++++++++++++ .../containers/PostCommentFormContainer.tsx | 67 +++++++++++-- .../PostCommentFormContainer.spec.tsx.snap | 20 ++++ .../stream/local/initLocalState.spec.ts | 20 ++-- .../__snapshots__/postComment.spec.tsx.snap | 6 +- src/core/client/stream/test/create.tsx | 59 +++++++++++ src/core/client/stream/test/loadMore.spec.tsx | 35 +------ .../client/stream/test/permalinkView.spec.tsx | 36 +------ .../test/permalinkViewAssetNotFound.spec.tsx | 36 +------ .../permalinkViewCommentNotFound.spec.tsx | 36 +------ .../client/stream/test/postComment.spec.tsx | 40 ++------ .../client/stream/test/renderReplies.spec.tsx | 36 +------ .../client/stream/test/renderStream.spec.tsx | 36 +------ .../stream/test/showAllReplies.spec.tsx | 36 +------ 24 files changed, 354 insertions(+), 304 deletions(-) delete mode 100644 src/core/client/framework/lib/storage/__snapshots__/PymStorage.spec.ts.snap delete mode 100644 src/core/client/framework/lib/storage/interface.ts create mode 100644 src/core/client/framework/testHelpers/createFakePymStorage.ts create mode 100644 src/core/client/stream/containers/PostCommentFormContainer.spec.tsx create mode 100644 src/core/client/stream/containers/__snapshots__/PostCommentFormContainer.spec.tsx.snap create mode 100644 src/core/client/stream/test/create.tsx diff --git a/src/core/client/framework/lib/bootstrap/TalkContext.tsx b/src/core/client/framework/lib/bootstrap/TalkContext.tsx index 5b5880883..81306aadc 100644 --- a/src/core/client/framework/lib/bootstrap/TalkContext.tsx +++ b/src/core/client/framework/lib/bootstrap/TalkContext.tsx @@ -8,7 +8,7 @@ import { Environment } from "relay-runtime"; import { PostMessageService } from "talk-framework/lib/postMessage"; import { RestClient } from "talk-framework/lib/rest"; -import { Storage } from "talk-framework/lib/storage"; +import { PymStorage } from "talk-framework/lib/storage"; import { UIContext } from "talk-ui/components"; import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside"; @@ -28,6 +28,12 @@ export interface TalkContext { /** Session storage */ sessionStorage: Storage; + /** Session Storage over pym */ + pymLocalStorage?: PymStorage; + + /** Session storage over pym */ + pymSessionStorage?: PymStorage; + /** media query values for testing purposes */ mediaQueryValues?: MediaQueryMatchers; diff --git a/src/core/client/framework/lib/bootstrap/createContext.tsx b/src/core/client/framework/lib/bootstrap/createContext.tsx index 64241dd5a..3fce90cd3 100644 --- a/src/core/client/framework/lib/bootstrap/createContext.tsx +++ b/src/core/client/framework/lib/bootstrap/createContext.tsx @@ -117,12 +117,10 @@ export default async function createContext({ registerClickFarAway, rest: new RestClient("/api", tokenGetter), postMessage: new PostMessageService(), - localStorage: pym - ? createPymStorage(pym, "localStorage") - : createLocalStorage(), - sessionStorage: pym - ? createPymStorage(pym, "sessionStorage") - : createSessionStorage(), + localStorage: createLocalStorage(), + sessionStorage: createSessionStorage(), + pymLocalStorage: pym && createPymStorage(pym, "localStorage"), + pymSessionStorage: pym && createPymStorage(pym, "sessionStorage"), }; // Run custom initializations. diff --git a/src/core/client/framework/lib/storage/PymStorage.ts b/src/core/client/framework/lib/storage/PymStorage.ts index 5075e6505..ba53ec827 100644 --- a/src/core/client/framework/lib/storage/PymStorage.ts +++ b/src/core/client/framework/lib/storage/PymStorage.ts @@ -1,6 +1,20 @@ import { Child, Parent } from "pym.js"; import uuid from "uuid/v4"; -import { Storage } from "./interface"; + +export interface PymStorage { + /** + * value = storage[key] + */ + getItem(key: string): Promise; + /** + * delete storage[key] + */ + removeItem(key: string): Promise; + /** + * storage[key] = value + */ + setItem(key: string, value: string): Promise; +} type Pym = Child | Parent; @@ -13,7 +27,7 @@ type Pym = Child | Parent; export default function createPymStorage( pym: Pym, type: "localStorage" | "sessionStorage" -): Storage { +): PymStorage { // A Map of requestID => {resolve, reject} const requests: Record< string, diff --git a/src/core/client/framework/lib/storage/__snapshots__/PymStorage.spec.ts.snap b/src/core/client/framework/lib/storage/__snapshots__/PymStorage.spec.ts.snap deleted file mode 100644 index 715a8260a..000000000 --- a/src/core/client/framework/lib/storage/__snapshots__/PymStorage.spec.ts.snap +++ /dev/null @@ -1,11 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`connectStorageToPym should handle handle errors 1`] = `"[{\\"key\\":\\"pymStorage.localStorage.error\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"0\\\\\\",\\\\\\"error\\\\\\":\\\\\\"error\\\\\\"}\\"}]"`; - -exports[`connectStorageToPym should handle unknown method 1`] = `"[{\\"key\\":\\"pymStorage.localStorage.error\\",\\"value\\":\\"{\\\\\\"id\\\\\\":\\\\\\"0\\\\\\",\\\\\\"error\\\\\\":\\\\\\"Unknown method unknown\\\\\\"}\\"}]"`; - -exports[`connectStorageToPym should set, get and remove item 1`] = `"{\\"talkPymStorage:key\\":\\"test\\"}"`; - -exports[`connectStorageToPym should set, get and remove item 2`] = `"{}"`; - -exports[`connectStorageToPym 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\\\\\\"}\\"}]"`; diff --git a/src/core/client/framework/lib/storage/index.ts b/src/core/client/framework/lib/storage/index.ts index 82f213a46..e17d10668 100644 --- a/src/core/client/framework/lib/storage/index.ts +++ b/src/core/client/framework/lib/storage/index.ts @@ -1,5 +1,4 @@ export { default as createInMemoryStorage } from "./InMemoryStorage"; export { default as createLocalStorage } from "./LocalStorage"; export { default as createSessionStorage } from "./SessionStorage"; -export { default as createPymStorage } from "./PymStorage"; -export { Storage } from "./interface"; +export { default as createPymStorage, PymStorage } from "./PymStorage"; diff --git a/src/core/client/framework/lib/storage/interface.ts b/src/core/client/framework/lib/storage/interface.ts deleted file mode 100644 index 5a3a1b6c8..000000000 --- a/src/core/client/framework/lib/storage/interface.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface Storage { - /** - * 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; -} diff --git a/src/core/client/framework/testHelpers/createFakePymStorage.ts b/src/core/client/framework/testHelpers/createFakePymStorage.ts new file mode 100644 index 000000000..ad8510dcd --- /dev/null +++ b/src/core/client/framework/testHelpers/createFakePymStorage.ts @@ -0,0 +1,21 @@ +import { PymStorage } from "talk-framework/lib/storage"; + +export class FakeStorage implements PymStorage { + public store: Record = {}; + + 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(); +} diff --git a/src/core/client/framework/testHelpers/index.ts b/src/core/client/framework/testHelpers/index.ts index 345e7be68..69e9c5cd0 100644 --- a/src/core/client/framework/testHelpers/index.ts +++ b/src/core/client/framework/testHelpers/index.ts @@ -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, diff --git a/src/core/client/stream/components/PostCommentForm.tsx b/src/core/client/stream/components/PostCommentForm.tsx index 00f544b86..09d4a1ba6 100644 --- a/src/core/client/stream/components/PostCommentForm.tsx +++ b/src/core/client/stream/components/PostCommentForm.tsx @@ -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; + onChange?: (state: FormState) => void; + initialValues?: FormProps; } const PostCommentForm: StatelessComponent = props => ( -
+ {({ handleSubmit, submitting }) => ( = props => ( className={styles.root} id="comments-postCommentForm-form" > + {({ input, meta }) => ( diff --git a/src/core/client/stream/components/__snapshots__/Stream.spec.tsx.snap b/src/core/client/stream/components/__snapshots__/Stream.spec.tsx.snap index 46c215a5b..b022bd1e3 100644 --- a/src/core/client/stream/components/__snapshots__/Stream.spec.tsx.snap +++ b/src/core/client/stream/components/__snapshots__/Stream.spec.tsx.snap @@ -213,7 +213,7 @@ exports[`when use is logged in renders correctly 1`] = ` - diff --git a/src/core/client/stream/containers/PostCommentFormContainer.spec.tsx b/src/core/client/stream/containers/PostCommentFormContainer.spec.tsx new file mode 100644 index 000000000..53b104b3a --- /dev/null +++ b/src/core/client/stream/containers/PostCommentFormContainer.spec.tsx @@ -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 = { + // tslint:disable-next-line:no-empty + createComment: (() => {}) as any, + assetID: "asset-id", + pymSessionStorage: createFakePymStorage(), + }; + + const wrapper = shallow(); + await timeout(); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); +}); + +it("renders with initialValues", async () => { + const props: PropTypesOf = { + // tslint:disable-next-line:no-empty + createComment: (() => {}) as any, + assetID: "asset-id", + pymSessionStorage: createFakePymStorage(), + }; + + await props.pymSessionStorage.setItem(contextKey, "Hello World!"); + + const wrapper = shallow(); + await timeout(); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); +}); + +it("save values", async () => { + const props: PropTypesOf = { + // tslint:disable-next-line:no-empty + createComment: (() => {}) as any, + assetID: "asset-id", + pymSessionStorage: createFakePymStorage(), + }; + + await props.pymSessionStorage.setItem(contextKey, "Hello World!"); + + const wrapper = shallow(); + 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 = { + // tslint:disable-next-line:no-empty + createComment: createCommentStub, + assetID, + pymSessionStorage: createFakePymStorage(), + }; + + await props.pymSessionStorage.setItem(contextKey, "Hello World!"); + + const wrapper = shallow(); + await timeout(); + wrapper.update(); + wrapper + .first() + .props() + .onSubmit(input, form); + expect( + createCommentStub.calledWith({ + assetID, + ...input, + }) + ).toBeTruthy(); + await timeout(); + formMock.verify(); +}); diff --git a/src/core/client/stream/containers/PostCommentFormContainer.tsx b/src/core/client/stream/containers/PostCommentFormContainer.tsx index 6398025eb..c862ce8ad 100644 --- a/src/core/client/stream/containers/PostCommentFormContainer.tsx +++ b/src/core/client/stream/containers/PostCommentFormContainer.tsx @@ -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 { - private onSubmit: PostCommentFormProps["onSubmit"] = async (input, form) => { +interface State { + initialValues?: PostCommentFormProps["initialValues"]; + initialized: boolean; +} + +const contextKey = "postCommentFormBody"; + +export class PostCommentFormContainer extends Component { + 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 { } 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 ; + if (!this.state.initialized) { + return null; + } + return ( + + ); } } -const enhanced = withCreateCommentMutation(PostCommentFormContainer); +const enhanced = withContext(({ pymSessionStorage }) => ({ + pymSessionStorage, +}))(withCreateCommentMutation(PostCommentFormContainer)); export type PostCommentFormContainerProps = PropTypesOf; export default enhanced; diff --git a/src/core/client/stream/containers/__snapshots__/PostCommentFormContainer.spec.tsx.snap b/src/core/client/stream/containers/__snapshots__/PostCommentFormContainer.spec.tsx.snap new file mode 100644 index 000000000..de1ba17f5 --- /dev/null +++ b/src/core/client/stream/containers/__snapshots__/PostCommentFormContainer.spec.tsx.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` + +`; + +exports[`renders with initialValues 1`] = ` + +`; diff --git a/src/core/client/stream/local/initLocalState.spec.ts b/src/core/client/stream/local/initLocalState.spec.ts index 2d2a3b486..49f090742 100644 --- a/src/core/client/stream/local/initLocalState.spec.ts +++ b/src/core/client/stream/local/initLocalState.spec.ts @@ -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"); }); diff --git a/src/core/client/stream/test/__snapshots__/postComment.spec.tsx.snap b/src/core/client/stream/test/__snapshots__/postComment.spec.tsx.snap index cb4c00a5c..491f705d3 100644 --- a/src/core/client/stream/test/__snapshots__/postComment.spec.tsx.snap +++ b/src/core/client/stream/test/__snapshots__/postComment.spec.tsx.snap @@ -194,10 +194,10 @@ exports[`post a comment 1`] = `
; + 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( + + + , + { createNodeMock } + ); + + return { context, testRenderer }; +} diff --git a/src/core/client/stream/test/loadMore.spec.tsx b/src/core/client/stream/test/loadMore.spec.tsx index f58a80004..ea72b5510 100644 --- a/src/core/client/stream/test/loadMore.spec.tsx +++ b/src/core/client/stream/test/loadMore.spec.tsx @@ -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( - - - , - { createNodeMock } - ); + })); }); it("renders comment stream", async () => { diff --git a/src/core/client/stream/test/permalinkView.spec.tsx b/src/core/client/stream/test/permalinkView.spec.tsx index c68d3fde3..438ca2cea 100644 --- a/src/core/client/stream/test/permalinkView.spec.tsx +++ b/src/core/client/stream/test/permalinkView.spec.tsx @@ -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( - - - , - { createNodeMock } - ); + })); }); it("renders permalink view", async () => { diff --git a/src/core/client/stream/test/permalinkViewAssetNotFound.spec.tsx b/src/core/client/stream/test/permalinkViewAssetNotFound.spec.tsx index 17b23b670..9d2df883e 100644 --- a/src/core/client/stream/test/permalinkViewAssetNotFound.spec.tsx +++ b/src/core/client/stream/test/permalinkViewAssetNotFound.spec.tsx @@ -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( - - - , - { createNodeMock } - ); + })); }); it("renders permalink view with unknown asset", async () => { diff --git a/src/core/client/stream/test/permalinkViewCommentNotFound.spec.tsx b/src/core/client/stream/test/permalinkViewCommentNotFound.spec.tsx index f49dc0534..6a7279669 100644 --- a/src/core/client/stream/test/permalinkViewCommentNotFound.spec.tsx +++ b/src/core/client/stream/test/permalinkViewCommentNotFound.spec.tsx @@ -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( - - - , - { createNodeMock } - ); + })); }); it("renders permalink view with unknown comment", async () => { diff --git a/src/core/client/stream/test/postComment.spec.tsx b/src/core/client/stream/test/postComment.spec.tsx index 9ddd8b1ec..40c4c4b4d 100644 --- a/src/core/client/stream/test/postComment.spec.tsx +++ b/src/core/client/stream/test/postComment.spec.tsx @@ -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; @@ -57,31 +48,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( - - - , - { createNodeMock } - ); + })); }); it("renders comment stream", async () => { @@ -91,11 +65,13 @@ 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: "Hello world!" }); - timekeeper.travel(new Date("2018-07-06T18:24:00.000Z")); + timekeeper.freeze(new Date("2018-07-06T18:24:00.000Z")); testRenderer.root .findByProps({ id: "comments-postCommentForm-form" }) .props.onSubmit(); diff --git a/src/core/client/stream/test/renderReplies.spec.tsx b/src/core/client/stream/test/renderReplies.spec.tsx index 970d1a31b..b508d78d8 100644 --- a/src/core/client/stream/test/renderReplies.spec.tsx +++ b/src/core/client/stream/test/renderReplies.spec.tsx @@ -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( - - - , - { createNodeMock } - ); + })); }); it("renders comment stream", async () => { diff --git a/src/core/client/stream/test/renderStream.spec.tsx b/src/core/client/stream/test/renderStream.spec.tsx index 3ea0d74c6..131bb678c 100644 --- a/src/core/client/stream/test/renderStream.spec.tsx +++ b/src/core/client/stream/test/renderStream.spec.tsx @@ -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( - - - , - { createNodeMock } - ); + })); }); it("renders comment stream", async () => { diff --git a/src/core/client/stream/test/showAllReplies.spec.tsx b/src/core/client/stream/test/showAllReplies.spec.tsx index a76aab40d..52d302e55 100644 --- a/src/core/client/stream/test/showAllReplies.spec.tsx +++ b/src/core/client/stream/test/showAllReplies.spec.tsx @@ -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( - - - , - { createNodeMock } - ); + })); }); it("renders comment stream", async () => { From 31bc5fa9135b1eae40f7b4324312ffed690e0f50 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Mon, 3 Sep 2018 22:33:34 +0200 Subject: [PATCH 03/26] WIP --- package-lock.json | 6 +- package.json | 2 +- .../framework/lib/bootstrap/withContext.tsx | 22 ++-- .../stream/components/Comment/Comment.css | 8 +- .../stream/components/Comment/Comment.tsx | 17 +-- .../stream/components/Comment/ReplyButton.tsx | 27 +++++ .../stream/components/Comment/TopBar.css | 3 - .../stream/components/Comment/TopBar.tsx | 4 +- .../stream/components/PermalinkView.tsx | 6 +- .../stream/components/PostCommentForm.css | 11 -- .../stream/components/ReplyCommentForm.tsx | 99 +++++++++++++++ .../client/stream/components/ReplyList.tsx | 19 ++- src/core/client/stream/components/Stream.tsx | 15 ++- .../stream/containers/CommentContainer.tsx | 86 +++++++++---- .../containers/PermalinkViewContainer.tsx | 12 +- .../containers/ReplyCommentFormContainer.tsx | 114 ++++++++++++++++++ .../stream/containers/ReplyListContainer.tsx | 16 ++- .../stream/containers/StreamContainer.tsx | 7 +- .../stream/queries/PermalinkViewQuery.tsx | 22 +++- .../client/stream/queries/StreamQuery.tsx | 6 +- src/docs/workarounds.mdx | 2 +- 21 files changed, 415 insertions(+), 89 deletions(-) create mode 100644 src/core/client/stream/components/Comment/ReplyButton.tsx delete mode 100644 src/core/client/stream/components/Comment/TopBar.css create mode 100644 src/core/client/stream/components/ReplyCommentForm.tsx create mode 100644 src/core/client/stream/containers/ReplyCommentFormContainer.tsx diff --git a/package-lock.json b/package-lock.json index 6b20227b4..3f6204f4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2162,9 +2162,9 @@ } }, "@types/recompose": { - "version": "0.26.1", - "resolved": "https://registry.npmjs.org/@types/recompose/-/recompose-0.26.1.tgz", - "integrity": "sha512-S5fkitL277yWCEHDzgb/3aJ4RySeqSC7L3Xo+7AlDk6+DAPpQAfF0iwgfjAqbP49JAjVCY+asPQxFPiw1+4CYg==", + "version": "0.26.4", + "resolved": "https://registry.npmjs.org/@types/recompose/-/recompose-0.26.4.tgz", + "integrity": "sha512-QOLPlsBxn/yOxSv4Au66kd8KvYZRCgZA3vV5pNZ6YTEY4GeDHNoYL+sCnbzGIcmWDYoN7PUNZSopaGhvHQperw==", "dev": true, "requires": { "@types/react": "*" diff --git a/package.json b/package.json index 5acf90d14..c7eea6abb 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,7 @@ "@types/react-relay": "^1.3.9", "@types/react-responsive": "^3.0.1", "@types/react-test-renderer": "^16.0.1", - "@types/recompose": "^0.26.1", + "@types/recompose": "^0.26.4", "@types/relay-runtime": "^1.3.6", "@types/sane": "^2.0.0", "@types/sinon": "^5.0.1", diff --git a/src/core/client/framework/lib/bootstrap/withContext.tsx b/src/core/client/framework/lib/bootstrap/withContext.tsx index 677da236a..89a0da1d8 100644 --- a/src/core/client/framework/lib/bootstrap/withContext.tsx +++ b/src/core/client/framework/lib/bootstrap/withContext.tsx @@ -1,12 +1,18 @@ -import * as React from "react"; -import { - hoistStatics, - InferableComponentEnhancer, - wrapDisplayName, -} from "recompose"; +import React from "react"; +import { hoistStatics, wrapDisplayName } from "recompose"; +import { Omit } from "talk-ui/types"; import { TalkContext, TalkContextConsumer } from "./TalkContext"; +// Injects props and removes them from the prop requirements. +// Will not pass through the injected props if they are passed in during +// render. Also adds new prop requirements from TNeedsProps. +type InferableComponentEnhancerWithProps = < + P extends TInjectedProps +>( + component: React.ComponentType

+) => React.ComponentType>; + /** * withContext is a HOC wrapper around `TalkContextConsumer`. * `propsCallback` must be provided which accepts the `TalkContext` @@ -14,7 +20,7 @@ import { TalkContext, TalkContextConsumer } from "./TalkContext"; */ function withContext( propsCallback: (context: TalkContext) => T -): InferableComponentEnhancer { +): InferableComponentEnhancerWithProps { return hoistStatics( (WrappedComponent: React.ComponentType) => { const Component: React.StatelessComponent = props => ( @@ -27,7 +33,7 @@ function withContext( Component.displayName = wrapDisplayName(WrappedComponent, "withContext"); return Component; } - ); + ) as any; } export default withContext; diff --git a/src/core/client/stream/components/Comment/Comment.css b/src/core/client/stream/components/Comment/Comment.css index 821be5613..b996f5d55 100644 --- a/src/core/client/stream/components/Comment/Comment.css +++ b/src/core/client/stream/components/Comment/Comment.css @@ -1,5 +1,11 @@ .root { } -.footer { +.topBar { + margin-bottom: calc(0.5 * var(--spacing-unit)); +} +.footer:not(:empty) { + margin-top: var(--spacing-unit); +} +.reply { margin-top: var(--spacing-unit); } diff --git a/src/core/client/stream/components/Comment/Comment.tsx b/src/core/client/stream/components/Comment/Comment.tsx index 4cee7fe37..bd4fc17f7 100644 --- a/src/core/client/stream/components/Comment/Comment.tsx +++ b/src/core/client/stream/components/Comment/Comment.tsx @@ -1,8 +1,8 @@ -import React from "react"; -import { StatelessComponent } from "react"; -import * as styles from "./Comment.css"; +import React, { ReactElement, StatelessComponent } from "react"; -import PermalinkButtonContainer from "../../containers/PermalinkButtonContainer"; +import { Flex } from "talk-ui/components"; + +import * as styles from "./Comment.css"; import HTMLContent from "./HTMLContent"; import Timestamp from "./Timestamp"; import TopBar from "./TopBar"; @@ -16,20 +16,21 @@ export interface CommentProps { } | null; body: string | null; createdAt: string; + footer?: ReactElement | Array>; } const Comment: StatelessComponent = props => { return (

- + {props.author && props.author.username && {props.author.username}} {props.createdAt} {props.body || ""} -
- -
+ + {props.footer} +
); }; diff --git a/src/core/client/stream/components/Comment/ReplyButton.tsx b/src/core/client/stream/components/Comment/ReplyButton.tsx new file mode 100644 index 000000000..43b1028ca --- /dev/null +++ b/src/core/client/stream/components/Comment/ReplyButton.tsx @@ -0,0 +1,27 @@ +import { Localized } from "fluent-react/compat"; +import React, { EventHandler, MouseEvent, StatelessComponent } from "react"; + +import { Button, ButtonIcon, MatchMedia } from "talk-ui/components"; + +interface Props { + onClick?: EventHandler>; + active?: boolean; +} + +const ReplyButton: StatelessComponent = props => ( + +); + +export default ReplyButton; diff --git a/src/core/client/stream/components/Comment/TopBar.css b/src/core/client/stream/components/Comment/TopBar.css deleted file mode 100644 index dff9c8a74..000000000 --- a/src/core/client/stream/components/Comment/TopBar.css +++ /dev/null @@ -1,3 +0,0 @@ -.root { - margin-bottom: calc(0.5 * var(--spacing-unit)); -} diff --git a/src/core/client/stream/components/Comment/TopBar.tsx b/src/core/client/stream/components/Comment/TopBar.tsx index a82dd0a28..eed8aea32 100644 --- a/src/core/client/stream/components/Comment/TopBar.tsx +++ b/src/core/client/stream/components/Comment/TopBar.tsx @@ -4,15 +4,13 @@ import { StatelessComponent } from "react"; import { Flex, MatchMedia } from "talk-ui/components"; -import * as styles from "./TopBar.css"; - export interface TopBarProps { className?: string; children: React.ReactNode; } const TopBar: StatelessComponent = props => { - const rootClassName = cn(styles.root, props.className); + const rootClassName = cn(props.className); return ( {matches => ( diff --git a/src/core/client/stream/components/PermalinkView.tsx b/src/core/client/stream/components/PermalinkView.tsx index 1747152d7..38d2d29a0 100644 --- a/src/core/client/stream/components/PermalinkView.tsx +++ b/src/core/client/stream/components/PermalinkView.tsx @@ -8,7 +8,8 @@ import CommentContainer from "../containers/CommentContainer"; import * as styles from "./PermalinkView.css"; export interface PermalinkViewProps { - comment: PropTypesOf["data"] | null; + asset: PropTypesOf["asset"]; + comment: PropTypesOf["comment"] | null; showAllCommentsHref: string | null; onShowAllComments: (e: MouseEvent) => void; } @@ -16,6 +17,7 @@ export interface PermalinkViewProps { const PermalinkView: StatelessComponent = ({ showAllCommentsHref, comment, + asset, onShowAllComments, }) => { return ( @@ -42,7 +44,7 @@ const PermalinkView: StatelessComponent = ({ Comment not found )} - {comment && } + {comment && }
); }; diff --git a/src/core/client/stream/components/PostCommentForm.css b/src/core/client/stream/components/PostCommentForm.css index 8c35347ff..59669f339 100644 --- a/src/core/client/stream/components/PostCommentForm.css +++ b/src/core/client/stream/components/PostCommentForm.css @@ -1,17 +1,6 @@ .root { } -.textarea { - composes: bodyCopy from "talk-ui/shared/typography.css"; - display: block; - height: 150px; - width: 100%; - box-sizing: border-box; - padding: var(--spacing-unit); - border-radius: var(--round-corners); - resize: vertical; -} - .poweredBy { margin-top: calc(-0.5 * var(--spacing-unit)); } diff --git a/src/core/client/stream/components/ReplyCommentForm.tsx b/src/core/client/stream/components/ReplyCommentForm.tsx new file mode 100644 index 000000000..608d9a74d --- /dev/null +++ b/src/core/client/stream/components/ReplyCommentForm.tsx @@ -0,0 +1,99 @@ +import { FormState } from "final-form"; +import { Localized } from "fluent-react/compat"; +import React, { EventHandler, MouseEvent, StatelessComponent } from "react"; +import { Field, Form, FormSpy } from "react-final-form"; + +import { OnSubmit } from "talk-framework/lib/form"; +import { required } from "talk-framework/lib/validation"; +import { + AriaInfo, + Button, + Flex, + HorizontalGutter, + Typography, +} from "talk-ui/components"; + +import RTE from "./RTE"; + +interface FormProps { + body: string; +} + +export interface ReplyCommentFormProps { + className?: string; + onSubmit: OnSubmit; + onCancel?: EventHandler>; + onChange?: (state: FormState) => void; + initialValues?: FormProps; +} + +const ReplyCommentForm: StatelessComponent = props => ( + + {({ handleSubmit, submitting }) => ( + + + + + {({ input, meta }) => ( +
+ + + Write a reply + + + + input.onChange(html)} + value={input.value} + placeholder="Write a reply" + /> + + {meta.touched && + (meta.error || meta.submitError) && ( + + {meta.error || meta.submitError} + + )} +
+ )} +
+ + + + + + + + +
+ + )} + +); + +export default ReplyCommentForm; diff --git a/src/core/client/stream/components/ReplyList.tsx b/src/core/client/stream/components/ReplyList.tsx index cd2be4660..9e6897514 100644 --- a/src/core/client/stream/components/ReplyList.tsx +++ b/src/core/client/stream/components/ReplyList.tsx @@ -9,9 +9,12 @@ import CommentContainer from "../containers/CommentContainer"; import Indent from "./Indent"; export interface ReplyListProps { - commentID: string; + asset: PropTypesOf["asset"]; + comment: { + id: string; + }; comments: ReadonlyArray< - { id: string } & PropTypesOf["data"] + { id: string } & PropTypesOf["comment"] >; onShowAll: () => void; hasMore: boolean; @@ -22,17 +25,21 @@ const ReplyList: StatelessComponent = props => { return ( {props.comments.map(comment => ( - + ))} {props.hasMore && ( +
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
@@ -320,7 +368,7 @@ exports[`renders comment stream 1`] = ` > @@ -334,7 +382,7 @@ exports[`renders comment stream 1`] = ` > @@ -348,7 +396,7 @@ exports[`renders comment stream 1`] = ` > @@ -411,7 +459,7 @@ exports[`renders comment stream 1`] = ` role="article" >
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
+ @@ -121,7 +137,7 @@ exports[`show all comments 1`] = ` > @@ -135,7 +151,7 @@ exports[`show all comments 1`] = ` > @@ -149,7 +165,7 @@ exports[`show all comments 1`] = ` > @@ -212,7 +228,7 @@ exports[`show all comments 1`] = ` role="article" >
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
diff --git a/src/core/client/stream/test/__snapshots__/permalinkViewAssetNotFound.spec.tsx.snap b/src/core/client/stream/test/__snapshots__/permalinkViewAssetNotFound.spec.tsx.snap index cfaa83d0e..b7f4fc521 100644 --- a/src/core/client/stream/test/__snapshots__/permalinkViewAssetNotFound.spec.tsx.snap +++ b/src/core/client/stream/test/__snapshots__/permalinkViewAssetNotFound.spec.tsx.snap @@ -4,30 +4,8 @@ exports[`renders permalink view with unknown asset 1`] = `
-
- - Show all comments - -

- Comment not found -

+
+ Asset not found
`; diff --git a/src/core/client/stream/test/__snapshots__/permalinkViewCommentNotFound.spec.tsx.snap b/src/core/client/stream/test/__snapshots__/permalinkViewCommentNotFound.spec.tsx.snap index 62871c5d1..a383774e9 100644 --- a/src/core/client/stream/test/__snapshots__/permalinkViewCommentNotFound.spec.tsx.snap +++ b/src/core/client/stream/test/__snapshots__/permalinkViewCommentNotFound.spec.tsx.snap @@ -94,7 +94,7 @@ exports[`show all comments 1`] = ` > @@ -108,7 +108,7 @@ exports[`show all comments 1`] = ` > @@ -122,7 +122,7 @@ exports[`show all comments 1`] = ` > @@ -185,7 +185,7 @@ exports[`show all comments 1`] = ` role="article" >
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
diff --git a/src/core/client/stream/test/__snapshots__/postComment.spec.tsx.snap b/src/core/client/stream/test/__snapshots__/postComment.spec.tsx.snap index 491f705d3..ee81cb4e7 100644 --- a/src/core/client/stream/test/__snapshots__/postComment.spec.tsx.snap +++ b/src/core/client/stream/test/__snapshots__/postComment.spec.tsx.snap @@ -81,7 +81,7 @@ exports[`post a comment 1`] = ` > @@ -95,7 +95,7 @@ exports[`post a comment 1`] = ` > @@ -109,7 +109,7 @@ exports[`post a comment 1`] = ` > @@ -185,7 +185,7 @@ exports[`post a comment 1`] = ` role="article" >
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
@@ -371,7 +419,7 @@ exports[`post a comment 2`] = ` > @@ -385,7 +433,7 @@ exports[`post a comment 2`] = ` > @@ -399,7 +447,7 @@ exports[`post a comment 2`] = ` > @@ -481,7 +529,7 @@ exports[`post a comment 2`] = ` role="article" >
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
@@ -667,7 +763,7 @@ exports[`renders comment stream 1`] = ` > @@ -681,7 +777,7 @@ exports[`renders comment stream 1`] = ` > @@ -695,7 +791,7 @@ exports[`renders comment stream 1`] = ` > @@ -777,7 +873,7 @@ exports[`renders comment stream 1`] = ` role="article" >
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
diff --git a/src/core/client/stream/test/__snapshots__/renderReplies.spec.tsx.snap b/src/core/client/stream/test/__snapshots__/renderReplies.spec.tsx.snap index 56c8f0963..2ebca65ec 100644 --- a/src/core/client/stream/test/__snapshots__/renderReplies.spec.tsx.snap +++ b/src/core/client/stream/test/__snapshots__/renderReplies.spec.tsx.snap @@ -62,7 +62,7 @@ exports[`renders comment stream 1`] = ` > @@ -76,7 +76,7 @@ exports[`renders comment stream 1`] = ` > @@ -90,7 +90,7 @@ exports[`renders comment stream 1`] = ` > @@ -153,7 +153,7 @@ exports[`renders comment stream 1`] = ` role="article" >
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
diff --git a/src/core/client/stream/test/__snapshots__/renderStream.spec.tsx.snap b/src/core/client/stream/test/__snapshots__/renderStream.spec.tsx.snap index 65c26f837..36ae77216 100644 --- a/src/core/client/stream/test/__snapshots__/renderStream.spec.tsx.snap +++ b/src/core/client/stream/test/__snapshots__/renderStream.spec.tsx.snap @@ -62,7 +62,7 @@ exports[`renders comment stream 1`] = ` > @@ -76,7 +76,7 @@ exports[`renders comment stream 1`] = ` > @@ -90,7 +90,7 @@ exports[`renders comment stream 1`] = ` > @@ -153,7 +153,7 @@ exports[`renders comment stream 1`] = ` role="article" >
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
diff --git a/src/core/client/stream/test/__snapshots__/showAllReplies.spec.tsx.snap b/src/core/client/stream/test/__snapshots__/showAllReplies.spec.tsx.snap index cfb9bbaa1..bb44e5208 100644 --- a/src/core/client/stream/test/__snapshots__/showAllReplies.spec.tsx.snap +++ b/src/core/client/stream/test/__snapshots__/showAllReplies.spec.tsx.snap @@ -62,7 +62,7 @@ exports[`renders comment stream 1`] = ` > @@ -76,7 +76,7 @@ exports[`renders comment stream 1`] = ` > @@ -90,7 +90,7 @@ exports[`renders comment stream 1`] = ` > @@ -153,7 +153,7 @@ exports[`renders comment stream 1`] = ` role="article" >
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
+
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
diff --git a/src/core/client/ui/components/AriaInfo/AriaInfo.tsx b/src/core/client/ui/components/AriaInfo/AriaInfo.tsx index 45dbcd598..fc88b0e03 100644 --- a/src/core/client/ui/components/AriaInfo/AriaInfo.tsx +++ b/src/core/client/ui/components/AriaInfo/AriaInfo.tsx @@ -1,5 +1,5 @@ import cn from "classnames"; -import React, { AllHTMLAttributes, StatelessComponent } from "react"; +import React, { AllHTMLAttributes, Ref, StatelessComponent } from "react"; import { withForwardRef, withStyles } from "talk-ui/hocs"; import { PropTypesOf } from "talk-ui/types"; @@ -14,6 +14,8 @@ interface InnerProps extends AllHTMLAttributes { classes: typeof styles; component?: string; children: React.ReactNode; + /** Internal: Forwarded Ref */ + forwardRef?: Ref; } const AriaInfo: StatelessComponent = props => { diff --git a/src/core/client/ui/components/Button/Button.tsx b/src/core/client/ui/components/Button/Button.tsx index 424b8bbe5..fe2a8aa4a 100644 --- a/src/core/client/ui/components/Button/Button.tsx +++ b/src/core/client/ui/components/Button/Button.tsx @@ -1,6 +1,6 @@ import cn from "classnames"; import { pick } from "lodash"; -import React, { ButtonHTMLAttributes, Ref } from "react"; +import React, { Ref } from "react"; import { withForwardRef, withStyles } from "talk-ui/hocs"; import { PropTypesOf } from "talk-ui/types"; @@ -10,7 +10,7 @@ import * as styles from "./Button.css"; // This should extend from BaseButton instead but we can't because of this bug // TODO: add bug link. -interface InnerProps extends ButtonHTMLAttributes { +interface InnerProps extends BaseButtonProps { /** If set renders an anchor tag instead */ anchor?: boolean; href?: string; diff --git a/src/core/client/ui/components/Button/ButtonIcon.tsx b/src/core/client/ui/components/Button/ButtonIcon.tsx index 963808a7b..90660c1aa 100644 --- a/src/core/client/ui/components/Button/ButtonIcon.tsx +++ b/src/core/client/ui/components/Button/ButtonIcon.tsx @@ -31,7 +31,7 @@ export const ButtonIcon: StatelessComponent = props => { ButtonIcon.defaultProps = { size: "sm", -}; +} as Partial; const enhanced = withForwardRef(withStyles(styles)(ButtonIcon)); export type ButtonIconProps = PropTypesOf; diff --git a/src/core/client/ui/components/CallOut/CallOut.tsx b/src/core/client/ui/components/CallOut/CallOut.tsx index 11eccff2c..5124b3197 100644 --- a/src/core/client/ui/components/CallOut/CallOut.tsx +++ b/src/core/client/ui/components/CallOut/CallOut.tsx @@ -51,7 +51,7 @@ const CallOut: StatelessComponent = props => { CallOut.defaultProps = { color: "regular", fullWidth: false, -}; +} as Partial; const enhanced = withStyles(styles)(CallOut); export default enhanced; diff --git a/src/core/client/ui/components/HorizontalGutter/HorizontalGutter.tsx b/src/core/client/ui/components/HorizontalGutter/HorizontalGutter.tsx index 5594bd415..408dfb568 100644 --- a/src/core/client/ui/components/HorizontalGutter/HorizontalGutter.tsx +++ b/src/core/client/ui/components/HorizontalGutter/HorizontalGutter.tsx @@ -30,7 +30,7 @@ const HorizontalGutter: StatelessComponent = props => { HorizontalGutter.defaultProps = { size: "full", -}; +} as Partial; const enhanced = withForwardRef(withStyles(styles)(HorizontalGutter)); export type HorizontalGutterProps = PropTypesOf; diff --git a/src/core/client/ui/components/Icon/Icon.tsx b/src/core/client/ui/components/Icon/Icon.tsx index 26f33ee22..3baae6c27 100644 --- a/src/core/client/ui/components/Icon/Icon.tsx +++ b/src/core/client/ui/components/Icon/Icon.tsx @@ -37,7 +37,7 @@ const Icon: StatelessComponent = props => { Icon.defaultProps = { size: "sm", -}; +} as Partial; const enhanced = withForwardRef(withStyles(styles)(Icon)); export type IconProps = PropTypesOf; diff --git a/src/core/client/ui/components/TextField/TextField.tsx b/src/core/client/ui/components/TextField/TextField.tsx index 2e4fe448b..767c379a6 100644 --- a/src/core/client/ui/components/TextField/TextField.tsx +++ b/src/core/client/ui/components/TextField/TextField.tsx @@ -89,7 +89,7 @@ TextField.defaultProps = { color: "regular", placeholder: "", type: "text", -}; +} as Partial; const enhanced = withStyles(styles)(TextField); export default enhanced; diff --git a/src/core/client/ui/components/Typography/Typography.tsx b/src/core/client/ui/components/Typography/Typography.tsx index a6912f04c..2c4a20d94 100644 --- a/src/core/client/ui/components/Typography/Typography.tsx +++ b/src/core/client/ui/components/Typography/Typography.tsx @@ -151,7 +151,7 @@ Typography.defaultProps = { noWrap: false, paragraph: false, variant: "bodyCopy", -}; +} as Partial; const enhanced = withForwardRef(withStyles(styles)(Typography)); export type TypographyProps = PropTypesOf; diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index 009c52e99..9d2986d97 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -44,3 +44,7 @@ comments-postCommentForm-rte = comments-postCommentFormFake-rte = .placeholder = { comments-postCommentForm-rteLabel } + +comments-replyButton-reply = Reply + +comments-permalinkViewQuery-assetNotFound = { comments-streamQuery-assetNotFound } From ee30003390bbfc00d240759b0181d8d047c9f50e Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 4 Sep 2018 16:02:27 +0200 Subject: [PATCH 05/26] Indent comment only --- .../components/Comment/IndentedComment.tsx | 21 ++ .../client/stream/components/Comment/index.ts | 2 +- src/core/client/stream/components/Indent.css | 11 +- .../client/stream/components/Indent.spec.tsx | 21 +- src/core/client/stream/components/Indent.tsx | 12 +- .../stream/components/ReplyList.spec.tsx | 2 + .../client/stream/components/ReplyList.tsx | 34 +-- .../__snapshots__/Indent.spec.tsx.snap | 24 ++- .../__snapshots__/ReplyList.spec.tsx.snap | 196 +++++++++--------- .../containers/CommentContainer.spec.tsx | 2 + .../stream/containers/CommentContainer.tsx | 1 + .../stream/containers/ReplyListContainer.tsx | 1 + .../CommentContainer.spec.tsx.snap | 6 +- .../ReplyListContainer.spec.tsx.snap | 4 + .../__snapshots__/renderReplies.spec.tsx.snap | 12 +- .../showAllReplies.spec.tsx.snap | 24 ++- 16 files changed, 241 insertions(+), 132 deletions(-) create mode 100644 src/core/client/stream/components/Comment/IndentedComment.tsx diff --git a/src/core/client/stream/components/Comment/IndentedComment.tsx b/src/core/client/stream/components/Comment/IndentedComment.tsx new file mode 100644 index 000000000..2460f905b --- /dev/null +++ b/src/core/client/stream/components/Comment/IndentedComment.tsx @@ -0,0 +1,21 @@ +import React, { StatelessComponent } from "react"; + +import { PropTypesOf } from "talk-framework/types"; + +import Indent from "../Indent"; +import Comment from "./Comment"; + +export interface IndentedCommentProps extends PropTypesOf { + indentLevel?: number; +} + +const IndentedComment: StatelessComponent = props => { + const { indentLevel, ...rest } = props; + const CommentElement = ; + const CommentwithIndent = + (indentLevel && {CommentElement}) || + CommentElement; + return CommentwithIndent; +}; + +export default IndentedComment; diff --git a/src/core/client/stream/components/Comment/index.ts b/src/core/client/stream/components/Comment/index.ts index 173033df9..abb1d79ae 100644 --- a/src/core/client/stream/components/Comment/index.ts +++ b/src/core/client/stream/components/Comment/index.ts @@ -1 +1 @@ -export { default, default as Comment, CommentProps } from "./Comment"; +export { default, default as IndentedComment } from "./IndentedComment"; diff --git a/src/core/client/stream/components/Indent.css b/src/core/client/stream/components/Indent.css index d779d93e0..6aa4c5e7a 100644 --- a/src/core/client/stream/components/Indent.css +++ b/src/core/client/stream/components/Indent.css @@ -1,8 +1,11 @@ .root { - border-left: 3px solid; - padding-left: var(--spacing-unit); } -.level0 { - border-color: var(--palette-grey-darkest); +.level1 { + padding-left: var(--spacing-unit); + border-left: 3px solid var(--palette-grey-darkest); +} + +.noBorder { + border: 0; } diff --git a/src/core/client/stream/components/Indent.spec.tsx b/src/core/client/stream/components/Indent.spec.tsx index 3108ba209..9654922a7 100644 --- a/src/core/client/stream/components/Indent.spec.tsx +++ b/src/core/client/stream/components/Indent.spec.tsx @@ -5,10 +5,29 @@ import { PropTypesOf } from "talk-framework/types"; import Indent from "./Indent"; -it("renders correctly", () => { +it("renders level0", () => { const props: PropTypesOf = { children:
Hello World
, }; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); + +it("renders level1", () => { + const props: PropTypesOf = { + level: 1, + children:
Hello World
, + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); + +it("renders without border", () => { + const props: PropTypesOf = { + level: 1, + noBorder: true, + children:
Hello World
, + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/src/core/client/stream/components/Indent.tsx b/src/core/client/stream/components/Indent.tsx index 7ea8fddf3..1c33c3f26 100644 --- a/src/core/client/stream/components/Indent.tsx +++ b/src/core/client/stream/components/Indent.tsx @@ -5,11 +5,21 @@ import * as styles from "./Indent.css"; export interface IndentProps { level?: number; + noBorder?: boolean; children: React.ReactNode; } const Indent: StatelessComponent = props => { - return
{props.children}
; + return ( +
+ {props.children} +
+ ); }; export default Indent; diff --git a/src/core/client/stream/components/ReplyList.spec.tsx b/src/core/client/stream/components/ReplyList.spec.tsx index 9d214aaed..6ec16f23f 100644 --- a/src/core/client/stream/components/ReplyList.spec.tsx +++ b/src/core/client/stream/components/ReplyList.spec.tsx @@ -18,6 +18,7 @@ it("renders correctly", () => { onShowAll: noop, hasMore: false, disableShowAll: false, + indentLevel: 1, }; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); @@ -31,6 +32,7 @@ describe("when there is more", () => { onShowAll: sinon.spy(), hasMore: true, disableShowAll: false, + indentLevel: 1, }; const wrapper = shallow(); diff --git a/src/core/client/stream/components/ReplyList.tsx b/src/core/client/stream/components/ReplyList.tsx index 9e6897514..91db3770e 100644 --- a/src/core/client/stream/components/ReplyList.tsx +++ b/src/core/client/stream/components/ReplyList.tsx @@ -19,23 +19,25 @@ export interface ReplyListProps { onShowAll: () => void; hasMore: boolean; disableShowAll: boolean; + indentLevel?: number; } const ReplyList: StatelessComponent = props => { return ( - - - {props.comments.map(comment => ( - - ))} - {props.hasMore && ( + + {props.comments.map(comment => ( + + ))} + {props.hasMore && ( + - )} - - +
+ )} +
); }; diff --git a/src/core/client/stream/components/__snapshots__/Indent.spec.tsx.snap b/src/core/client/stream/components/__snapshots__/Indent.spec.tsx.snap index 910c819b2..d1b2bd78f 100644 --- a/src/core/client/stream/components/__snapshots__/Indent.spec.tsx.snap +++ b/src/core/client/stream/components/__snapshots__/Indent.spec.tsx.snap @@ -1,8 +1,28 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`renders correctly 1`] = ` +exports[`renders level0 1`] = `
+
+ Hello World +
+
+`; + +exports[`renders level1 1`] = ` +
+
+ Hello World +
+
+`; + +exports[`renders without border 1`] = ` +
Hello World diff --git a/src/core/client/stream/components/__snapshots__/ReplyList.spec.tsx.snap b/src/core/client/stream/components/__snapshots__/ReplyList.spec.tsx.snap index 60efc9399..6a009c7a8 100644 --- a/src/core/client/stream/components/__snapshots__/ReplyList.spec.tsx.snap +++ b/src/core/client/stream/components/__snapshots__/ReplyList.spec.tsx.snap @@ -1,73 +1,78 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`renders correctly 1`] = ` - - - + - + - - + } + indentLevel={1} + key="comment-2" + /> + `; exports[`when there is more disables load more button 1`] = ` - - + + + - - @@ -82,42 +87,47 @@ exports[`when there is more disables load more button 1`] = ` Show All Replies - - + + `; exports[`when there is more renders a load more button 1`] = ` - - + + + - - @@ -132,6 +142,6 @@ exports[`when there is more renders a load more button 1`] = ` Show All Replies - - + + `; diff --git a/src/core/client/stream/containers/CommentContainer.spec.tsx b/src/core/client/stream/containers/CommentContainer.spec.tsx index 3a110194d..2541a87d8 100644 --- a/src/core/client/stream/containers/CommentContainer.spec.tsx +++ b/src/core/client/stream/containers/CommentContainer.spec.tsx @@ -22,6 +22,7 @@ it("renders username and body", () => { body: "Woof", createdAt: "1995-12-17T03:24:00.000Z", }, + indentLevel: 1, }; const wrapper = shallow(); @@ -41,6 +42,7 @@ it("renders body only", () => { body: "Woof", createdAt: "1995-12-17T03:24:00.000Z", }, + indentLevel: 1, }; const wrapper = shallow(); diff --git a/src/core/client/stream/containers/CommentContainer.tsx b/src/core/client/stream/containers/CommentContainer.tsx index cc42c6304..8624110fd 100644 --- a/src/core/client/stream/containers/CommentContainer.tsx +++ b/src/core/client/stream/containers/CommentContainer.tsx @@ -14,6 +14,7 @@ import PermalinkButtonContainer from "./PermalinkButtonContainer"; interface InnerProps { comment: CommentData; asset: AssetData; + indentLevel?: number; } interface State { diff --git a/src/core/client/stream/containers/ReplyListContainer.tsx b/src/core/client/stream/containers/ReplyListContainer.tsx index 710984e3a..590cbb437 100644 --- a/src/core/client/stream/containers/ReplyListContainer.tsx +++ b/src/core/client/stream/containers/ReplyListContainer.tsx @@ -39,6 +39,7 @@ export class ReplyListContainer extends React.Component { onShowAll={this.showAll} hasMore={this.props.relay.hasMore()} disableShowAll={this.state.disableShowAll} + indentLevel={1} /> ); } diff --git a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap b/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap index 91b9bbb01..85fc8463c 100644 --- a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap +++ b/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap @@ -2,7 +2,7 @@ exports[`renders body only 1`] = ` - } id="comment-id" + indentLevel={1} /> `; exports[`renders username and body 1`] = ` - } id="comment-id" + indentLevel={1} /> `; diff --git a/src/core/client/stream/containers/__snapshots__/ReplyListContainer.spec.tsx.snap b/src/core/client/stream/containers/__snapshots__/ReplyListContainer.spec.tsx.snap index 9abc32ecc..b2fac6503 100644 --- a/src/core/client/stream/containers/__snapshots__/ReplyListContainer.spec.tsx.snap +++ b/src/core/client/stream/containers/__snapshots__/ReplyListContainer.spec.tsx.snap @@ -37,6 +37,7 @@ exports[`renders correctly 1`] = ` ] } disableShowAll={false} + indentLevel={1} onShowAll={[Function]} /> `; @@ -81,6 +82,7 @@ exports[`when has more replies renders hasMore 1`] = ` } disableShowAll={false} hasMore={true} + indentLevel={1} onShowAll={[Function]} /> `; @@ -123,6 +125,7 @@ exports[`when has more replies when showing all disables show all button 1`] = ` } disableShowAll={true} hasMore={true} + indentLevel={1} onShowAll={[Function]} /> `; @@ -165,6 +168,7 @@ exports[`when has more replies when showing all enable show all button after loa } disableShowAll={false} hasMore={true} + indentLevel={1} onShowAll={[Function]} /> `; diff --git a/src/core/client/stream/test/__snapshots__/renderReplies.spec.tsx.snap b/src/core/client/stream/test/__snapshots__/renderReplies.spec.tsx.snap index 2ebca65ec..30b74fa08 100644 --- a/src/core/client/stream/test/__snapshots__/renderReplies.spec.tsx.snap +++ b/src/core/client/stream/test/__snapshots__/renderReplies.spec.tsx.snap @@ -249,12 +249,12 @@ exports[`renders comment stream 1`] = `
+
+
+
+
+
+
Date: Tue, 4 Sep 2018 19:47:09 +0200 Subject: [PATCH 06/26] Implement reply --- .../testHelpers/createFakePymStorage.ts | 2 +- .../testHelpers/removeFragmentRefs.ts | 42 +- .../Comment/IndentedComment.spec.tsx | 20 + .../components/Comment/ReplyButton.spec.tsx | 17 + .../stream/components/Comment/ReplyButton.tsx | 2 + .../IndentedComment.spec.tsx.snap | 18 + .../__snapshots__/ReplyButton.spec.tsx.snap | 26 + .../stream/components/ReplyCommentForm.tsx | 133 +- .../stream/containers/CommentContainer.tsx | 1 + .../ReplyCommentFormContainer.spec.tsx | 171 ++ .../containers/ReplyCommentFormContainer.tsx | 2 + .../stream/containers/StreamContainer.tsx | 13 +- .../CommentContainer.spec.tsx.snap | 2 + .../ReplyCommentFormContainer.spec.tsx.snap | 22 + .../stream/mutations/CreateCommentMutation.ts | 10 +- .../test/__snapshots__/loadMore.spec.tsx.snap | 5 + .../__snapshots__/permalinkView.spec.tsx.snap | 2 + ...permalinkViewCommentNotFound.spec.tsx.snap | 1 + .../__snapshots__/postComment.spec.tsx.snap | 8 + .../__snapshots__/postReply.spec.tsx.snap | 1541 +++++++++++++++++ .../__snapshots__/renderReplies.spec.tsx.snap | 4 + .../__snapshots__/renderStream.spec.tsx.snap | 2 + .../showAllReplies.spec.tsx.snap | 5 + src/core/client/stream/test/fixtures.ts | 3 + .../client/stream/test/postReply.spec.tsx | 102 ++ src/locales/en-US/stream.ftl | 6 + 26 files changed, 2076 insertions(+), 84 deletions(-) create mode 100644 src/core/client/stream/components/Comment/IndentedComment.spec.tsx create mode 100644 src/core/client/stream/components/Comment/ReplyButton.spec.tsx create mode 100644 src/core/client/stream/components/Comment/__snapshots__/IndentedComment.spec.tsx.snap create mode 100644 src/core/client/stream/components/Comment/__snapshots__/ReplyButton.spec.tsx.snap create mode 100644 src/core/client/stream/containers/ReplyCommentFormContainer.spec.tsx create mode 100644 src/core/client/stream/containers/__snapshots__/ReplyCommentFormContainer.spec.tsx.snap create mode 100644 src/core/client/stream/test/__snapshots__/postReply.spec.tsx.snap create mode 100644 src/core/client/stream/test/postReply.spec.tsx diff --git a/src/core/client/framework/testHelpers/createFakePymStorage.ts b/src/core/client/framework/testHelpers/createFakePymStorage.ts index ad8510dcd..f7303411f 100644 --- a/src/core/client/framework/testHelpers/createFakePymStorage.ts +++ b/src/core/client/framework/testHelpers/createFakePymStorage.ts @@ -12,7 +12,7 @@ export class FakeStorage implements PymStorage { return Promise.resolve(); } public getItem(key: string) { - return Promise.resolve(this.store[key]); + return Promise.resolve(this.store[key] || null); } } diff --git a/src/core/client/framework/testHelpers/removeFragmentRefs.ts b/src/core/client/framework/testHelpers/removeFragmentRefs.ts index b92e57aa9..4dd29247b 100644 --- a/src/core/client/framework/testHelpers/removeFragmentRefs.ts +++ b/src/core/client/framework/testHelpers/removeFragmentRefs.ts @@ -1,12 +1,42 @@ import { ComponentType } from "react"; -/** Remove all traces of `$fragmentRefs` and `$refType` from type recursively */ +/** Remove `$fragmentRefs` and `$refType` on a single object */ +export type OmitFragments = Pick< + T, + { + [P in keyof T]: P extends " $fragmentRefs" | " $refType" ? never : P + }[keyof T] +>; + export type NoFragmentRefs = T extends object - ? { - [P in Exclude]: NoFragmentRefs< - T[P] - > - } + ? T extends ((...args: any[]) => any) + ? T + : T extends ReadonlyArray + ? ReadonlyArray> // TODO: (cvle) this should normally reference itself but it complains about a circular reference. + : { [P in keyof OmitFragments]: NoFragmentRefs } + : T; + +// TODO: (cvle) these NoFragmentRefX are a workaround for above issue +export type NoFragmentRefs2 = T extends object + ? T extends ((...args: any[]) => any) + ? T + : T extends ReadonlyArray + ? ReadonlyArray> + : { [P in keyof OmitFragments]: NoFragmentRefs } + : T; + +export type NoFragmentRefs3 = T extends object + ? T extends ((...args: any[]) => any) + ? T + : T extends ReadonlyArray + ? ReadonlyArray> + : { [P in keyof OmitFragments]: NoFragmentRefs } + : T; + +export type NoFragmentRefs4 = T extends object + ? T extends ((...args: any[]) => any) + ? T + : { [P in keyof OmitFragments]: NoFragmentRefs } : T; export default function removeFragmentRefs( diff --git a/src/core/client/stream/components/Comment/IndentedComment.spec.tsx b/src/core/client/stream/components/Comment/IndentedComment.spec.tsx new file mode 100644 index 000000000..c86a66ef2 --- /dev/null +++ b/src/core/client/stream/components/Comment/IndentedComment.spec.tsx @@ -0,0 +1,20 @@ +import { shallow } from "enzyme"; +import React from "react"; + +import { PropTypesOf } from "talk-framework/types"; + +import IndentedComment from "./IndentedComment"; + +it("renders correctly", () => { + const props: PropTypesOf = { + indentLevel: 1, + id: "comment-id", + author: { + username: "Marvin", + }, + body: "Woof", + createdAt: "1995-12-17T03:24:00.000Z", + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/src/core/client/stream/components/Comment/ReplyButton.spec.tsx b/src/core/client/stream/components/Comment/ReplyButton.spec.tsx new file mode 100644 index 000000000..f1e2a4267 --- /dev/null +++ b/src/core/client/stream/components/Comment/ReplyButton.spec.tsx @@ -0,0 +1,17 @@ +import { shallow } from "enzyme"; +import { noop } from "lodash"; +import React from "react"; + +import { PropTypesOf } from "talk-framework/types"; + +import ReplyButton from "./ReplyButton"; + +it("renders correctly", () => { + const props: PropTypesOf = { + id: "id", + onClick: noop, + active: true, + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/src/core/client/stream/components/Comment/ReplyButton.tsx b/src/core/client/stream/components/Comment/ReplyButton.tsx index 43b1028ca..cf8ddd25b 100644 --- a/src/core/client/stream/components/Comment/ReplyButton.tsx +++ b/src/core/client/stream/components/Comment/ReplyButton.tsx @@ -4,12 +4,14 @@ import React, { EventHandler, MouseEvent, StatelessComponent } from "react"; import { Button, ButtonIcon, MatchMedia } from "talk-ui/components"; interface Props { + id?: string; onClick?: EventHandler>; active?: boolean; } const ReplyButton: StatelessComponent = props => (
- )} - - - - - - - - - - - - )} - -); + Cancel + + + + + + + + + )} + + ); +}; export default ReplyCommentForm; diff --git a/src/core/client/stream/containers/CommentContainer.tsx b/src/core/client/stream/containers/CommentContainer.tsx index 8624110fd..aed03f7ff 100644 --- a/src/core/client/stream/containers/CommentContainer.tsx +++ b/src/core/client/stream/containers/CommentContainer.tsx @@ -49,6 +49,7 @@ export class CommentContainer extends Component { footer={ <> diff --git a/src/core/client/stream/containers/ReplyCommentFormContainer.spec.tsx b/src/core/client/stream/containers/ReplyCommentFormContainer.spec.tsx new file mode 100644 index 000000000..0f437cb17 --- /dev/null +++ b/src/core/client/stream/containers/ReplyCommentFormContainer.spec.tsx @@ -0,0 +1,171 @@ +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, + removeFragmentRefs, +} from "talk-framework/testHelpers"; +import { ReplyCommentFormContainer } from "./ReplyCommentFormContainer"; + +const ReplyCommentFormContainerN = removeFragmentRefs( + ReplyCommentFormContainer +); + +function getContextKey(commentID: string) { + return `replyCommentFormBody-${commentID}`; +} + +it("renders correctly", async () => { + const props: PropTypesOf = { + // tslint:disable-next-line:no-empty + createComment: (() => {}) as any, + asset: { + id: "asset-id", + }, + comment: { + id: "comment-id", + }, + pymSessionStorage: createFakePymStorage(), + }; + + const wrapper = shallow(); + await timeout(); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); +}); + +it("renders with initialValues", async () => { + const props: PropTypesOf = { + // tslint:disable-next-line:no-empty + createComment: (() => {}) as any, + asset: { + id: "asset-id", + }, + comment: { + id: "comment-id", + }, + pymSessionStorage: createFakePymStorage(), + }; + + await props.pymSessionStorage.setItem( + getContextKey(props.comment.id), + "Hello World!" + ); + + const wrapper = shallow(); + await timeout(); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); +}); + +it("save values", async () => { + const props: PropTypesOf = { + // tslint:disable-next-line:no-empty + createComment: (() => {}) as any, + asset: { + id: "asset-id", + }, + comment: { + id: "comment-id", + }, + pymSessionStorage: createFakePymStorage(), + }; + + await props.pymSessionStorage.setItem( + getContextKey(props.comment.id), + "Hello World!" + ); + + const wrapper = shallow(); + await timeout(); + wrapper.update(); + wrapper + .first() + .props() + .onChange({ values: { body: "changed" } }); + expect( + await props.pymSessionStorage.getItem(getContextKey(props.comment.id)) + ).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 onCloseStub = sinon.stub(); + + const props: PropTypesOf = { + // tslint:disable-next-line:no-empty + createComment: createCommentStub, + asset: { + id: "asset-id", + }, + comment: { + id: "comment-id", + }, + pymSessionStorage: createFakePymStorage(), + onClose: onCloseStub, + }; + + await props.pymSessionStorage.setItem( + getContextKey(props.comment.id), + "Hello World!" + ); + + const wrapper = shallow(); + await timeout(); + wrapper.update(); + wrapper + .first() + .props() + .onSubmit(input, form); + expect( + createCommentStub.calledWith({ + assetID, + parentID: props.comment.id, + ...input, + }) + ).toBeTruthy(); + await timeout(); + expect(onCloseStub.calledOnce).toBe(true); +}); + +it("closes on cancel", async () => { + const onCloseStub = sinon.stub(); + const props: PropTypesOf = { + // tslint:disable-next-line:no-empty + createComment: (() => {}) as any, + asset: { + id: "asset-id", + }, + comment: { + id: "comment-id", + }, + pymSessionStorage: createFakePymStorage(), + onClose: onCloseStub, + }; + + await props.pymSessionStorage.setItem( + getContextKey(props.comment.id), + "Hello World!" + ); + + const wrapper = shallow(); + await timeout(); + wrapper.update(); + wrapper.findWhere(w => !!w.prop("onCancel")).prop("onCancel")(); + + // Calls close. + expect(onCloseStub.calledOnce).toBe(true); + + // Removes saved value. + expect( + await props.pymSessionStorage.getItem(getContextKey(props.comment.id)) + ).toBeNull(); +}); diff --git a/src/core/client/stream/containers/ReplyCommentFormContainer.tsx b/src/core/client/stream/containers/ReplyCommentFormContainer.tsx index 0e376226e..84425c1c8 100644 --- a/src/core/client/stream/containers/ReplyCommentFormContainer.tsx +++ b/src/core/client/stream/containers/ReplyCommentFormContainer.tsx @@ -67,6 +67,7 @@ export class ReplyCommentFormContainer extends Component { parentID: this.props.comment.id, ...input, }); + this.props.pymSessionStorage.removeItem(this.contextKey); if (this.props.onClose) { this.props.onClose(); @@ -95,6 +96,7 @@ export class ReplyCommentFormContainer extends Component { } return ( { public state = { disableLoadMore: false, @@ -81,9 +90,7 @@ const enhanced = withPaginationContainer< @connection(key: "Stream_comments") { edges { node { - id - ...CommentContainer_comment - ...ReplyListContainer_comment + ...StreamContainer_comment @relay(mask: false) } } } diff --git a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap b/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap index 85fc8463c..f7bd9ae74 100644 --- a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap +++ b/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap @@ -14,6 +14,7 @@ exports[`renders body only 1`] = ` +`; + +exports[`renders with initialValues 1`] = ` + +`; diff --git a/src/core/client/stream/mutations/CreateCommentMutation.ts b/src/core/client/stream/mutations/CreateCommentMutation.ts index 95c874ff7..670784753 100644 --- a/src/core/client/stream/mutations/CreateCommentMutation.ts +++ b/src/core/client/stream/mutations/CreateCommentMutation.ts @@ -22,13 +22,7 @@ const mutation = graphql` edge { cursor node { - id - author { - id - username - } - body - createdAt + ...StreamContainer_comment @relay(mask: false) } } clientMutationId @@ -98,7 +92,7 @@ function commit(environment: Environment, input: CreateCommentInput) { }, clientMutationId: (clientMutationId++).toString(), }, - }, + } as any, // TODO: (cvle) generated types should contain one for the optimistic response. configs: getConfig(input), }); } diff --git a/src/core/client/stream/test/__snapshots__/loadMore.spec.tsx.snap b/src/core/client/stream/test/__snapshots__/loadMore.spec.tsx.snap index 955e252f0..c72a5212a 100644 --- a/src/core/client/stream/test/__snapshots__/loadMore.spec.tsx.snap +++ b/src/core/client/stream/test/__snapshots__/loadMore.spec.tsx.snap @@ -181,6 +181,7 @@ exports[`loads more comments 1`] = ` > +
+
+ +
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+ + Markus + + +
+
+
+ +
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+ + +
+
+ +
+
+
+
+ + Lukas + + +
+
+
+ +
+
+
+
+
+
+`; + +exports[`post a reply 2`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+ + Markus + + +
+
+
+ +
+
+
+
+
+ +
+
+
+ + + +
+
Hello world!", + } + } + id="comments-replyCommentForm-rte-comment-0" + onBlur={[Function]} + onChange={[Function]} + onCut={[Function]} + onFocus={[Function]} + onInput={[Function]} + onKeyDown={[Function]} + onPaste={[Function]} + onSelect={[Function]} + /> +
+
+
+
+ + +
+
+ +
+
+
+
+ + Markus + + +
+
Hello world!", + } + } + /> +
+ +
+
+
+
+
+
+
+
+ + Lukas + + +
+
+
+ +
+
+
+
+
+
+`; + +exports[`post a reply 3`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+ + Markus + + +
+
+
+ +
+
+
+
+
+
+ + Markus + + +
+
Hello world! (from server)", + } + } + /> +
+ +
+
+
+
+
+
+
+
+ + Lukas + + +
+
+
+ +
+
+
+
+
+
+`; + +exports[`renders comment stream 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+ + Markus + + +
+
+
+ +
+
+
+
+
+
+ + Lukas + + +
+
+
+ +
+
+
+
+
+
+`; diff --git a/src/core/client/stream/test/__snapshots__/renderReplies.spec.tsx.snap b/src/core/client/stream/test/__snapshots__/renderReplies.spec.tsx.snap index 30b74fa08..684fbfe59 100644 --- a/src/core/client/stream/test/__snapshots__/renderReplies.spec.tsx.snap +++ b/src/core/client/stream/test/__snapshots__/renderReplies.spec.tsx.snap @@ -181,6 +181,7 @@ exports[`renders comment stream 1`] = ` >
diff --git a/src/core/client/stream/components/ReplyCommentForm.tsx b/src/core/client/stream/components/ReplyCommentForm.tsx index 000fd45b2..92ae6e9fa 100644 --- a/src/core/client/stream/components/ReplyCommentForm.tsx +++ b/src/core/client/stream/components/ReplyCommentForm.tsx @@ -1,6 +1,12 @@ +import { CoralRTE } from "@coralproject/rte"; import { FormState } from "final-form"; import { Localized } from "fluent-react/compat"; -import React, { EventHandler, MouseEvent, StatelessComponent } from "react"; +import React, { + EventHandler, + MouseEvent, + Ref, + StatelessComponent, +} from "react"; import { Field, Form, FormSpy } from "react-final-form"; import { OnSubmit } from "talk-framework/lib/form"; @@ -26,6 +32,7 @@ export interface ReplyCommentFormProps { onCancel?: EventHandler>; onChange?: (state: FormState) => void; initialValues?: FormProps; + rteRef?: Ref; } const ReplyCommentForm: StatelessComponent = props => { @@ -58,6 +65,7 @@ const ReplyCommentForm: StatelessComponent = props => { onChange={({ html }) => input.onChange(html)} value={input.value} placeholder="Write a reply" + forwardRef={props.rteRef} /> {meta.touched && diff --git a/src/core/client/stream/containers/ReplyCommentFormContainer.spec.tsx b/src/core/client/stream/containers/ReplyCommentFormContainer.spec.tsx index 0f437cb17..ed6c42b7c 100644 --- a/src/core/client/stream/containers/ReplyCommentFormContainer.spec.tsx +++ b/src/core/client/stream/containers/ReplyCommentFormContainer.spec.tsx @@ -31,6 +31,7 @@ it("renders correctly", async () => { id: "comment-id", }, pymSessionStorage: createFakePymStorage(), + autofocus: false, }; const wrapper = shallow(); @@ -50,6 +51,7 @@ it("renders with initialValues", async () => { id: "comment-id", }, pymSessionStorage: createFakePymStorage(), + autofocus: false, }; await props.pymSessionStorage.setItem( @@ -74,6 +76,7 @@ it("save values", async () => { id: "comment-id", }, pymSessionStorage: createFakePymStorage(), + autofocus: false, }; await props.pymSessionStorage.setItem( @@ -111,6 +114,7 @@ it("creates a comment", async () => { }, pymSessionStorage: createFakePymStorage(), onClose: onCloseStub, + autofocus: false, }; await props.pymSessionStorage.setItem( @@ -149,6 +153,7 @@ it("closes on cancel", async () => { }, pymSessionStorage: createFakePymStorage(), onClose: onCloseStub, + autofocus: false, }; await props.pymSessionStorage.setItem( diff --git a/src/core/client/stream/containers/ReplyCommentFormContainer.tsx b/src/core/client/stream/containers/ReplyCommentFormContainer.tsx index 84425c1c8..4bd5f311b 100644 --- a/src/core/client/stream/containers/ReplyCommentFormContainer.tsx +++ b/src/core/client/stream/containers/ReplyCommentFormContainer.tsx @@ -1,3 +1,4 @@ +import { CoralRTE } from "@coralproject/rte"; import React, { Component } from "react"; import { graphql } from "react-relay"; @@ -20,6 +21,7 @@ interface InnerProps { comment: CommentData; asset: AssetData; onClose?: () => void; + autofocus: boolean; } interface State { @@ -36,6 +38,12 @@ export class ReplyCommentFormContainer extends Component { this.init(); } + private handleRTERef = (rte: CoralRTE | null) => { + if (rte && this.props.autofocus) { + rte.focus(); + } + }; + private async init() { const body = await this.props.pymSessionStorage.getItem(this.contextKey); if (body) { @@ -101,12 +109,15 @@ export class ReplyCommentFormContainer extends Component { onChange={this.handleOnChange} initialValues={this.state.initialValues} onCancel={this.handleOnCancel} + rteRef={this.handleRTERef} /> ); } } -const enhanced = withContext(({ pymSessionStorage }) => ({ +const enhanced = withContext(({ pymSessionStorage, browserInfo }) => ({ pymSessionStorage, + // Disable autofocus on ios and enable for the rest. + autofocus: !browserInfo.ios, }))( withCreateCommentMutation( withFragmentContainer({ diff --git a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap b/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap index f7bd9ae74..1d763eb7a 100644 --- a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap +++ b/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap @@ -14,7 +14,7 @@ exports[`renders body only 1`] = ` `; exports[`renders with initialValues 1`] = ` `; diff --git a/src/core/client/stream/test/__snapshots__/loadMore.spec.tsx.snap b/src/core/client/stream/test/__snapshots__/loadMore.spec.tsx.snap index c72a5212a..2b6612877 100644 --- a/src/core/client/stream/test/__snapshots__/loadMore.spec.tsx.snap +++ b/src/core/client/stream/test/__snapshots__/loadMore.spec.tsx.snap @@ -181,7 +181,7 @@ exports[`loads more comments 1`] = ` >