diff --git a/config/jest/client.config.js b/config/jest/client.config.js index 507257368..d5ba3c585 100644 --- a/config/jest/client.config.js +++ b/config/jest/client.config.js @@ -16,6 +16,7 @@ module.exports = { transform: { "^.+\\.tsx?$": "/node_modules/ts-jest", "^.+\\.css$": "/config/jest/cssTransform.js", + "^.+\\.ftl$": "/config/jest/contentTransform.js", "^(?!.*\\.(js|jsx|mjs|ts|tsx|css|json|ftl)$)": "/config/jest/fileTransform.js", }, @@ -30,7 +31,7 @@ module.exports = { "^talk-framework/(.*)$": "/src/core/client/framework/$1", "^talk-common/(.*)$": "/src/core/common/$1", }, - moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], + moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node", "ftl"], snapshotSerializers: ["enzyme-to-json/serializer"], globals: { "ts-jest": { diff --git a/config/jest/contentTransform.js b/config/jest/contentTransform.js new file mode 100644 index 000000000..8cb718d6c --- /dev/null +++ b/config/jest/contentTransform.js @@ -0,0 +1,15 @@ +"use strict"; + +const path = require("path"); +const fs = require("fs"); +const crypto = require("crypto"); + +// This is a custom Jest transformer that returns the content of a file + +module.exports = { + process(src, filename) { + return `module.exports = ${JSON.stringify( + fs.readFileSync(filename).toString() + )};`; + }, +}; diff --git a/package-lock.json b/package-lock.json index 686af8a81..87ed024ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2162,13 +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==", - "dev": true, - "requires": { - "@types/react": "*" - } + "version": "github:coralproject/patched#3ca0deec868322739fc0d750398fd97735d27aac", + "from": "github:coralproject/patched#types/recompose", + "dev": true }, "@types/relateurl": { "version": "0.2.28", diff --git a/package.json b/package.json index 49fd68d73..5a29bc959 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": "github:coralproject/patched#types/recompose", "@types/relay-runtime": "^1.3.6", "@types/sane": "^2.0.0", "@types/sinon": "^5.0.1", @@ -151,6 +151,7 @@ "babel-plugin-module-resolver": "^3.1.1", "babel-plugin-relay": "^1.7.0-rc.1", "babel-preset-react-optimize": "^1.0.1", + "bowser": "^1.9.4", "case-sensitive-paths-webpack-plugin": "^2.1.2", "chalk": "^2.4.1", "chokidar": "^2.0.4", diff --git a/src/core/client/auth/test/create.tsx b/src/core/client/auth/test/create.tsx new file mode 100644 index 000000000..a0b9d9da9 --- /dev/null +++ b/src/core/client/auth/test/create.tsx @@ -0,0 +1,57 @@ +import { IResolvers } from "graphql-tools"; +import React from "react"; +import TestRenderer from "react-test-renderer"; +import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime"; + +import AppContainer from "talk-auth/containers/AppContainer"; +import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap"; +import { PostMessageService } from "talk-framework/lib/postMessage"; +import { RestClient } from "talk-framework/lib/rest"; +import { createPromisifiedStorage } from "talk-framework/lib/storage"; +import { createUUIDGenerator } from "talk-framework/testHelpers"; + +import createEnvironment from "./createEnvironment"; +import createFluentBundle from "./createFluentBundle"; + +interface CreateParams { + logNetwork?: boolean; + resolvers?: IResolvers; + 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: createPromisifiedStorage(), + sessionStorage: createPromisifiedStorage(), + rest: new RestClient("http://localhost/api"), + postMessage: new PostMessageService(), + browserInfo: { ios: false }, + uuidGenerator: createUUIDGenerator(), + }; + + const testRenderer = TestRenderer.create( + + + + ); + + return { context, testRenderer }; +} diff --git a/src/core/client/auth/test/navigation.spec.tsx b/src/core/client/auth/test/navigation.spec.tsx index 36ba888d8..fe669fb1c 100644 --- a/src/core/client/auth/test/navigation.spec.tsx +++ b/src/core/client/auth/test/navigation.spec.tsx @@ -1,38 +1,16 @@ -// Enable after this is solved: https://github.com/projectfluent/fluent.js/issues/280 +import { ReactTestRenderer } from "react-test-renderer"; -import React from "react"; -import TestRenderer, { ReactTestRenderer } from "react-test-renderer"; -import { RecordProxy } from "relay-runtime"; - -import AppContainer from "talk-auth/containers/AppContainer"; -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 createEnvironment from "./createEnvironment"; -import createFluentBundle from "./createFluentBundle"; +import create from "./create"; function createTestRenderer(initialView: string): ReactTestRenderer { - const environment = createEnvironment({ - initLocalState: (localRecord: RecordProxy) => { + const { testRenderer } = create({ + // Set this to true, to see graphql responses. + logNetwork: false, + initLocalState: localRecord => { localRecord.setValue(initialView, "view"); }, }); - - const context: TalkContext = { - relayEnvironment: environment, - localeBundles: [createFluentBundle()], - localStorage: createInMemoryStorage(), - sessionStorage: createInMemoryStorage(), - rest: new RestClient("http://localhost/api"), - postMessage: new PostMessageService(), - }; - return TestRenderer.create( - - - - ); + return testRenderer; } it("renders sign in form", async () => { diff --git a/src/core/client/auth/test/signIn.spec.tsx b/src/core/client/auth/test/signIn.spec.tsx index 848a05934..cde0e748e 100644 --- a/src/core/client/auth/test/signIn.spec.tsx +++ b/src/core/client/auth/test/signIn.spec.tsx @@ -1,20 +1,10 @@ -import React from "react"; -import TestRenderer, { - ReactTestInstance, - ReactTestRenderer, -} from "react-test-renderer"; -import { RecordProxy } from "relay-runtime"; +import { ReactTestInstance, ReactTestRenderer } from "react-test-renderer"; import sinon from "sinon"; -import AppContainer from "talk-auth/containers/AppContainer"; import { animationFrame, 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 { TalkContext } from "talk-framework/lib/bootstrap"; -import createEnvironment from "./createEnvironment"; -import createFluentBundle from "./createFluentBundle"; +import create from "./create"; const inputPredicate = (name: string) => (n: ReactTestInstance) => { return n.props.name === name && n.props.onChange; @@ -24,26 +14,13 @@ let context: TalkContext; let testRenderer: ReactTestRenderer; let form: ReactTestInstance; beforeEach(() => { - const environment = createEnvironment({ - initLocalState: (localRecord: RecordProxy) => { + ({ testRenderer, context } = create({ + // Set this to true, to see graphql responses. + logNetwork: false, + initLocalState: localRecord => { localRecord.setValue("SIGN_IN", "view"); }, - }); - - context = { - relayEnvironment: environment, - localeBundles: [createFluentBundle()], - localStorage: createInMemoryStorage(), - sessionStorage: createInMemoryStorage(), - rest: new RestClient("http://localhost/api"), - postMessage: new PostMessageService(), - }; - - testRenderer = TestRenderer.create( - - - - ); + })); form = testRenderer.root.findByType("form"); }); diff --git a/src/core/client/auth/test/signUp.spec.tsx b/src/core/client/auth/test/signUp.spec.tsx index a4b36e3d4..cf7ef5b89 100644 --- a/src/core/client/auth/test/signUp.spec.tsx +++ b/src/core/client/auth/test/signUp.spec.tsx @@ -1,20 +1,10 @@ -import React from "react"; -import TestRenderer, { - ReactTestInstance, - ReactTestRenderer, -} from "react-test-renderer"; -import { RecordProxy } from "relay-runtime"; +import { ReactTestInstance, ReactTestRenderer } from "react-test-renderer"; import sinon from "sinon"; -import AppContainer from "talk-auth/containers/AppContainer"; import { animationFrame, 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 { TalkContext } from "talk-framework/lib/bootstrap"; -import createEnvironment from "./createEnvironment"; -import createFluentBundle from "./createFluentBundle"; +import create from "./create"; const inputPredicate = (name: string) => (n: ReactTestInstance) => { return n.props.name === name && n.props.onChange; @@ -24,27 +14,13 @@ let context: TalkContext; let testRenderer: ReactTestRenderer; let form: ReactTestInstance; beforeEach(() => { - const environment = createEnvironment({ - initLocalState: (localRecord: RecordProxy) => { + ({ testRenderer, context } = create({ + // Set this to true, to see graphql responses. + logNetwork: false, + initLocalState: localRecord => { localRecord.setValue("SIGN_UP", "view"); }, - }); - - context = { - relayEnvironment: environment, - localeBundles: [createFluentBundle()], - localStorage: createInMemoryStorage(), - sessionStorage: createInMemoryStorage(), - rest: new RestClient("http://localhost/api"), - postMessage: new PostMessageService(), - }; - - testRenderer = TestRenderer.create( - - - - ); - + })); form = testRenderer.root.findByType("form"); }); diff --git a/src/core/client/embed/decorators/__snapshots__/withPymStorage.spec.ts.snap b/src/core/client/embed/decorators/__snapshots__/withPymStorage.spec.ts.snap index 6cc6e2817..127601892 100644 --- a/src/core/client/embed/decorators/__snapshots__/withPymStorage.spec.ts.snap +++ b/src/core/client/embed/decorators/__snapshots__/withPymStorage.spec.ts.snap @@ -1,15 +1,51 @@ // 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 clear storage 1`] = `"{}"`; + +exports[`withPymStorage should clear storage 2`] = ` +Array [ + Object { + "key": "pymStorage.localStorage.response", + "value": "{\\"id\\":\\"0\\"}", + }, +] +`; + +exports[`withPymStorage should get key of storage 1`] = ` +Array [ + Object { + "key": "pymStorage.localStorage.response", + "value": "{\\"id\\":\\"0\\",\\"result\\":\\"b\\"}", + }, + Object { + "key": "pymStorage.localStorage.response", + "value": "{\\"id\\":\\"0\\",\\"result\\":null}", + }, +] +`; + +exports[`withPymStorage should get length of storage 1`] = ` +Array [ + Object { + "key": "pymStorage.localStorage.response", + "value": "{\\"id\\":\\"0\\",\\"result\\":3}", + }, +] +`; + +exports[`withPymStorage should handle handle errors 1`] = ` +Array [ + Object { + "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 1`] = `"{\\"talk:key\\":\\"test\\"}"`; -exports[`withPymStorage should set, get and remove item 2`] = `Object {}`; +exports[`withPymStorage should set, get and remove item 2`] = `"{}"`; 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/withPymStorage.spec.ts b/src/core/client/embed/decorators/withPymStorage.spec.ts index 60ab2ed0f..006c9bd39 100644 --- a/src/core/client/embed/decorators/withPymStorage.spec.ts +++ b/src/core/client/embed/decorators/withPymStorage.spec.ts @@ -1,23 +1,8 @@ import sinon from "sinon"; +import { createInMemoryStorage } from "../testUtils"; 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 }> = []; @@ -38,10 +23,8 @@ class PymStub { 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 - ); + const storage = createInMemoryStorage(); + withPymStorage(storage, "localStorage", "talk:")(pym as any); pym.listeners["pymStorage.localStorage.request"]( JSON.stringify({ id: "0", @@ -49,7 +32,7 @@ describe("withPymStorage", () => { parameters: { key: "key", value: "test" }, }) ); - expect(storage.store).toMatchSnapshot(); + expect(storage.toString()).toMatchSnapshot(); pym.listeners["pymStorage.localStorage.request"]( JSON.stringify({ id: "1", @@ -64,15 +47,72 @@ describe("withPymStorage", () => { parameters: { key: "key" }, }) ); - expect(storage.store).toMatchSnapshot(); + expect(storage.toString()).toMatchSnapshot(); expect(JSON.stringify(pym.messages)).toMatchSnapshot(); }); + it("should get key of storage", () => { + const pym = new PymStub("localStorage"); + const storage = createInMemoryStorage({ + a: "1", + b: "2", + c: "3", + }); + withPymStorage(storage, "localStorage", "")(pym as any); + pym.listeners["pymStorage.localStorage.request"]( + JSON.stringify({ + id: "0", + method: "key", + parameters: { n: 1 }, + }) + ); + pym.listeners["pymStorage.localStorage.request"]( + JSON.stringify({ + id: "0", + method: "key", + parameters: { n: 3 }, + }) + ); + expect(pym.messages).toMatchSnapshot(); + }); + it("should get length of storage", () => { + const pym = new PymStub("localStorage"); + const storage = createInMemoryStorage({ + a: "1", + b: "2", + c: "3", + }); + withPymStorage(storage, "localStorage", "")(pym as any); + pym.listeners["pymStorage.localStorage.request"]( + JSON.stringify({ + id: "0", + method: "length", + parameters: {}, + }) + ); + expect(pym.messages).toMatchSnapshot(); + }); + it("should clear storage", () => { + const pym = new PymStub("localStorage"); + const storage = createInMemoryStorage({ + a: "1", + b: "2", + c: "3", + }); + withPymStorage(storage, "localStorage", "")(pym as any); + pym.listeners["pymStorage.localStorage.request"]( + JSON.stringify({ + id: "0", + method: "clear", + parameters: {}, + }) + ); + expect(storage.toString()).toMatchSnapshot(); + expect(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 - ); + const storage = createInMemoryStorage(); + withPymStorage(storage, "localStorage", "talk:")(pym as any); pym.listeners["pymStorage.localStorage.request"]( JSON.stringify({ id: "0", @@ -84,14 +124,12 @@ describe("withPymStorage", () => { }); it("should handle handle errors", () => { const pym = new PymStub("localStorage"); - const storage = new FakeStorage(); + const storage = createInMemoryStorage(); sinon .mock(storage) .expects("getItem") .throws("error"); - withPymStorage(storage as any, "localStorage", "talkPymStorage:")( - pym as any - ); + withPymStorage(storage, "localStorage", "talk:")(pym as any); pym.listeners["pymStorage.localStorage.request"]( JSON.stringify({ id: "0", @@ -99,6 +137,6 @@ describe("withPymStorage", () => { parameters: {}, }) ); - expect(JSON.stringify(pym.messages)).toMatchSnapshot(); + expect(pym.messages).toMatchSnapshot(); }); }); diff --git a/src/core/client/embed/decorators/withPymStorage.ts b/src/core/client/embed/decorators/withPymStorage.ts index 2dcaff1c0..d104dc53e 100644 --- a/src/core/client/embed/decorators/withPymStorage.ts +++ b/src/core/client/embed/decorators/withPymStorage.ts @@ -1,14 +1,15 @@ +import { prefixStorage } from "../utils"; import { Decorator } from "./types"; const withPymStorage = ( storage: Storage, type: "localStorage" | "sessionStorage", - prefix = "talkPymStorage:" + prefix = "talk:" ): Decorator => pym => { pym.onMessage(`pymStorage.${type}.request`, (msg: any) => { const { id, method, parameters } = JSON.parse(msg); - const { key, value } = parameters; - const prefixedKey = `${prefix}${key}`; + const { n, key, value } = parameters; + const prefixedStorage = prefixStorage(storage, prefix); // Variable for the method return value. let result; @@ -25,13 +26,22 @@ const withPymStorage = ( try { switch (method) { case "setItem": - result = storage.setItem(prefixedKey, value); + result = prefixedStorage.setItem(key, value); break; case "getItem": - result = storage.getItem(prefixedKey); + result = prefixedStorage.getItem(key); break; case "removeItem": - result = storage.removeItem(prefixedKey); + result = prefixedStorage.removeItem(key); + break; + case "key": + result = prefixedStorage.key(n); + break; + case "length": + result = prefixedStorage.length; + break; + case "clear": + result = prefixedStorage.clear(); break; default: sendError(`Unknown method ${method}`); diff --git a/src/core/client/embed/testUtils/InMemoryStorage.spec.ts b/src/core/client/embed/testUtils/InMemoryStorage.spec.ts new file mode 100644 index 000000000..c3c7f60f1 --- /dev/null +++ b/src/core/client/embed/testUtils/InMemoryStorage.spec.ts @@ -0,0 +1,34 @@ +import createInMemoryStorage from "./InMemoryStorage"; + +it("should set and unset values", () => { + const storage = createInMemoryStorage(); + storage.setItem("test", "value"); + expect(storage.getItem("test")).toBe("value"); + storage.removeItem("test"); + expect(storage.getItem("test")).toBeNull(); +}); + +it("should return length", () => { + const storage = createInMemoryStorage(); + storage.setItem("a", "value"); + storage.setItem("b", "value"); + storage.setItem("c", "value"); + expect(storage.length).toBe(3); +}); + +it("should nth key", () => { + const storage = createInMemoryStorage(); + storage.setItem("a", "0"); + storage.setItem("b", "1"); + storage.setItem("c", "2"); + expect(storage.key(2)).toBe("c"); +}); + +it("accepts predefined data", () => { + const storage = createInMemoryStorage({ + a: "0", + b: "1", + c: "2", + }); + expect(storage.toString()).toMatchSnapshot(); +}); diff --git a/src/core/client/embed/testUtils/InMemoryStorage.ts b/src/core/client/embed/testUtils/InMemoryStorage.ts new file mode 100644 index 000000000..803323e2d --- /dev/null +++ b/src/core/client/embed/testUtils/InMemoryStorage.ts @@ -0,0 +1,49 @@ +/** + * InMemoryStorage is a dumb implementation of the Storage interface that will + * not persist the data at all. It implements the Storage interface found: + * + * https://developer.mozilla.org/en-US/docs/Web/API/Storage + */ +class InMemoryStorage implements Storage { + private data: Record; + + constructor(data: Record = {}) { + this.data = data; + } + + get length() { + return Object.keys(this.data).length; + } + + public clear() { + this.data = {}; + } + + public key(n: number) { + if (this.length <= n) { + return null; + } + + return Object.keys(this.data)[n]; + } + + public getItem(key: string) { + return this.data[key] || null; + } + + public setItem(key: string, value: string) { + this.data[key] = value; + } + + public removeItem(key: string) { + delete this.data[key]; + } + + public toString() { + return JSON.stringify(this.data); + } +} + +export default function createInMemoryStorage(data?: Record) { + return new InMemoryStorage(data); +} diff --git a/src/core/client/embed/testUtils/__snapshots__/InMemoryStorage.spec.ts.snap b/src/core/client/embed/testUtils/__snapshots__/InMemoryStorage.spec.ts.snap new file mode 100644 index 000000000..109b66739 --- /dev/null +++ b/src/core/client/embed/testUtils/__snapshots__/InMemoryStorage.spec.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`accepts predefined data 1`] = `"{\\"a\\":\\"0\\",\\"b\\":\\"1\\",\\"c\\":\\"2\\"}"`; diff --git a/src/core/client/embed/testUtils/index.ts b/src/core/client/embed/testUtils/index.ts new file mode 100644 index 000000000..72296ca86 --- /dev/null +++ b/src/core/client/embed/testUtils/index.ts @@ -0,0 +1 @@ +export { default as createInMemoryStorage } from "./InMemoryStorage"; diff --git a/src/core/client/embed/utils/__snapshots__/prefixStorage.spec.ts.snap b/src/core/client/embed/utils/__snapshots__/prefixStorage.spec.ts.snap new file mode 100644 index 000000000..67b9effeb --- /dev/null +++ b/src/core/client/embed/utils/__snapshots__/prefixStorage.spec.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should call clear 1`] = `"{\\"a\\":\\"0\\",\\"b\\":\\"1\\",\\"d\\":\\"3\\"}"`; diff --git a/src/core/client/embed/utils/index.ts b/src/core/client/embed/utils/index.ts index 0cf1aaa7f..9a373ce0b 100644 --- a/src/core/client/embed/utils/index.ts +++ b/src/core/client/embed/utils/index.ts @@ -1,2 +1,4 @@ export { default as buildURL } from "./buildURL"; export { default as ensureEndSlash } from "./ensureEndSlash"; +export { default as startsWith } from "./startsWith"; +export { default as prefixStorage } from "./prefixStorage"; diff --git a/src/core/client/embed/utils/prefixStorage.spec.ts b/src/core/client/embed/utils/prefixStorage.spec.ts new file mode 100644 index 000000000..ce9e7fb65 --- /dev/null +++ b/src/core/client/embed/utils/prefixStorage.spec.ts @@ -0,0 +1,79 @@ +import sinon from "sinon"; +import { createInMemoryStorage } from "../testUtils"; +import prefixStorage from "./prefixStorage"; + +it("should get nth key", () => { + const storage = createInMemoryStorage({ + a: "0", + b: "1", + "talk:c": "2", + d: "3", + "talk:e": "4", + }); + + const prefixed = prefixStorage(storage, "talk:"); + expect(prefixed.key(0)).toBe("talk:c"); + expect(prefixed.key(1)).toBe("talk:e"); + expect(prefixed.key(2)).toBeNull(); +}); + +it("should call clear", () => { + const storage = createInMemoryStorage({ + a: "0", + b: "1", + "talk:c": "2", + d: "3", + "talk:e": "4", + }); + + const prefixed = prefixStorage(storage, "talk:"); + prefixed.clear(); + expect(storage.toString()).toMatchSnapshot(); +}); + +it("should call length", () => { + const storage = createInMemoryStorage({ + a: "0", + b: "1", + "talk:c": "2", + d: "3", + "talk:e": "4", + }); + + const prefixed = prefixStorage(storage, "talk:"); + expect(prefixed.length).toBe(2); +}); + +it("should prefix setItem", () => { + const storage = { + setItem: sinon.mock().withArgs("talk:key", "value"), + }; + + const prefixed = prefixStorage(storage as any, "talk:"); + prefixed.setItem("key", "value"); + storage.setItem.verify(); +}); + +it("should prefix removeItem", () => { + const storage = { + removeItem: sinon.mock().withArgs("talk:key"), + }; + + const prefixed = prefixStorage(storage as any, "talk:"); + prefixed.removeItem("key"); + storage.removeItem.verify(); +}); + +it("should prefix getItem", () => { + const ret = "value"; + const storage = { + getItem: sinon + .mock() + .withArgs("talk:key") + .returns(ret), + }; + + const prefixed = prefixStorage(storage as any, "talk:"); + expect(prefixed.getItem("key")).toBe(ret); + (storage.getItem as any).verify(); +}); diff --git a/src/core/client/embed/utils/prefixStorage.ts b/src/core/client/embed/utils/prefixStorage.ts new file mode 100644 index 000000000..0972a6804 --- /dev/null +++ b/src/core/client/embed/utils/prefixStorage.ts @@ -0,0 +1,66 @@ +import startsWith from "./startsWith"; + +/** + * PrefixedStorage decorates a Storage and prefixes keys in + * getItem, setItem and removeItem with given prefix. + */ +class PrefixedStorage implements Storage { + private storage: Storage; + private prefix: string; + + constructor(storage: Storage, prefix: string) { + this.storage = storage; + this.prefix = prefix; + } + + get length() { + let count = 0; + for (let i = 0; i < this.storage.length; i++) { + if (startsWith(this.storage.key(i)!, this.prefix)) { + count++; + } + } + return count; + } + + public clear() { + const toBeDeleted = []; + for (let i = 0; i < this.storage.length; i++) { + const key = this.storage.key(i)!; + if (startsWith(key, this.prefix)) { + toBeDeleted.push(key); + } + } + toBeDeleted.forEach(key => this.storage.removeItem(key)); + } + + public key(n: number) { + let count = 0; + for (let i = 0; i < this.storage.length; i++) { + const key = this.storage.key(i)!; + if (startsWith(key, this.prefix)) { + if (count === n) { + return key; + } + count++; + } + } + return null; + } + + public getItem(key: string) { + return this.storage.getItem(`${this.prefix}${key}`); + } + + public setItem(key: string, value: string) { + return this.storage.setItem(`${this.prefix}${key}`, value); + } + + public removeItem(key: string) { + return this.storage.removeItem(`${this.prefix}${key}`); + } +} + +export default function prefixStorage(storage: Storage, prefix: string) { + return new PrefixedStorage(storage, prefix); +} diff --git a/src/core/client/embed/utils/startsWith.spec.ts b/src/core/client/embed/utils/startsWith.spec.ts new file mode 100644 index 000000000..7e4c5d16e --- /dev/null +++ b/src/core/client/embed/utils/startsWith.spec.ts @@ -0,0 +1,7 @@ +import startsWith from "./startsWith"; + +it("should work correctly", () => { + const str1 = "Saturday night plans"; + expect(startsWith(str1, "Sat")).toBe(true); + expect(startsWith(str1, "Sat", 3)).toBe(false); +}); diff --git a/src/core/client/embed/utils/startsWith.ts b/src/core/client/embed/utils/startsWith.ts new file mode 100644 index 000000000..fb9efe717 --- /dev/null +++ b/src/core/client/embed/utils/startsWith.ts @@ -0,0 +1,4 @@ +/** A substitute for string.startsWith */ +export default function startsWith(str: string, search: string, pos?: number) { + return str.substr(!pos || pos < 0 ? 0 : +pos, search.length) === search; +} diff --git a/src/core/client/framework/helpers/getMe.ts b/src/core/client/framework/helpers/getMe.ts index 07eeb5952..fb5bafcf9 100644 --- a/src/core/client/framework/helpers/getMe.ts +++ b/src/core/client/framework/helpers/getMe.ts @@ -3,7 +3,9 @@ import { Environment, ROOT_ID } from "relay-runtime"; export default function getMe(environment: Environment) { const source = environment.getStore().getSource(); const root = source.get(ROOT_ID)!; - const meKey = Object.keys(root).find(s => s.startsWith("me("))!; + const meKey = Object.keys(root) + .reverse() + .find(s => s.startsWith("me("))!; if (!root[meKey]) { return null; } diff --git a/src/core/client/framework/lib/bootstrap/TalkContext.tsx b/src/core/client/framework/lib/bootstrap/TalkContext.tsx index f46bee9f2..d855a884e 100644 --- a/src/core/client/framework/lib/bootstrap/TalkContext.tsx +++ b/src/core/client/framework/lib/bootstrap/TalkContext.tsx @@ -6,9 +6,10 @@ import { MediaQueryMatchers } from "react-responsive"; import { Formatter } from "react-timeago"; import { Environment } from "relay-runtime"; +import { BrowserInfo } from "talk-framework/lib/browserInfo"; import { PostMessageService } from "talk-framework/lib/postMessage"; import { RestClient } from "talk-framework/lib/rest"; -import { PymStorage } from "talk-framework/lib/storage"; +import { PromisifiedStorage } from "talk-framework/lib/storage"; import { UIContext } from "talk-ui/components"; import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside"; @@ -23,16 +24,10 @@ export interface TalkContext { timeagoFormatter?: Formatter; /** Local Storage */ - localStorage: Storage; + localStorage: PromisifiedStorage; /** Session storage */ - sessionStorage: Storage; - - /** Local Storage over pym */ - pymLocalStorage?: PymStorage; - - /** Session storage over pym */ - pymSessionStorage?: PymStorage; + sessionStorage: PromisifiedStorage; /** media query values for testing purposes */ mediaQueryValues?: MediaQueryMatchers; @@ -51,6 +46,12 @@ export interface TalkContext { /** A pym child that interacts with the pym parent. */ pym?: PymChild; + + /** Browser detection. */ + browserInfo: BrowserInfo; + + /** Generates uuids. */ + uuidGenerator: () => string; } const { Provider, Consumer } = React.createContext({} as any); diff --git a/src/core/client/framework/lib/bootstrap/createContext.tsx b/src/core/client/framework/lib/bootstrap/createContext.tsx index 3fce90cd3..839cee453 100644 --- a/src/core/client/framework/lib/bootstrap/createContext.tsx +++ b/src/core/client/framework/lib/bootstrap/createContext.tsx @@ -5,9 +5,13 @@ import { Child as PymChild } from "pym.js"; import React from "react"; import { Formatter } from "react-timeago"; import { Environment, Network, RecordSource, Store } from "relay-runtime"; +import uuid from "uuid/v4"; + +import { getBrowserInfo } from "talk-framework/lib/browserInfo"; import { LOCAL_ID } from "talk-framework/lib/relay"; import { createLocalStorage, + createPromisifiedStorage, createPymStorage, createSessionStorage, } from "talk-framework/lib/storage"; @@ -56,6 +60,14 @@ export const timeagoFormatter: Formatter = (value, unit, suffix) => { ); }; +function areWeInIframe() { + try { + return window.self !== window.top; + } catch (e) { + return true; + } +} + /** * `createContext` manages the dependencies of our framework * and returns a `TalkContext` that can be passed to the @@ -68,6 +80,7 @@ export default async function createContext({ pym, eventEmitter = new EventEmitter2({ wildcard: true }), }: CreateContextArguments): Promise { + const inIframe = areWeInIframe(); // Initialize Relay. const source = new RecordSource(); const tokenGetter: TokenGetter = () => { @@ -117,10 +130,14 @@ export default async function createContext({ registerClickFarAway, rest: new RestClient("/api", tokenGetter), postMessage: new PostMessageService(), - localStorage: createLocalStorage(), - sessionStorage: createSessionStorage(), - pymLocalStorage: pym && createPymStorage(pym, "localStorage"), - pymSessionStorage: pym && createPymStorage(pym, "sessionStorage"), + localStorage: + (pym && inIframe && createPymStorage(pym, "localStorage")) || + createPromisifiedStorage(createLocalStorage()), + sessionStorage: + (pym && inIframe && createPymStorage(pym, "sessionStorage")) || + createPromisifiedStorage(createSessionStorage()), + browserInfo: getBrowserInfo(), + uuidGenerator: uuid, }; // Run custom initializations. diff --git a/src/core/client/framework/lib/browserInfo.ts b/src/core/client/framework/lib/browserInfo.ts new file mode 100644 index 000000000..f7f3c9493 --- /dev/null +++ b/src/core/client/framework/lib/browserInfo.ts @@ -0,0 +1,11 @@ +import bowser from "bowser"; + +export interface BrowserInfo { + ios: boolean; +} + +export function getBrowserInfo(): BrowserInfo { + return { + ios: bowser.ios, + }; +} diff --git a/src/core/client/framework/lib/storage/InMemoryStorage.spec.ts b/src/core/client/framework/lib/storage/InMemoryStorage.spec.ts index 8c141ed61..c3c7f60f1 100644 --- a/src/core/client/framework/lib/storage/InMemoryStorage.spec.ts +++ b/src/core/client/framework/lib/storage/InMemoryStorage.spec.ts @@ -5,7 +5,7 @@ it("should set and unset values", () => { storage.setItem("test", "value"); expect(storage.getItem("test")).toBe("value"); storage.removeItem("test"); - expect(storage.getItem("test")).toBeUndefined(); + expect(storage.getItem("test")).toBeNull(); }); it("should return length", () => { @@ -16,10 +16,19 @@ it("should return length", () => { expect(storage.length).toBe(3); }); -it("should nth value", () => { +it("should nth key", () => { const storage = createInMemoryStorage(); - storage.setItem("a", "a"); - storage.setItem("b", "b"); - storage.setItem("c", "c"); + storage.setItem("a", "0"); + storage.setItem("b", "1"); + storage.setItem("c", "2"); expect(storage.key(2)).toBe("c"); }); + +it("accepts predefined data", () => { + const storage = createInMemoryStorage({ + a: "0", + b: "1", + c: "2", + }); + expect(storage.toString()).toMatchSnapshot(); +}); diff --git a/src/core/client/framework/lib/storage/InMemoryStorage.ts b/src/core/client/framework/lib/storage/InMemoryStorage.ts index ec4838b20..803323e2d 100644 --- a/src/core/client/framework/lib/storage/InMemoryStorage.ts +++ b/src/core/client/framework/lib/storage/InMemoryStorage.ts @@ -5,18 +5,18 @@ * https://developer.mozilla.org/en-US/docs/Web/API/Storage */ class InMemoryStorage implements Storage { - private storage: Record; + private data: Record; - constructor() { - this.storage = {}; + constructor(data: Record = {}) { + this.data = data; } get length() { - return Object.keys(this.storage).length; + return Object.keys(this.data).length; } public clear() { - this.storage = {}; + this.data = {}; } public key(n: number) { @@ -24,26 +24,26 @@ class InMemoryStorage implements Storage { return null; } - return this.storage[Object.keys(this.storage)[n]]; + return Object.keys(this.data)[n]; } public getItem(key: string) { - return this.storage[key]; + return this.data[key] || null; } public setItem(key: string, value: string) { - this.storage[key] = value; + this.data[key] = value; } public removeItem(key: string) { - delete this.storage[key]; + delete this.data[key]; } public toString() { - return JSON.stringify(this.storage); + return JSON.stringify(this.data); } } -export default function createInMemoryStorage() { - return new InMemoryStorage(); +export default function createInMemoryStorage(data?: Record) { + return new InMemoryStorage(data); } diff --git a/src/core/client/framework/lib/storage/LocalStorage.ts b/src/core/client/framework/lib/storage/LocalStorage.ts index 72588d880..91d8db2ad 100644 --- a/src/core/client/framework/lib/storage/LocalStorage.ts +++ b/src/core/client/framework/lib/storage/LocalStorage.ts @@ -1,5 +1,5 @@ import prefixStorage from "./prefixStorage"; -export default function createLocalStorage(): Storage { - return prefixStorage(window.localStorage, "talk"); +export default function createLocalStorage(prefix = "talk:"): Storage { + return prefixStorage(window.localStorage, prefix); } diff --git a/src/core/client/framework/lib/storage/PromisifiedStorage.spec.ts b/src/core/client/framework/lib/storage/PromisifiedStorage.spec.ts new file mode 100644 index 000000000..027e4ebf0 --- /dev/null +++ b/src/core/client/framework/lib/storage/PromisifiedStorage.spec.ts @@ -0,0 +1,26 @@ +import createInMemoryStorage from "./InMemoryStorage"; +import createPromisifiedStorage from "./PromisifiedStorage"; + +it("should set and unset values", async () => { + const storage = createPromisifiedStorage(createInMemoryStorage()); + await expect(storage.setItem("test", "value")).resolves.toBeUndefined(); + await expect(storage.getItem("test")).resolves.toBe("value"); + storage.removeItem("test"); + await expect(storage.getItem("test")).resolves.toBeNull(); +}); + +it("should return length", async () => { + const storage = createPromisifiedStorage(createInMemoryStorage()); + storage.setItem("a", "value"); + storage.setItem("b", "value"); + storage.setItem("c", "value"); + await expect(storage.length).resolves.toBe(3); +}); + +it("should nth value", async () => { + const storage = createPromisifiedStorage(createInMemoryStorage()); + storage.setItem("a", "a"); + storage.setItem("b", "b"); + storage.setItem("c", "c"); + await expect(storage.key(2)).resolves.toBe("c"); +}); diff --git a/src/core/client/framework/lib/storage/PromisifiedStorage.ts b/src/core/client/framework/lib/storage/PromisifiedStorage.ts new file mode 100644 index 000000000..8bdc9a7bc --- /dev/null +++ b/src/core/client/framework/lib/storage/PromisifiedStorage.ts @@ -0,0 +1,63 @@ +import createInMemoryStorage from "./InMemoryStorage"; + +export interface PromisifiedStorage { + length: Promise; + + clear(): Promise; + + key(n: number): Promise; + + /** + * value = storage[key] + */ + getItem(key: string): Promise; + /** + * delete storage[key] + */ + removeItem(key: string): Promise; + /** + * storage[key] = value + */ + setItem(key: string, value: string): Promise; +} + +/** + * BackedPromisifedStorage. + */ +class BackedPromisifedStorage implements PromisifiedStorage { + private storage: Storage; + + constructor(storage: Storage) { + this.storage = storage; + } + + get length() { + return Promise.resolve(this.storage.length); + } + + public clear() { + return Promise.resolve(this.storage.clear()); + } + + public key(n: number) { + return Promise.resolve(this.storage.key(n)); + } + + public getItem(key: string) { + return Promise.resolve(this.storage.getItem(key)); + } + + public setItem(key: string, value: string) { + return Promise.resolve(this.storage.setItem(key, value)); + } + + public removeItem(key: string) { + return Promise.resolve(this.storage.removeItem(key)); + } +} + +export default function createPromisifiedStorage( + storage: Storage = createInMemoryStorage() +) { + return new BackedPromisifedStorage(storage); +} diff --git a/src/core/client/framework/lib/storage/PymStorage.spec.ts b/src/core/client/framework/lib/storage/PymStorage.spec.ts index 7a9c9a805..f4216bd2c 100644 --- a/src/core/client/framework/lib/storage/PymStorage.spec.ts +++ b/src/core/client/framework/lib/storage/PymStorage.spec.ts @@ -59,6 +59,49 @@ describe("PymStorage", () => { expect(promise).resolves.toBe("value"); }); + it("should get length", () => { + const pym = new PymStub("localStorage"); + const storage = createPymStorage(pym as any, "localStorage"); + const promise = storage.length; + const { key, value } = pym.messages.pop()!; + expect(key).toBe(`pymStorage.localStorage.request`); + const { id, method, parameters } = JSON.parse(value); + expect(method).toBe("length"); + expect(parameters).toEqual({}); + pym.listeners["pymStorage.localStorage.response"]( + JSON.stringify({ id, result: 3 }) + ); + expect(promise).resolves.toBe(3); + }); + + it("should get key", () => { + const pym = new PymStub("localStorage"); + const storage = createPymStorage(pym as any, "localStorage"); + const promise = storage.key(2); + const { key, value } = pym.messages.pop()!; + expect(key).toBe(`pymStorage.localStorage.request`); + const { id, method, parameters } = JSON.parse(value); + expect(method).toBe("key"); + expect(parameters).toEqual({ n: 2 }); + pym.listeners["pymStorage.localStorage.response"]( + JSON.stringify({ id, result: "myKey" }) + ); + expect(promise).resolves.toBe("myKey"); + }); + + it("should clear", () => { + const pym = new PymStub("localStorage"); + const storage = createPymStorage(pym as any, "localStorage"); + const promise = storage.clear(); + const { key, value } = pym.messages.pop()!; + expect(key).toBe(`pymStorage.localStorage.request`); + const { id, method, parameters } = JSON.parse(value); + expect(method).toBe("clear"); + expect(parameters).toEqual({}); + pym.listeners["pymStorage.localStorage.response"](JSON.stringify({ id })); + expect(promise).resolves.toBeUndefined(); + }); + describe("on error", () => { it("should reject set item", () => { const pym = new PymStub("localStorage"); diff --git a/src/core/client/framework/lib/storage/PymStorage.ts b/src/core/client/framework/lib/storage/PymStorage.ts index 0b66f9c7f..6a93d5035 100644 --- a/src/core/client/framework/lib/storage/PymStorage.ts +++ b/src/core/client/framework/lib/storage/PymStorage.ts @@ -1,24 +1,10 @@ import { Child, Parent } from "pym.js"; import uuid from "uuid/v4"; +import { PromisifiedStorage } from "./PromisifiedStorage"; type Pym = Child | Parent; -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; -} - -class PymStorageImpl implements PymStorage { +class PymStorage implements PromisifiedStorage { /** Instance to pym */ private pym: Pym; @@ -34,7 +20,7 @@ class PymStorageImpl implements PymStorage { /** Requests method with parameters over pym. */ private call( method: string, - parameters: { key: string; value?: string } + parameters: Record = {} ): Promise { const id = uuid(); return new Promise((resolve, reject) => { @@ -69,6 +55,15 @@ class PymStorageImpl implements PymStorage { this.listen(); } + get length() { + return this.call("length"); + } + public key(n: number) { + return this.call("key", { n }); + } + public clear() { + return this.call("clear"); + } public setItem(key: string, value: string) { return this.call("setItem", { key, value }); } @@ -90,5 +85,5 @@ export default function createPymStorage( pym: Pym, type: "localStorage" | "sessionStorage" ): PymStorage { - return new PymStorageImpl(pym, type); + return new PymStorage(pym, type); } diff --git a/src/core/client/framework/lib/storage/SessionStorage.ts b/src/core/client/framework/lib/storage/SessionStorage.ts index 7b45e09a3..092c03edf 100644 --- a/src/core/client/framework/lib/storage/SessionStorage.ts +++ b/src/core/client/framework/lib/storage/SessionStorage.ts @@ -1,5 +1,5 @@ import prefixStorage from "./prefixStorage"; -export default function createSessionStorage(): Storage { - return prefixStorage(window.sessionStorage, "talk"); +export default function createSessionStorage(prefix = "talk:"): Storage { + return prefixStorage(window.sessionStorage, prefix); } diff --git a/src/core/client/framework/lib/storage/__snapshots__/InMemoryStorage.spec.ts.snap b/src/core/client/framework/lib/storage/__snapshots__/InMemoryStorage.spec.ts.snap new file mode 100644 index 000000000..109b66739 --- /dev/null +++ b/src/core/client/framework/lib/storage/__snapshots__/InMemoryStorage.spec.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`accepts predefined data 1`] = `"{\\"a\\":\\"0\\",\\"b\\":\\"1\\",\\"c\\":\\"2\\"}"`; diff --git a/src/core/client/framework/lib/storage/__snapshots__/prefixStorage.spec.ts.snap b/src/core/client/framework/lib/storage/__snapshots__/prefixStorage.spec.ts.snap new file mode 100644 index 000000000..67b9effeb --- /dev/null +++ b/src/core/client/framework/lib/storage/__snapshots__/prefixStorage.spec.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should call clear 1`] = `"{\\"a\\":\\"0\\",\\"b\\":\\"1\\",\\"d\\":\\"3\\"}"`; diff --git a/src/core/client/framework/lib/storage/index.ts b/src/core/client/framework/lib/storage/index.ts index e17d10668..ca667465b 100644 --- a/src/core/client/framework/lib/storage/index.ts +++ b/src/core/client/framework/lib/storage/index.ts @@ -1,4 +1,8 @@ export { default as createInMemoryStorage } from "./InMemoryStorage"; export { default as createLocalStorage } from "./LocalStorage"; export { default as createSessionStorage } from "./SessionStorage"; -export { default as createPymStorage, PymStorage } from "./PymStorage"; +export { default as createPymStorage } from "./PymStorage"; +export { + default as createPromisifiedStorage, + PromisifiedStorage, +} from "./PromisifiedStorage"; diff --git a/src/core/client/framework/lib/storage/prefixStorage.spec.ts b/src/core/client/framework/lib/storage/prefixStorage.spec.ts index 6e8e8088d..1c483ef33 100644 --- a/src/core/client/framework/lib/storage/prefixStorage.spec.ts +++ b/src/core/client/framework/lib/storage/prefixStorage.spec.ts @@ -1,54 +1,47 @@ import sinon from "sinon"; +import createInMemoryStorage from "./InMemoryStorage"; import prefixStorage from "./prefixStorage"; -it("should call clear", () => { - const storage = { - clear: sinon.mock().once(), - }; +it("should get nth key", () => { + const storage = createInMemoryStorage({ + a: "0", + b: "1", + "talk:c": "2", + d: "3", + "talk:e": "4", + }); - const prefixed = prefixStorage(storage as any, "talk"); + const prefixed = prefixStorage(storage, "talk:"); + expect(prefixed.key(0)).toBe("talk:c"); + expect(prefixed.key(1)).toBe("talk:e"); + expect(prefixed.key(2)).toBeNull(); +}); + +it("should call clear", () => { + const storage = createInMemoryStorage({ + a: "0", + b: "1", + "talk:c": "2", + d: "3", + "talk:e": "4", + }); + + const prefixed = prefixStorage(storage, "talk:"); prefixed.clear(); - storage.clear.verify(); + expect(storage.toString()).toMatchSnapshot(); }); it("should call length", () => { - const ret = 10; - const storage = { - get length() { - return ret; - }, - }; + const storage = createInMemoryStorage({ + a: "0", + b: "1", + "talk:c": "2", + d: "3", + "talk:e": "4", + }); - const prefixed = prefixStorage(storage as any, "talk"); - expect(prefixed.length).toBe(ret); -}); - -it("should call key", () => { - const ret = "value"; - const storage = { - key: sinon - .mock() - .withArgs(3) - .returns(ret), - }; - - const prefixed = prefixStorage(storage as any, "talk"); - expect(prefixed.key(3)).toBe(ret); - (storage.key as any).verify(); -}); - -it("should call key", () => { - const ret = "value"; - const storage = { - key: sinon - .mock() - .withArgs(3) - .returns(ret), - }; - - const prefixed = prefixStorage(storage as any, "talk"); - expect(prefixed.key(3)).toBe(ret); - (storage.key as any).verify(); + const prefixed = prefixStorage(storage, "talk:"); + expect(prefixed.length).toBe(2); }); it("should prefix setItem", () => { @@ -56,7 +49,7 @@ it("should prefix setItem", () => { setItem: sinon.mock().withArgs("talk:key", "value"), }; - const prefixed = prefixStorage(storage as any, "talk"); + const prefixed = prefixStorage(storage as any, "talk:"); prefixed.setItem("key", "value"); storage.setItem.verify(); }); @@ -66,7 +59,7 @@ it("should prefix removeItem", () => { removeItem: sinon.mock().withArgs("talk:key"), }; - const prefixed = prefixStorage(storage as any, "talk"); + const prefixed = prefixStorage(storage as any, "talk:"); prefixed.removeItem("key"); storage.removeItem.verify(); }); @@ -80,7 +73,7 @@ it("should prefix getItem", () => { .returns(ret), }; - const prefixed = prefixStorage(storage as any, "talk"); + const prefixed = prefixStorage(storage as any, "talk:"); expect(prefixed.getItem("key")).toBe(ret); (storage.getItem as any).verify(); }); diff --git a/src/core/client/framework/lib/storage/prefixStorage.ts b/src/core/client/framework/lib/storage/prefixStorage.ts index c44f7c364..9e360ecec 100644 --- a/src/core/client/framework/lib/storage/prefixStorage.ts +++ b/src/core/client/framework/lib/storage/prefixStorage.ts @@ -12,27 +12,50 @@ class PrefixedStorage implements Storage { } get length() { - return this.storage.length; + let count = 0; + for (let i = 0; i < this.storage.length; i++) { + if (this.storage.key(i)!.startsWith(this.prefix)) { + count++; + } + } + return count; } public clear() { - this.storage.clear(); + const toBeDeleted = []; + for (let i = 0; i < this.storage.length; i++) { + const key = this.storage.key(i)!; + if (key.startsWith(this.prefix)) { + toBeDeleted.push(key); + } + } + toBeDeleted.forEach(key => this.storage.removeItem(key)); } public key(n: number) { - return this.storage.key(n); + let count = 0; + for (let i = 0; i < this.storage.length; i++) { + const key = this.storage.key(i)!; + if (key.startsWith(this.prefix)) { + if (count === n) { + return key; + } + count++; + } + } + return null; } public getItem(key: string) { - return this.storage.getItem(`${this.prefix}:${key}`); + return this.storage.getItem(`${this.prefix}${key}`); } public setItem(key: string, value: string) { - return this.storage.setItem(`${this.prefix}:${key}`, value); + return this.storage.setItem(`${this.prefix}${key}`, value); } public removeItem(key: string) { - return this.storage.removeItem(`${this.prefix}:${key}`); + return this.storage.removeItem(`${this.prefix}${key}`); } } diff --git a/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts b/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts index 587ddc2d5..887e6fd84 100644 --- a/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts +++ b/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts @@ -1,6 +1,5 @@ -import { commitLocalUpdate, Environment, RecordSource } from "relay-runtime"; +import { Environment, RecordSource } from "relay-runtime"; -import { timeout } from "talk-common/utils"; import { LOCAL_ID } from "talk-framework/lib/relay"; import { createInMemoryStorage } from "talk-framework/lib/storage"; import { createRelayEnvironment } from "talk-framework/testHelpers"; @@ -16,7 +15,7 @@ beforeAll(() => { }); }); -it("Sets auth token", async () => { +it("Sets auth token to localStorage", () => { const context = { localStorage: createInMemoryStorage(), }; @@ -26,26 +25,11 @@ it("Sets auth token", async () => { expect(context.localStorage.getItem("authToken")).toEqual(authToken); }); -it("Removes auth token from localStorage", async () => { +it("Removes auth token from localStorage", () => { const context = { localStorage: createInMemoryStorage(), }; localStorage.setItem("authToken", "tmp"); commit(environment, { authToken: null }, context as any); - expect(context.localStorage.getItem("authToken")).toBeUndefined(); -}); - -it("Should call gc", async () => { - const context = { - localStorage: createInMemoryStorage(), - }; - commitLocalUpdate(environment, store => { - store.create("should-disappear", "tmp"); - }); - const authToken = null; - expect(source.get("should-disappear")).not.toBeUndefined(); - commit(environment, { authToken }, context as any); - await timeout(); - expect(source.get(LOCAL_ID)!.authToken).toEqual(authToken); - expect(source.get("should-disappear")).toBeUndefined(); + expect(context.localStorage.getItem("authToken")).toBeNull(); }); diff --git a/src/core/client/framework/testHelpers/createFakePymStorage.ts b/src/core/client/framework/testHelpers/createFakePymStorage.ts deleted file mode 100644 index ad8510dcd..000000000 --- a/src/core/client/framework/testHelpers/createFakePymStorage.ts +++ /dev/null @@ -1,21 +0,0 @@ -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/createFluentBundle.ts b/src/core/client/framework/testHelpers/createFluentBundle.ts index a937b2b76..d524576e4 100644 --- a/src/core/client/framework/testHelpers/createFluentBundle.ts +++ b/src/core/client/framework/testHelpers/createFluentBundle.ts @@ -5,6 +5,31 @@ import path from "path"; // These locale prefixes are always loaded. const commonPrefixes = ["common", "framework"]; +function decorateErrorWhenMissing(bundle: FluentBundle) { + const originalHasMessage = bundle.hasMessage; + const originalGetMessage = bundle.getMessage; + const missing: string[] = []; + bundle.hasMessage = (id: string) => { + const result = originalHasMessage.apply(bundle, [id]); + if (!result) { + const msg = `${bundle.locales} translation for key "${id}" not found`; + // tslint:disable-next-line:no-console + console.error(msg); + missing.push(id); + } + // Even if it is missing, we say it is available and later return a descriptive error + // string as the translation. + return true; + }; + bundle.getMessage = (id: string) => { + if (missing.indexOf(id) !== -1) { + return `Missing translation "${id}"`; + } + return originalGetMessage.apply(bundle, [id]); + }; + return bundle; +} + function createFluentBundle( target: string, pathToLocale: string @@ -15,13 +40,11 @@ function createFluentBundle( files.forEach(f => { prefixes.forEach(prefix => { if (f.startsWith(prefix)) { - bundle.addMessages( - fs.readFileSync(path.resolve(pathToLocale, f)).toString() - ); + bundle.addMessages(require(path.resolve(pathToLocale, f))); } }); }); - return bundle; + return decorateErrorWhenMissing(bundle); } export default createFluentBundle; diff --git a/src/core/client/framework/testHelpers/createUUIDGenerator.ts b/src/core/client/framework/testHelpers/createUUIDGenerator.ts new file mode 100644 index 000000000..c654f4116 --- /dev/null +++ b/src/core/client/framework/testHelpers/createUUIDGenerator.ts @@ -0,0 +1,4 @@ +export default function createUUIDGenerator() { + let counter = 0; + return () => `uuid-${counter++}`; +} diff --git a/src/core/client/framework/testHelpers/index.ts b/src/core/client/framework/testHelpers/index.ts index 69e9c5cd0..811e1ba19 100644 --- a/src/core/client/framework/testHelpers/index.ts +++ b/src/core/client/framework/testHelpers/index.ts @@ -4,8 +4,8 @@ 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, } from "./removeFragmentRefs"; +export { default as createUUIDGenerator } from "./createUUIDGenerator"; 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/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/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/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/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 new file mode 100644 index 000000000..cf8ddd25b --- /dev/null +++ b/src/core/client/stream/components/Comment/ReplyButton.tsx @@ -0,0 +1,29 @@ +import { Localized } from "fluent-react/compat"; +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 => ( + +); + +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/Comment/__snapshots__/Comment.spec.tsx.snap b/src/core/client/stream/components/Comment/__snapshots__/Comment.spec.tsx.snap index 02ad016ad..246444f0d 100644 --- a/src/core/client/stream/components/Comment/__snapshots__/Comment.spec.tsx.snap +++ b/src/core/client/stream/components/Comment/__snapshots__/Comment.spec.tsx.snap @@ -5,7 +5,9 @@ exports[`renders username and body 1`] = ` className="Comment-root" role="article" > - + Marvin @@ -16,12 +18,10 @@ exports[`renders username and body 1`] = ` Woof -
- -
+ direction="row" + itemGutter="half" + /> `; diff --git a/src/core/client/stream/components/Comment/__snapshots__/IndentedComment.spec.tsx.snap b/src/core/client/stream/components/Comment/__snapshots__/IndentedComment.spec.tsx.snap new file mode 100644 index 000000000..ae2d56dee --- /dev/null +++ b/src/core/client/stream/components/Comment/__snapshots__/IndentedComment.spec.tsx.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` + + + +`; diff --git a/src/core/client/stream/components/Comment/__snapshots__/ReplyButton.spec.tsx.snap b/src/core/client/stream/components/Comment/__snapshots__/ReplyButton.spec.tsx.snap new file mode 100644 index 000000000..ffa459ad3 --- /dev/null +++ b/src/core/client/stream/components/Comment/__snapshots__/ReplyButton.spec.tsx.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` + + + + reply + + + + + Reply + + + +`; diff --git a/src/core/client/stream/components/Comment/__snapshots__/TopBar.spec.tsx.snap b/src/core/client/stream/components/Comment/__snapshots__/TopBar.spec.tsx.snap index 9ce1f79c5..bda33e1dd 100644 --- a/src/core/client/stream/components/Comment/__snapshots__/TopBar.spec.tsx.snap +++ b/src/core/client/stream/components/Comment/__snapshots__/TopBar.spec.tsx.snap @@ -2,7 +2,7 @@ exports[`renders correctly on big screens 1`] = `
Hello World @@ -12,7 +12,7 @@ exports[`renders correctly on big screens 1`] = ` exports[`renders correctly on small screens 1`] = `
Hello World 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/PermalinkView.tsx b/src/core/client/stream/components/PermalinkView.tsx index 1747152d7..ceb58f0d1 100644 --- a/src/core/client/stream/components/PermalinkView.tsx +++ b/src/core/client/stream/components/PermalinkView.tsx @@ -8,7 +8,9 @@ import CommentContainer from "../containers/CommentContainer"; import * as styles from "./PermalinkView.css"; export interface PermalinkViewProps { - comment: PropTypesOf["data"] | null; + me: PropTypesOf["me"]; + asset: PropTypesOf["asset"]; + comment: PropTypesOf["comment"] | null; showAllCommentsHref: string | null; onShowAllComments: (e: MouseEvent) => void; } @@ -16,7 +18,9 @@ export interface PermalinkViewProps { const PermalinkView: StatelessComponent = ({ showAllCommentsHref, comment, + asset, onShowAllComments, + me, }) => { return (
@@ -42,7 +46,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/PostCommentForm.tsx b/src/core/client/stream/components/PostCommentForm.tsx index 09d4a1ba6..5772271e6 100644 --- a/src/core/client/stream/components/PostCommentForm.tsx +++ b/src/core/client/stream/components/PostCommentForm.tsx @@ -29,7 +29,7 @@ export interface PostCommentFormProps { const PostCommentForm: StatelessComponent = props => (
- {({ handleSubmit, submitting }) => ( + {({ handleSubmit, submitting, hasValidationErrors }) => ( = props => ( onChange={({ html }) => input.onChange(html)} value={input.value} placeholder="Post a comment" + disabled={submitting} /> {meta.touched && @@ -79,7 +80,7 @@ const PostCommentForm: StatelessComponent = props => (
diff --git a/src/core/client/stream/components/ReplyCommentForm.tsx b/src/core/client/stream/components/ReplyCommentForm.tsx new file mode 100644 index 000000000..cc18c6382 --- /dev/null +++ b/src/core/client/stream/components/ReplyCommentForm.tsx @@ -0,0 +1,109 @@ +import { CoralRTE } from "@coralproject/rte"; +import { FormState } from "final-form"; +import { Localized } from "fluent-react/compat"; +import React, { + EventHandler, + MouseEvent, + Ref, + 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 { + id: string; + className?: string; + onSubmit: OnSubmit; + onCancel?: EventHandler>; + onChange?: (state: FormState) => void; + initialValues?: FormProps; + rteRef?: Ref; +} + +const ReplyCommentForm: StatelessComponent = props => { + const inputID = `comments-replyCommentForm-rte-${props.id}`; + return ( + + {({ handleSubmit, submitting, hasValidationErrors }) => ( + + + + + {({ input, meta }) => ( +
+ + + Write a reply + + + + input.onChange(html)} + value={input.value} + placeholder="Write a reply" + forwardRef={props.rteRef} + disabled={submitting} + /> + + {meta.touched && + (meta.error || meta.submitError) && ( + + {meta.error || meta.submitError} + + )} +
+ )} +
+ + + + + + + + +
+ + )} + + ); +}; + +export default ReplyCommentForm; diff --git a/src/core/client/stream/components/ReplyList.spec.tsx b/src/core/client/stream/components/ReplyList.spec.tsx index 04038673f..b978b0955 100644 --- a/src/core/client/stream/components/ReplyList.spec.tsx +++ b/src/core/client/stream/components/ReplyList.spec.tsx @@ -12,11 +12,14 @@ const ReplyListN = removeFragmentRefs(ReplyList); it("renders correctly", () => { const props: PropTypesOf = { - commentID: "comment-id", + asset: { id: "asset-id" }, + comment: { id: "comment-id" }, comments: [{ id: "comment-1" }, { id: "comment-2" }], onShowAll: noop, hasMore: false, disableShowAll: false, + indentLevel: 1, + me: null, }; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); @@ -24,11 +27,14 @@ it("renders correctly", () => { describe("when there is more", () => { const props: PropTypesOf = { - commentID: "comment-id", + asset: { id: "asset-id" }, + comment: { id: "comment-id" }, comments: [{ id: "comment-1" }, { id: "comment-2" }], onShowAll: sinon.spy(), hasMore: true, disableShowAll: false, + indentLevel: 1, + me: null, }; const wrapper = shallow(); diff --git a/src/core/client/stream/components/ReplyList.tsx b/src/core/client/stream/components/ReplyList.tsx index cd2be4660..47e0dbc39 100644 --- a/src/core/client/stream/components/ReplyList.tsx +++ b/src/core/client/stream/components/ReplyList.tsx @@ -9,30 +9,41 @@ import CommentContainer from "../containers/CommentContainer"; import Indent from "./Indent"; export interface ReplyListProps { - commentID: string; + asset: PropTypesOf["asset"]; + me: PropTypesOf["me"]; + comment: { + id: string; + }; comments: ReadonlyArray< - { id: string } & PropTypesOf["data"] + { id: string } & PropTypesOf["comment"] >; 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/Stream.spec.tsx b/src/core/client/stream/components/Stream.spec.tsx index ff327d798..6a717731c 100644 --- a/src/core/client/stream/components/Stream.spec.tsx +++ b/src/core/client/stream/components/Stream.spec.tsx @@ -12,13 +12,15 @@ const StreamN = removeFragmentRefs(Stream); it("renders correctly", () => { const props: PropTypesOf = { - assetID: "asset-id", - isClosed: false, + asset: { + id: "asset-id", + isClosed: false, + }, comments: [{ id: "comment-1" }, { id: "comment-2" }], onLoadMore: noop, disableLoadMore: false, hasMore: false, - user: null, + me: null, }; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); @@ -27,13 +29,15 @@ it("renders correctly", () => { describe("when use is logged in", () => { it("renders correctly", () => { const props: PropTypesOf = { - assetID: "asset-id", - isClosed: false, + asset: { + id: "asset-id", + isClosed: false, + }, comments: [{ id: "comment-1" }, { id: "comment-2" }], onLoadMore: noop, disableLoadMore: false, hasMore: false, - user: {}, + me: {}, }; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); @@ -42,13 +46,15 @@ describe("when use is logged in", () => { describe("when there is more", () => { const props: PropTypesOf = { - assetID: "asset-id", - isClosed: false, + asset: { + id: "asset-id", + isClosed: false, + }, comments: [{ id: "comment-1" }, { id: "comment-2" }], onLoadMore: sinon.spy(), disableLoadMore: false, hasMore: true, - user: null, + me: null, }; const wrapper = shallow(); diff --git a/src/core/client/stream/components/Stream.tsx b/src/core/client/stream/components/Stream.tsx index 2d1358f64..1bed822ea 100644 --- a/src/core/client/stream/components/Stream.tsx +++ b/src/core/client/stream/components/Stream.tsx @@ -13,25 +13,32 @@ import PostCommentFormFake from "./PostCommentFormFake"; import * as styles from "./Stream.css"; export interface StreamProps { - assetID: string; - isClosed?: boolean; + asset: { + id: string; + isClosed?: boolean; + } & PropTypesOf["asset"] & + PropTypesOf["asset"]; comments: ReadonlyArray< - { id: string } & PropTypesOf["data"] & + { id: string } & PropTypesOf["comment"] & PropTypesOf["comment"] >; onLoadMore?: () => void; hasMore?: boolean; disableLoadMore?: boolean; - user: PropTypesOf["user"] | null; + me: + | PropTypesOf["me"] & + PropTypesOf["me"] & + PropTypesOf["me"] + | null; } const Stream: StatelessComponent = props => { return ( - - {props.user ? ( - + + {props.me ? ( + ) : ( )} @@ -43,8 +50,16 @@ const Stream: StatelessComponent = props => { > {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__/RTE.spec.tsx.snap b/src/core/client/stream/components/__snapshots__/RTE.spec.tsx.snap index cc39de98a..322439aab 100644 --- a/src/core/client/stream/components/__snapshots__/RTE.spec.tsx.snap +++ b/src/core/client/stream/components/__snapshots__/RTE.spec.tsx.snap @@ -18,7 +18,9 @@ exports[`renders correctly 1`] = ` id="comments-rte-bold" > - + format_bold @@ -32,7 +34,9 @@ exports[`renders correctly 1`] = ` id="comments-rte-italic" > - + format_italic @@ -46,7 +50,9 @@ exports[`renders correctly 1`] = ` id="comments-rte-blockquote" > - + format_quote 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 8e8929149..3473f9fe0 100644 --- a/src/core/client/stream/components/__snapshots__/ReplyList.spec.tsx.snap +++ b/src/core/client/stream/components/__snapshots__/ReplyList.spec.tsx.snap @@ -1,53 +1,82 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`renders correctly 1`] = ` - - - + - - - + } + indentLevel={1} + key="comment-1" + me={null} + /> + + `; exports[`when there is more disables load more button 1`] = ` - - + + + - - @@ -62,32 +91,49 @@ exports[`when there is more disables load more button 1`] = ` Show All Replies - - + + `; exports[`when there is more renders a load more button 1`] = ` - - + + + - - @@ -102,6 +148,6 @@ exports[`when there is more renders a load more button 1`] = ` Show All Replies - - + + `; 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 b022bd1e3..50588e6bb 100644 --- a/src/core/client/stream/components/__snapshots__/Stream.spec.tsx.snap +++ b/src/core/client/stream/components/__snapshots__/Stream.spec.tsx.snap @@ -9,7 +9,7 @@ exports[`renders correctly 1`] = ` size="half" > @@ -21,37 +21,65 @@ exports[`renders correctly 1`] = ` - - + - - + @@ -67,7 +95,7 @@ exports[`when there is more disables load more button 1`] = ` size="half" > @@ -79,37 +107,65 @@ exports[`when there is more disables load more button 1`] = ` - - + - - + @@ -151,37 +207,65 @@ exports[`when there is more renders a load more button 1`] = ` - - + - - + - - + - - + diff --git a/src/core/client/stream/containers/CommentContainer.spec.tsx b/src/core/client/stream/containers/CommentContainer.spec.tsx index 892957994..d43c241af 100644 --- a/src/core/client/stream/containers/CommentContainer.spec.tsx +++ b/src/core/client/stream/containers/CommentContainer.spec.tsx @@ -1,4 +1,5 @@ import { shallow } from "enzyme"; +import { noop } from "lodash"; import React from "react"; import { removeFragmentRefs } from "talk-framework/testHelpers"; @@ -11,7 +12,11 @@ const CommentContainerN = removeFragmentRefs(CommentContainer); it("renders username and body", () => { const props: PropTypesOf = { - data: { + me: null, + asset: { + id: "asset-id", + }, + comment: { id: "comment-id", author: { username: "Marvin", @@ -19,6 +24,8 @@ it("renders username and body", () => { body: "Woof", createdAt: "1995-12-17T03:24:00.000Z", }, + indentLevel: 1, + showAuthPopup: noop as any, }; const wrapper = shallow(); @@ -27,7 +34,11 @@ it("renders username and body", () => { it("renders body only", () => { const props: PropTypesOf = { - data: { + me: null, + asset: { + id: "asset-id", + }, + comment: { id: "comment-id", author: { username: null, @@ -35,6 +46,8 @@ it("renders body only", () => { body: "Woof", createdAt: "1995-12-17T03:24:00.000Z", }, + indentLevel: 1, + showAuthPopup: noop as any, }; const wrapper = shallow(); diff --git a/src/core/client/stream/containers/CommentContainer.tsx b/src/core/client/stream/containers/CommentContainer.tsx index 4c0a97744..21b4b767e 100644 --- a/src/core/client/stream/containers/CommentContainer.tsx +++ b/src/core/client/stream/containers/CommentContainer.tsx @@ -1,40 +1,110 @@ -import React, { StatelessComponent } from "react"; +import React, { Component } from "react"; import { graphql } from "react-relay"; import withFragmentContainer from "talk-framework/lib/relay/withFragmentContainer"; import { PropTypesOf } from "talk-framework/types"; -import { CommentContainer as Data } from "talk-stream/__generated__/CommentContainer.graphql"; +import { CommentContainer_asset as AssetData } from "talk-stream/__generated__/CommentContainer_asset.graphql"; +import { CommentContainer_comment as CommentData } from "talk-stream/__generated__/CommentContainer_comment.graphql"; +import { CommentContainer_me as MeData } from "talk-stream/__generated__/CommentContainer_me.graphql"; +import { + ShowAuthPopupMutation, + withShowAuthPopupMutation, +} from "talk-stream/mutations"; import Comment from "../components/Comment"; +import ReplyButton from "../components/Comment/ReplyButton"; +import ReplyCommentFormContainer from ".//ReplyCommentFormContainer"; +import PermalinkButtonContainer from "./PermalinkButtonContainer"; interface InnerProps { - data: Data; + me: MeData | null; + comment: CommentData; + asset: AssetData; + indentLevel?: number; + showAuthPopup: ShowAuthPopupMutation; } -// tslint:disable-next-line:no-unused-expression -graphql` - fragment CommentContainer_comment on Comment { - id - author { - username +interface State { + showReplyDialog: boolean; +} + +export class CommentContainer extends Component { + public state = { + showReplyDialog: false, + }; + + private openReplyDialog = () => { + if (this.props.me) { + this.setState(state => ({ + showReplyDialog: true, + })); + } else { + this.props.showAuthPopup({ view: "SIGN_IN" }); } - body - createdAt + }; + + private closeReplyDialog = () => { + this.setState(state => ({ + showReplyDialog: false, + })); + }; + + public render() { + const { comment, asset, ...rest } = this.props; + const { showReplyDialog } = this.state; + return ( + <> + + + + + } + /> + {showReplyDialog && ( + + )} + + ); } -`; +} -export const CommentContainer: StatelessComponent = props => { - const { data, ...rest } = props; - return ; -}; - -const enhanced = withFragmentContainer({ - data: graphql` - fragment CommentContainer on Comment { - ...CommentContainer_comment @relay(mask: false) - } - `, -})(CommentContainer); +const enhanced = withShowAuthPopupMutation( + withFragmentContainer({ + me: graphql` + fragment CommentContainer_me on User { + __typename + } + `, + asset: graphql` + fragment CommentContainer_asset on Asset { + ...ReplyCommentFormContainer_asset + } + `, + comment: graphql` + fragment CommentContainer_comment on Comment { + id + author { + username + } + body + createdAt + ...ReplyCommentFormContainer_comment + } + `, + })(CommentContainer) +); export type CommentContainerProps = PropTypesOf; export default enhanced; diff --git a/src/core/client/stream/containers/PermalinkViewContainer.tsx b/src/core/client/stream/containers/PermalinkViewContainer.tsx index 29e08c61e..664168e36 100644 --- a/src/core/client/stream/containers/PermalinkViewContainer.tsx +++ b/src/core/client/stream/containers/PermalinkViewContainer.tsx @@ -5,7 +5,9 @@ import { graphql } from "react-relay"; import { withContext } from "talk-framework/lib/bootstrap"; import { withFragmentContainer } from "talk-framework/lib/relay"; import { buildURL, parseURL } from "talk-framework/utils"; +import { PermalinkViewContainer_asset as AssetData } from "talk-stream/__generated__/PermalinkViewContainer_asset.graphql"; import { PermalinkViewContainer_comment as CommentData } from "talk-stream/__generated__/PermalinkViewContainer_comment.graphql"; +import { PermalinkViewContainer_me as MeData } from "talk-stream/__generated__/PermalinkViewContainer_me.graphql"; import { SetCommentIDMutation, withSetCommentIDMutation, @@ -15,6 +17,8 @@ import PermalinkView from "../components/PermalinkView"; interface PermalinkViewContainerProps { comment: CommentData | null; + asset: AssetData; + me: MeData | null; setCommentID: SetCommentIDMutation; pym: PymChild | undefined; } @@ -37,9 +41,11 @@ class PermalinkViewContainer extends React.Component< return buildURL({ ...urlParts, search }); } public render() { - const { comment } = this.props; + const { comment, asset, me } = this.props; return ( ({ }))( withSetCommentIDMutation( withFragmentContainer({ + asset: graphql` + fragment PermalinkViewContainer_asset on Asset { + ...CommentContainer_asset + } + `, comment: graphql` fragment PermalinkViewContainer_comment on Comment { - ...CommentContainer + ...CommentContainer_comment + } + `, + me: graphql` + fragment PermalinkViewContainer_me on User { + ...CommentContainer_me } `, })(PermalinkViewContainer) diff --git a/src/core/client/stream/containers/PostCommentFormContainer.spec.tsx b/src/core/client/stream/containers/PostCommentFormContainer.spec.tsx index 53b104b3a..ff72be551 100644 --- a/src/core/client/stream/containers/PostCommentFormContainer.spec.tsx +++ b/src/core/client/stream/containers/PostCommentFormContainer.spec.tsx @@ -3,10 +3,9 @@ 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 { createPromisifiedStorage } from "talk-framework/lib/storage"; +import { PropTypesOf } from "talk-framework/types"; import { PostCommentFormContainer } from "./PostCommentFormContainer"; const contextKey = "postCommentFormBody"; @@ -16,7 +15,7 @@ it("renders correctly", async () => { // tslint:disable-next-line:no-empty createComment: (() => {}) as any, assetID: "asset-id", - pymSessionStorage: createFakePymStorage(), + sessionStorage: createPromisifiedStorage(), }; const wrapper = shallow(); @@ -30,10 +29,10 @@ it("renders with initialValues", async () => { // tslint:disable-next-line:no-empty createComment: (() => {}) as any, assetID: "asset-id", - pymSessionStorage: createFakePymStorage(), + sessionStorage: createPromisifiedStorage(), }; - await props.pymSessionStorage.setItem(contextKey, "Hello World!"); + await props.sessionStorage.setItem(contextKey, "Hello World!"); const wrapper = shallow(); await timeout(); @@ -46,10 +45,10 @@ it("save values", async () => { // tslint:disable-next-line:no-empty createComment: (() => {}) as any, assetID: "asset-id", - pymSessionStorage: createFakePymStorage(), + sessionStorage: createPromisifiedStorage(), }; - await props.pymSessionStorage.setItem(contextKey, "Hello World!"); + await props.sessionStorage.setItem(contextKey, "Hello World!"); const wrapper = shallow(); await timeout(); @@ -58,7 +57,7 @@ it("save values", async () => { .first() .props() .onChange({ values: { body: "changed" } }); - expect(await props.pymSessionStorage.getItem(contextKey)).toBe("changed"); + expect(await props.sessionStorage.getItem(contextKey)).toBe("changed"); }); it("creates a comment", async () => { @@ -76,10 +75,10 @@ it("creates a comment", async () => { // tslint:disable-next-line:no-empty createComment: createCommentStub, assetID, - pymSessionStorage: createFakePymStorage(), + sessionStorage: createPromisifiedStorage(), }; - await props.pymSessionStorage.setItem(contextKey, "Hello World!"); + await props.sessionStorage.setItem(contextKey, "Hello World!"); const wrapper = shallow(); await timeout(); diff --git a/src/core/client/stream/containers/PostCommentFormContainer.tsx b/src/core/client/stream/containers/PostCommentFormContainer.tsx index c862ce8ad..882a3a07c 100644 --- a/src/core/client/stream/containers/PostCommentFormContainer.tsx +++ b/src/core/client/stream/containers/PostCommentFormContainer.tsx @@ -2,7 +2,7 @@ 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 { PromisifiedStorage } from "talk-framework/lib/storage"; import { PropTypesOf } from "talk-framework/types"; import PostCommentForm, { @@ -13,7 +13,7 @@ import { CreateCommentMutation, withCreateCommentMutation } from "../mutations"; interface InnerProps { createComment: CreateCommentMutation; assetID: string; - pymSessionStorage: PymStorage; + sessionStorage: PromisifiedStorage; } interface State { @@ -32,7 +32,7 @@ export class PostCommentFormContainer extends Component { } private async init() { - const body = await this.props.pymSessionStorage.getItem(contextKey); + const body = await this.props.sessionStorage.getItem(contextKey); if (body) { this.setState({ initialValues: { @@ -67,9 +67,9 @@ export class PostCommentFormContainer extends Component { private handleOnChange: PostCommentFormProps["onChange"] = state => { if (state.values.body) { - this.props.pymSessionStorage.setItem(contextKey, state.values.body); + this.props.sessionStorage.setItem(contextKey, state.values.body); } else { - this.props.pymSessionStorage.removeItem(contextKey); + this.props.sessionStorage.removeItem(contextKey); } }; @@ -87,8 +87,8 @@ export class PostCommentFormContainer extends Component { } } -const enhanced = withContext(({ pymSessionStorage }) => ({ - pymSessionStorage, +const enhanced = withContext(({ sessionStorage }) => ({ + sessionStorage, }))(withCreateCommentMutation(PostCommentFormContainer)); export type PostCommentFormContainerProps = PropTypesOf; export default enhanced; 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..e189de00d --- /dev/null +++ b/src/core/client/stream/containers/ReplyCommentFormContainer.spec.tsx @@ -0,0 +1,193 @@ +import { shallow } from "enzyme"; +import { noop } from "lodash"; +import React from "react"; +import sinon from "sinon"; + +import { timeout } from "talk-common/utils"; +import { createPromisifiedStorage } from "talk-framework/lib/storage"; +import { removeFragmentRefs } from "talk-framework/testHelpers"; +import { PropTypesOf } from "talk-framework/types"; +import { ReplyCommentFormContainer } from "./ReplyCommentFormContainer"; + +const ReplyCommentFormContainerN = removeFragmentRefs( + ReplyCommentFormContainer +); + +function getContextKey(commentID: string) { + return `replyCommentFormBody-${commentID}`; +} + +it("renders correctly", async () => { + const props: PropTypesOf = { + createComment: noop as any, + asset: { + id: "asset-id", + }, + comment: { + id: "comment-id", + }, + sessionStorage: createPromisifiedStorage(), + autofocus: false, + }; + + const wrapper = shallow(); + await timeout(); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); +}); + +it("renders with initialValues", async () => { + const props: PropTypesOf = { + createComment: noop as any, + asset: { + id: "asset-id", + }, + comment: { + id: "comment-id", + }, + sessionStorage: createPromisifiedStorage(), + autofocus: false, + }; + + await props.sessionStorage.setItem( + getContextKey(props.comment.id), + "Hello World!" + ); + + const wrapper = shallow(); + await timeout(); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); +}); + +it("save values", async () => { + const props: PropTypesOf = { + createComment: noop as any, + asset: { + id: "asset-id", + }, + comment: { + id: "comment-id", + }, + sessionStorage: createPromisifiedStorage(), + autofocus: false, + }; + + await props.sessionStorage.setItem( + getContextKey(props.comment.id), + "Hello World!" + ); + + const wrapper = shallow(); + await timeout(); + wrapper.update(); + wrapper + .first() + .props() + .onChange({ values: { body: "changed" } }); + expect( + await props.sessionStorage.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 = { + createComment: createCommentStub, + asset: { + id: "asset-id", + }, + comment: { + id: "comment-id", + }, + sessionStorage: createPromisifiedStorage(), + onClose: onCloseStub, + autofocus: false, + }; + + await props.sessionStorage.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 = { + createComment: noop as any, + asset: { + id: "asset-id", + }, + comment: { + id: "comment-id", + }, + sessionStorage: createPromisifiedStorage(), + onClose: onCloseStub, + autofocus: false, + }; + + await props.sessionStorage.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.sessionStorage.getItem(getContextKey(props.comment.id)) + ).toBeNull(); +}); + +it("autofocuses", async () => { + const focusStub = sinon.stub(); + const rte = { focus: focusStub }; + const props: PropTypesOf = { + createComment: noop as any, + asset: { + id: "asset-id", + }, + comment: { + id: "comment-id", + }, + sessionStorage: createPromisifiedStorage(), + autofocus: true, + }; + + const wrapper = shallow(); + await timeout(); + wrapper.update(); + wrapper + .findWhere(n => n.prop("rteRef")) + .props() + .rteRef(rte); + expect(focusStub.calledOnce).toBe(true); +}); diff --git a/src/core/client/stream/containers/ReplyCommentFormContainer.tsx b/src/core/client/stream/containers/ReplyCommentFormContainer.tsx new file mode 100644 index 000000000..3c0379e30 --- /dev/null +++ b/src/core/client/stream/containers/ReplyCommentFormContainer.tsx @@ -0,0 +1,138 @@ +import { CoralRTE } from "@coralproject/rte"; +import React, { Component } from "react"; +import { graphql } from "react-relay"; + +import { withContext } from "talk-framework/lib/bootstrap"; +import { BadUserInputError } from "talk-framework/lib/errors"; +import { withFragmentContainer } from "talk-framework/lib/relay"; +import { PromisifiedStorage } from "talk-framework/lib/storage"; +import { PropTypesOf } from "talk-framework/types"; +import { ReplyCommentFormContainer_asset as AssetData } from "talk-stream/__generated__/ReplyCommentFormContainer_asset.graphql"; +import { ReplyCommentFormContainer_comment as CommentData } from "talk-stream/__generated__/ReplyCommentFormContainer_comment.graphql"; + +import ReplyCommentForm, { + ReplyCommentFormProps, +} from "../components/ReplyCommentForm"; +import { CreateCommentMutation, withCreateCommentMutation } from "../mutations"; + +interface InnerProps { + createComment: CreateCommentMutation; + sessionStorage: PromisifiedStorage; + comment: CommentData; + asset: AssetData; + onClose?: () => void; + autofocus: boolean; +} + +interface State { + initialValues?: ReplyCommentFormProps["initialValues"]; + initialized: boolean; +} + +export class ReplyCommentFormContainer extends Component { + public state: State = { initialized: false }; + private contextKey = `replyCommentFormBody-${this.props.comment.id}`; + + constructor(props: InnerProps) { + super(props); + this.init(); + } + + private handleRTERef = (rte: CoralRTE | null) => { + if (rte && this.props.autofocus) { + rte.focus(); + } + }; + + private async init() { + const body = await this.props.sessionStorage.getItem(this.contextKey); + if (body) { + this.setState({ + initialValues: { + body, + }, + }); + } + this.setState({ + initialized: true, + }); + } + + private handleOnCancel = () => { + this.props.sessionStorage.removeItem(this.contextKey); + if (this.props.onClose) { + this.props.onClose(); + } + }; + + private handleOnSubmit: ReplyCommentFormProps["onSubmit"] = async ( + input, + form + ) => { + try { + await this.props.createComment({ + assetID: this.props.asset.id, + parentID: this.props.comment.id, + ...input, + }); + + this.props.sessionStorage.removeItem(this.contextKey); + if (this.props.onClose) { + this.props.onClose(); + } + } catch (error) { + if (error instanceof BadUserInputError) { + return error.invalidArgsLocalized; + } + // tslint:disable-next-line:no-console + console.error(error); + } + return undefined; + }; + + private handleOnChange: ReplyCommentFormProps["onChange"] = state => { + if (state.values.body) { + this.props.sessionStorage.setItem(this.contextKey, state.values.body); + } else { + this.props.sessionStorage.removeItem(this.contextKey); + } + }; + + public render() { + if (!this.state.initialized) { + return null; + } + return ( + + ); + } +} +const enhanced = withContext(({ sessionStorage, browserInfo }) => ({ + sessionStorage, + // Disable autofocus on ios and enable for the rest. + autofocus: !browserInfo.ios, +}))( + withCreateCommentMutation( + withFragmentContainer({ + asset: graphql` + fragment ReplyCommentFormContainer_asset on Asset { + id + } + `, + comment: graphql` + fragment ReplyCommentFormContainer_comment on Comment { + id + } + `, + })(ReplyCommentFormContainer) + ) +); +export type PostCommentFormContainerProps = PropTypesOf; +export default enhanced; diff --git a/src/core/client/stream/containers/ReplyListContainer.spec.tsx b/src/core/client/stream/containers/ReplyListContainer.spec.tsx index 9096f3614..a23402ce1 100644 --- a/src/core/client/stream/containers/ReplyListContainer.spec.tsx +++ b/src/core/client/stream/containers/ReplyListContainer.spec.tsx @@ -13,6 +13,9 @@ const ReplyListContainerN = removeFragmentRefs(ReplyListContainer); it("renders correctly", () => { const props: PropTypesOf = { + asset: { + id: "asset-id", + }, comment: { id: "comment-id", replies: { @@ -23,6 +26,7 @@ it("renders correctly", () => { hasMore: noop, isLoading: noop, } as any, + me: null, }; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); @@ -30,6 +34,9 @@ it("renders correctly", () => { it("renders correctly when replies are null", () => { const props: PropTypesOf = { + asset: { + id: "asset-id", + }, comment: { id: "comment-id", replies: null, @@ -38,6 +45,7 @@ it("renders correctly when replies are null", () => { hasMore: noop, isLoading: noop, } as any, + me: null, }; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); @@ -46,6 +54,9 @@ it("renders correctly when replies are null", () => { describe("when has more replies", () => { let finishLoading: ((error?: Error) => void) | null = null; const props: PropTypesOf = { + asset: { + id: "asset-id", + }, comment: { id: "comment-id", replies: { @@ -57,6 +68,7 @@ describe("when has more replies", () => { isLoading: () => false, loadMore: (_: any, callback: () => void) => (finishLoading = callback), } as any, + me: null, }; let wrapper: ShallowWrapper; diff --git a/src/core/client/stream/containers/ReplyListContainer.tsx b/src/core/client/stream/containers/ReplyListContainer.tsx index de741c60d..65c6b9ec9 100644 --- a/src/core/client/stream/containers/ReplyListContainer.tsx +++ b/src/core/client/stream/containers/ReplyListContainer.tsx @@ -3,7 +3,9 @@ import { graphql, RelayPaginationProp } from "react-relay"; import { withPaginationContainer } from "talk-framework/lib/relay"; import { PropTypesOf } from "talk-framework/types"; -import { ReplyListContainer_comment as Data } from "talk-stream/__generated__/ReplyListContainer_comment.graphql"; +import { ReplyListContainer_asset as AssetData } from "talk-stream/__generated__/ReplyListContainer_asset.graphql"; +import { ReplyListContainer_comment as CommentData } from "talk-stream/__generated__/ReplyListContainer_comment.graphql"; +import { ReplyListContainer_me as MeData } from "talk-stream/__generated__/ReplyListContainer_me.graphql"; import { COMMENT_SORT, ReplyListContainerPaginationQueryVariables, @@ -12,7 +14,9 @@ import { import ReplyList from "../components/ReplyList"; export interface InnerProps { - comment: Data; + me: MeData | null; + asset: AssetData; + comment: CommentData; relay: RelayPaginationProp; } @@ -31,11 +35,14 @@ export class ReplyListContainer extends React.Component { const comments = this.props.comment.replies.edges.map(edge => edge.node); return ( ); } @@ -72,6 +79,16 @@ const enhanced = withPaginationContainer< FragmentVariables >( { + me: graphql` + fragment ReplyListContainer_me on User { + ...CommentContainer_me + } + `, + asset: graphql` + fragment ReplyListContainer_asset on Asset { + ...CommentContainer_asset + } + `, comment: graphql` fragment ReplyListContainer_comment on Comment @argumentDefinitions( @@ -85,7 +102,7 @@ const enhanced = withPaginationContainer< edges { node { id - ...CommentContainer + ...CommentContainer_comment } } } diff --git a/src/core/client/stream/containers/StreamContainer.spec.tsx b/src/core/client/stream/containers/StreamContainer.spec.tsx index 531c74970..3d3a51841 100644 --- a/src/core/client/stream/containers/StreamContainer.spec.tsx +++ b/src/core/client/stream/containers/StreamContainer.spec.tsx @@ -20,7 +20,7 @@ it("renders correctly", () => { edges: [{ node: { id: "comment-1" } }, { node: { id: "comment-2" } }], }, }, - user: null, + me: null, relay: { hasMore: noop, isLoading: noop, @@ -40,7 +40,7 @@ describe("when has more comments", () => { edges: [{ node: { id: "comment-1" } }, { node: { id: "comment-2" } }], }, }, - user: null, + me: null, relay: { hasMore: () => true, isLoading: () => false, diff --git a/src/core/client/stream/containers/StreamContainer.tsx b/src/core/client/stream/containers/StreamContainer.tsx index ff24de3fb..980d737b2 100644 --- a/src/core/client/stream/containers/StreamContainer.tsx +++ b/src/core/client/stream/containers/StreamContainer.tsx @@ -4,7 +4,7 @@ import { graphql, RelayPaginationProp } from "react-relay"; import { withPaginationContainer } from "talk-framework/lib/relay"; import { PropTypesOf } from "talk-framework/types"; import { StreamContainer_asset as AssetData } from "talk-stream/__generated__/StreamContainer_asset.graphql"; -import { StreamContainer_user as UserData } from "talk-stream/__generated__/StreamContainer_user.graphql"; +import { StreamContainer_me as MeData } from "talk-stream/__generated__/StreamContainer_me.graphql"; import { COMMENT_SORT, StreamContainerPaginationQueryVariables, @@ -14,10 +14,19 @@ import Stream from "../components/Stream"; interface InnerProps { asset: AssetData; - user: UserData | null; + me: MeData | null; relay: RelayPaginationProp; } +// tslint:disable-next-line:no-unused-expression +graphql` + fragment StreamContainer_comment on Comment { + id + ...CommentContainer_comment + ...ReplyListContainer_comment + } +`; + export class StreamContainer extends React.Component { public state = { disableLoadMore: false, @@ -27,13 +36,12 @@ export class StreamContainer extends React.Component { const comments = this.props.asset.comments.edges.map(edge => edge.node); return ( ); } @@ -82,17 +90,19 @@ const enhanced = withPaginationContainer< @connection(key: "Stream_comments") { edges { node { - id - ...CommentContainer - ...ReplyListContainer_comment + ...StreamContainer_comment @relay(mask: false) } } } + ...CommentContainer_asset + ...ReplyListContainer_asset } `, - user: graphql` - fragment StreamContainer_user on User { - ...UserBoxContainer_user + me: graphql` + fragment StreamContainer_me on User { + ...ReplyListContainer_me + ...CommentContainer_me + ...UserBoxContainer_me } `, }, diff --git a/src/core/client/stream/containers/UserBoxContainer.spec.tsx b/src/core/client/stream/containers/UserBoxContainer.spec.tsx index f99832b8e..7439e50b1 100644 --- a/src/core/client/stream/containers/UserBoxContainer.spec.tsx +++ b/src/core/client/stream/containers/UserBoxContainer.spec.tsx @@ -18,7 +18,7 @@ it("renders correctly", () => { view: "SIGN_IN", }, }, - user: null, + me: null, // tslint:disable-next-line:no-empty showAuthPopup: async () => {}, // tslint:disable-next-line:no-empty diff --git a/src/core/client/stream/containers/UserBoxContainer.tsx b/src/core/client/stream/containers/UserBoxContainer.tsx index 6a390c63b..ac10dfa35 100644 --- a/src/core/client/stream/containers/UserBoxContainer.tsx +++ b/src/core/client/stream/containers/UserBoxContainer.tsx @@ -7,7 +7,7 @@ import { withLocalStateContainer, } from "talk-framework/lib/relay"; import { SignOutMutation, withSignOutMutation } from "talk-framework/mutations"; -import { UserBoxContainer_user as UserData } from "talk-stream/__generated__/UserBoxContainer_user.graphql"; +import { UserBoxContainer_me as MeData } from "talk-stream/__generated__/UserBoxContainer_me.graphql"; import { UserBoxContainerLocal as Local } from "talk-stream/__generated__/UserBoxContainerLocal.graphql"; import UserBoxUnauthenticated from "talk-stream/components/UserBoxUnauthenticated"; import { @@ -22,7 +22,7 @@ import UserBoxAuthenticated from "../components/UserBoxAuthenticated"; interface InnerProps { local: Local; - user: UserData | null; + me: MeData | null; showAuthPopup: ShowAuthPopupMutation; setAuthPopupState: SetAuthPopupStateMutation; signOut: SignOutMutation; @@ -40,16 +40,16 @@ export class UserBoxContainer extends Component { local: { authPopup: { open, focus, view }, }, - user, + me, signOut, } = this.props; - if (user) { + if (me) { return ( ); } @@ -90,8 +90,8 @@ const enhanced = withSignOutMutation( ` )( withFragmentContainer({ - user: graphql` - fragment UserBoxContainer_user on User { + me: graphql` + fragment UserBoxContainer_me on User { username } `, 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 fdf0b47f0..4883c80e1 100644 --- a/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap +++ b/src/core/client/stream/containers/__snapshots__/CommentContainer.spec.tsx.snap @@ -1,27 +1,61 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`renders body only 1`] = ` - + + body="Woof" + createdAt="1995-12-17T03:24:00.000Z" + footer={ + + + + + } + id="comment-id" + indentLevel={1} + me={null} + showAuthPopup={[Function]} + /> + `; exports[`renders username and body 1`] = ` - + + body="Woof" + createdAt="1995-12-17T03:24:00.000Z" + footer={ + + + + + } + id="comment-id" + indentLevel={1} + me={null} + showAuthPopup={[Function]} + /> + `; diff --git a/src/core/client/stream/containers/__snapshots__/ReplyCommentFormContainer.spec.tsx.snap b/src/core/client/stream/containers/__snapshots__/ReplyCommentFormContainer.spec.tsx.snap new file mode 100644 index 000000000..6f87c3a0a --- /dev/null +++ b/src/core/client/stream/containers/__snapshots__/ReplyCommentFormContainer.spec.tsx.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders correctly 1`] = ` + +`; + +exports[`renders with initialValues 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 52aa9c57b..e17a56740 100644 --- a/src/core/client/stream/containers/__snapshots__/ReplyListContainer.spec.tsx.snap +++ b/src/core/client/stream/containers/__snapshots__/ReplyListContainer.spec.tsx.snap @@ -2,7 +2,30 @@ exports[`renders correctly 1`] = ` `; @@ -22,7 +47,30 @@ exports[`renders correctly when replies are null 1`] = `""`; exports[`when has more replies renders hasMore 1`] = ` `; exports[`when has more replies when showing all disables show all button 1`] = ` `; exports[`when has more replies when showing all enable show all button after loading is done 1`] = ` `; diff --git a/src/core/client/stream/containers/__snapshots__/StreamContainer.spec.tsx.snap b/src/core/client/stream/containers/__snapshots__/StreamContainer.spec.tsx.snap index e24710a2b..d29f00fdf 100644 --- a/src/core/client/stream/containers/__snapshots__/StreamContainer.spec.tsx.snap +++ b/src/core/client/stream/containers/__snapshots__/StreamContainer.spec.tsx.snap @@ -2,7 +2,26 @@ exports[`renders correctly 1`] = ` `; exports[`when has more comments renders hasMore 1`] = ` `; exports[`when has more comments when loading more disables load more button 1`] = ` `; exports[`when has more comments when loading more enable load more button after loading is done 1`] = ` `; diff --git a/src/core/client/stream/local/initLocalState.spec.ts b/src/core/client/stream/local/initLocalState.spec.ts index 49f090742..584c78559 100644 --- a/src/core/client/stream/local/initLocalState.spec.ts +++ b/src/core/client/stream/local/initLocalState.spec.ts @@ -1,8 +1,9 @@ import { Environment, RecordSource } from "relay-runtime"; import { timeout } from "talk-common/utils"; +import { TalkContext } from "talk-framework/lib/bootstrap"; import { LOCAL_ID } from "talk-framework/lib/relay"; -import { createInMemoryStorage } from "talk-framework/lib/storage"; +import { createPromisifiedStorage } from "talk-framework/lib/storage"; import { createRelayEnvironment } from "talk-framework/testHelpers"; import initLocalState from "./initLocalState"; @@ -19,14 +20,18 @@ beforeEach(() => { }); it("init local state", async () => { - await initLocalState(environment, { - localStorage: createInMemoryStorage(), - } as any); + const context: Partial = { + localStorage: createPromisifiedStorage(), + }; + await initLocalState(environment, context as any); await timeout(); expect(JSON.stringify(source.toJSON(), null, 2)).toMatchSnapshot(); }); it("set assetID from query", async () => { + const context: Partial = { + localStorage: createPromisifiedStorage(), + }; const assetID = "asset-id"; const previousLocation = location.toString(); const previousState = window.history.state; @@ -35,14 +40,15 @@ it("set assetID from query", async () => { document.title, `http://localhost/?assetID=${assetID}` ); - await initLocalState(environment, { - localStorage: createInMemoryStorage(), - } as any); + await initLocalState(environment, context as any); expect(source.get(LOCAL_ID)!.assetID).toBe(assetID); window.history.replaceState(previousState, document.title, previousLocation); }); it("set commentID from query", async () => { + const context: Partial = { + localStorage: createPromisifiedStorage(), + }; const commentID = "comment-id"; const previousLocation = location.toString(); const previousState = window.history.state; @@ -51,18 +57,17 @@ it("set commentID from query", async () => { document.title, `http://localhost/?commentID=${commentID}` ); - await initLocalState(environment, { - localStorage: createInMemoryStorage(), - } as any); + await initLocalState(environment, context as any); expect(source.get(LOCAL_ID)!.commentID).toBe(commentID); window.history.replaceState(previousState, document.title, previousLocation); }); it("set authToken from localStorage", async () => { + const context: Partial = { + localStorage: createPromisifiedStorage(), + }; const authToken = "auth-token"; - const localStorage = createInMemoryStorage(); - localStorage.setItem("authToken", authToken); - await initLocalState(environment, { localStorage } as any); + context.localStorage!.setItem("authToken", authToken); + await initLocalState(environment, context as any); expect(source.get(LOCAL_ID)!.authToken).toBe(authToken); - localStorage.removeItem("authToken"); }); diff --git a/src/core/client/stream/local/initLocalState.ts b/src/core/client/stream/local/initLocalState.ts index 81b32a742..2d953e6ae 100644 --- a/src/core/client/stream/local/initLocalState.ts +++ b/src/core/client/stream/local/initLocalState.ts @@ -22,7 +22,7 @@ export default async function initLocalState( environment: Environment, { localStorage }: TalkContext ) { - const authToken = await localStorage.getItem("authToken"); + const authToken = await localStorage!.getItem("authToken"); commitLocalUpdate(environment, s => { // TODO: (cvle) move local, auth token and network initialization to framework. diff --git a/src/core/client/stream/mutations/CreateCommentMutation.ts b/src/core/client/stream/mutations/CreateCommentMutation.ts index 5d6d04556..a293a58e7 100644 --- a/src/core/client/stream/mutations/CreateCommentMutation.ts +++ b/src/core/client/stream/mutations/CreateCommentMutation.ts @@ -1,8 +1,8 @@ import { graphql } from "react-relay"; -import { Environment } from "relay-runtime"; -import uuid from "uuid/v4"; +import { Environment, RelayMutationConfig } from "relay-runtime"; import { getMe } from "talk-framework/helpers"; +import { TalkContext } from "talk-framework/lib/bootstrap"; import { commitMutationPromiseNormalized, createMutationContainer, @@ -22,13 +22,7 @@ const mutation = graphql` edge { cursor node { - id - author { - id - username - } - body - createdAt + ...StreamContainer_comment @relay(mask: false) } } clientMutationId @@ -38,7 +32,44 @@ const mutation = graphql` let clientMutationId = 0; -function commit(environment: Environment, input: CreateCommentInput) { +function getConfig(input: CreateCommentInput): RelayMutationConfig[] { + if (!input.parentID) { + return [ + { + type: "RANGE_ADD", + connectionInfo: [ + { + key: "Stream_comments", + rangeBehavior: "prepend", + filters: { orderBy: "CREATED_AT_DESC" }, + }, + ], + parentID: input.assetID, + edgeName: "edge", + }, + ]; + } + return [ + { + type: "RANGE_ADD", + connectionInfo: [ + { + key: "ReplyList_replies", + rangeBehavior: "append", + filters: { orderBy: "CREATED_AT_ASC" }, + }, + ], + parentID: input.parentID, + edgeName: "edge", + }, + ]; +} + +function commit( + environment: Environment, + input: CreateCommentInput, + { uuidGenerator }: TalkContext +) { const me = getMe(environment)!; const currentDate = new Date().toISOString(); return commitMutationPromiseNormalized(environment, { @@ -54,7 +85,7 @@ function commit(environment: Environment, input: CreateCommentInput) { edge: { cursor: currentDate, node: { - id: uuid(), + id: uuidGenerator(), createdAt: currentDate, author: { id: me.id, @@ -65,21 +96,8 @@ function commit(environment: Environment, input: CreateCommentInput) { }, clientMutationId: (clientMutationId++).toString(), }, - }, - configs: [ - { - type: "RANGE_ADD", - connectionInfo: [ - { - key: "Stream_comments", - rangeBehavior: "prepend", - filters: { orderBy: "CREATED_AT_DESC" }, - }, - ], - parentID: input.assetID, - edgeName: "edge", - }, - ], + } as any, // TODO: (cvle) generated types should contain one for the optimistic response. + configs: getConfig(input), }); } diff --git a/src/core/client/stream/queries/PermalinkViewQuery.spec.tsx b/src/core/client/stream/queries/PermalinkViewQuery.spec.tsx index b29ed668e..01b91218a 100644 --- a/src/core/client/stream/queries/PermalinkViewQuery.spec.tsx +++ b/src/core/client/stream/queries/PermalinkViewQuery.spec.tsx @@ -6,6 +6,7 @@ import { render } from "./PermalinkViewQuery"; it("renders permalink view container", () => { const data = { props: { + asset: {}, comment: {}, } as any, error: null, diff --git a/src/core/client/stream/queries/PermalinkViewQuery.tsx b/src/core/client/stream/queries/PermalinkViewQuery.tsx index 32943ea17..fc098dafe 100644 --- a/src/core/client/stream/queries/PermalinkViewQuery.tsx +++ b/src/core/client/stream/queries/PermalinkViewQuery.tsx @@ -1,3 +1,4 @@ +import { Localized } from "fluent-react/compat"; import * as React from "react"; import { StatelessComponent } from "react"; import { ReadyState } from "react-relay"; @@ -25,24 +26,52 @@ export const render = ({ return
{error.message}
; } if (props) { - return ; + if (!props.asset) { + return ( + +
Asset not found
+
+ ); + } + return ( + + ); } return ; }; const PermalinkViewQuery: StatelessComponent = ({ - local: { commentID }, + local: { commentID, assetID, authRevision }, }) => ( query={graphql` - query PermalinkViewQuery($commentID: ID!) { + query PermalinkViewQuery( + $commentID: ID! + $assetID: ID! + $authRevision: Int! + ) { + # authRevision is increment every time auth state has changed. + # This is basically a cache invalidation and causes relay + # to automatically update this query. + me(clientAuthRevision: $authRevision) { + ...PermalinkViewContainer_me + } + asset(id: $assetID) { + ...PermalinkViewContainer_asset + } comment(id: $commentID) { ...PermalinkViewContainer_comment } } `} variables={{ + assetID: assetID!, commentID: commentID!, + authRevision, }} render={render} /> @@ -51,6 +80,8 @@ const PermalinkViewQuery: StatelessComponent = ({ const enhanced = withLocalStateContainer( graphql` fragment PermalinkViewQueryLocal on Local { + assetID + authRevision commentID } ` diff --git a/src/core/client/stream/queries/StreamQuery.tsx b/src/core/client/stream/queries/StreamQuery.tsx index 67a5ae012..b0f1f0c3f 100644 --- a/src/core/client/stream/queries/StreamQuery.tsx +++ b/src/core/client/stream/queries/StreamQuery.tsx @@ -31,7 +31,7 @@ export const render = ({
); } - return ; + return ; } return ; @@ -43,14 +43,14 @@ const StreamQuery: StatelessComponent = ({ query={graphql` query StreamQuery($assetID: ID!, $authRevision: Int!) { - asset(id: $assetID) { - ...StreamContainer_asset - } # authRevision is increment every time auth state has changed. # This is basically a cache invalidation and causes relay # to automatically update this query. me(clientAuthRevision: $authRevision) { - ...StreamContainer_user + ...StreamContainer_me + } + asset(id: $assetID) { + ...StreamContainer_asset } } `} diff --git a/src/core/client/stream/queries/__snapshots__/PermalinkViewQuery.spec.tsx.snap b/src/core/client/stream/queries/__snapshots__/PermalinkViewQuery.spec.tsx.snap index b7d371bd9..c25801800 100644 --- a/src/core/client/stream/queries/__snapshots__/PermalinkViewQuery.spec.tsx.snap +++ b/src/core/client/stream/queries/__snapshots__/PermalinkViewQuery.spec.tsx.snap @@ -10,6 +10,7 @@ exports[`renders loading 1`] = ``; exports[`renders permalink view container 1`] = ` `; diff --git a/src/core/client/stream/shared/htmlContent.css b/src/core/client/stream/shared/htmlContent.css index a987f36d7..542fa9a54 100644 --- a/src/core/client/stream/shared/htmlContent.css +++ b/src/core/client/stream/shared/htmlContent.css @@ -1,5 +1,6 @@ .root { composes: bodyCopy from "talk-ui/shared/typography.css"; + overflow-wrap: break-word; & * bold, & * strong { 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 82c6ceccb..2b6612877 100644 --- a/src/core/client/stream/test/__snapshots__/loadMore.spec.tsx.snap +++ b/src/core/client/stream/test/__snapshots__/loadMore.spec.tsx.snap @@ -62,7 +62,7 @@ exports[`loads more comments 1`] = ` > @@ -76,7 +76,7 @@ exports[`loads more comments 1`] = ` > @@ -90,7 +90,7 @@ exports[`loads more comments 1`] = ` > @@ -153,7 +153,7 @@ exports[`loads more comments 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" + > + +
@@ -320,7 +371,7 @@ exports[`renders comment stream 1`] = ` > @@ -334,7 +385,7 @@ exports[`renders comment stream 1`] = ` > @@ -348,7 +399,7 @@ exports[`renders comment stream 1`] = ` > @@ -411,7 +462,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 +138,7 @@ exports[`show all comments 1`] = ` > @@ -135,7 +152,7 @@ exports[`show all comments 1`] = ` > @@ -149,7 +166,7 @@ exports[`show all comments 1`] = ` > @@ -212,7 +229,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..475a485da 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..f4dae1d35 100644 --- a/src/core/client/stream/test/__snapshots__/postComment.spec.tsx.snap +++ b/src/core/client/stream/test/__snapshots__/postComment.spec.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`post a comment 1`] = ` +exports[`post a comment: optimistic response 1`] = `
@@ -70,7 +70,7 @@ exports[`post a comment 1`] = ` className="" >
Hello world!", } } + disabled={true} id="comments-postCommentForm-field" onBlur={[Function]} onChange={[Function]} @@ -185,7 +186,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" + > + +
@@ -290,7 +342,7 @@ exports[`post a comment 1`] = ` `; -exports[`post a comment 2`] = ` +exports[`post a comment: server response 1`] = `
@@ -371,7 +423,7 @@ exports[`post a comment 2`] = ` > @@ -385,7 +437,7 @@ exports[`post a comment 2`] = ` > @@ -399,7 +451,7 @@ exports[`post a comment 2`] = ` > @@ -420,6 +472,7 @@ exports[`post a comment 2`] = ` "__html": "", } } + disabled={false} id="comments-postCommentForm-field" onBlur={[Function]} onChange={[Function]} @@ -451,8 +504,8 @@ exports[`post a comment 2`] = `
+
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
@@ -667,7 +771,7 @@ exports[`renders comment stream 1`] = ` > @@ -681,7 +785,7 @@ exports[`renders comment stream 1`] = ` > @@ -695,7 +799,7 @@ exports[`renders comment stream 1`] = ` > @@ -716,6 +820,7 @@ exports[`renders comment stream 1`] = ` "__html": "", } } + disabled={false} id="comments-postCommentForm-field" onBlur={[Function]} onChange={[Function]} @@ -747,8 +852,8 @@ exports[`renders comment stream 1`] = ` +
+ className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow" + > + +
diff --git a/src/core/client/stream/test/__snapshots__/postReply.spec.tsx.snap b/src/core/client/stream/test/__snapshots__/postReply.spec.tsx.snap new file mode 100644 index 000000000..0e6c5d219 --- /dev/null +++ b/src/core/client/stream/test/__snapshots__/postReply.spec.tsx.snap @@ -0,0 +1,1547 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`post a reply: open reply form 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+ + Markus + + +
+
+
+ +
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+ + +
+
+ +
+
+
+
+ + Lukas + + +
+
+
+ +
+
+
+
+
+
+`; + +exports[`post a reply: optimistic response 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+ + Markus + + +
+
+
+ +
+
+
+
+
+ +
+
+
+ + + +
+
Hello world!", + } + } + disabled={true} + 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: server response 1`] = ` +
+
+
+
+
+
+ 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 56c8f0963..1673675ef 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..b93b5d5f8 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..235810d26 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/stream/test/create.tsx b/src/core/client/stream/test/create.tsx index 5b3378b05..49d885c66 100644 --- a/src/core/client/stream/test/create.tsx +++ b/src/core/client/stream/test/create.tsx @@ -6,8 +6,8 @@ import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime"; import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap"; import { PostMessageService } from "talk-framework/lib/postMessage"; import { RestClient } from "talk-framework/lib/rest"; -import { createInMemoryStorage } from "talk-framework/lib/storage"; -import { createFakePymStorage } from "talk-framework/testHelpers"; +import { createPromisifiedStorage } from "talk-framework/lib/storage"; +import { createUUIDGenerator } from "talk-framework/testHelpers"; import AppContainer from "talk-stream/containers/AppContainer"; import createEnvironment from "./createEnvironment"; @@ -40,12 +40,12 @@ export default function create(params: CreateParams) { const context: TalkContext = { relayEnvironment: environment, localeBundles: [createFluentBundle()], - localStorage: createInMemoryStorage(), - sessionStorage: createInMemoryStorage(), - pymLocalStorage: createFakePymStorage(), - pymSessionStorage: createFakePymStorage(), + localStorage: createPromisifiedStorage(), + sessionStorage: createPromisifiedStorage(), rest: new RestClient("http://localhost/api"), postMessage: new PostMessageService(), + browserInfo: { ios: false }, + uuidGenerator: createUUIDGenerator(), }; const testRenderer = TestRenderer.create( diff --git a/src/core/client/stream/test/createNodeMock.ts b/src/core/client/stream/test/createNodeMock.ts index 27bbf4736..0676c5043 100644 --- a/src/core/client/stream/test/createNodeMock.ts +++ b/src/core/client/stream/test/createNodeMock.ts @@ -1,9 +1,11 @@ +import { noop } from "lodash"; import { ReactElement } from "react"; export default function createNodeMock(element: ReactElement) { if (element.type === "div") { return { innerHtml: "", + focus: noop, }; } return null; diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index 8913b67c7..b4e207978 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -19,18 +19,21 @@ export const comments = [ author: users[0], body: "Joining Too", createdAt: "2018-07-06T18:24:00.000Z", + replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } }, }, { id: "comment-1", author: users[1], body: "What's up?", createdAt: "2018-07-06T18:20:00.000Z", + replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } }, }, { id: "comment-2", author: users[2], body: "Hey!", createdAt: "2018-07-06T18:14:00.000Z", + replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } }, }, ]; diff --git a/src/core/client/stream/test/postComment.spec.tsx b/src/core/client/stream/test/postComment.spec.tsx index 1260e545e..658a9d036 100644 --- a/src/core/client/stream/test/postComment.spec.tsx +++ b/src/core/client/stream/test/postComment.spec.tsx @@ -35,7 +35,7 @@ beforeEach(() => { .returns({ // TODO: add a type assertion here to ensure that if the type changes, that the test will fail edge: { - cursor: "2018-07-06T18:24:00.000Z", + cursor: null, node: { id: "comment-x", author: users[0], @@ -81,11 +81,11 @@ it("post a comment", async () => { timekeeper.reset(); // Test optimistic response. - expect(testRenderer.toJSON()).toMatchSnapshot(); + expect(testRenderer.toJSON()).toMatchSnapshot("optimistic response"); // Wait for loading. await timeout(); // Test after server response. - expect(testRenderer.toJSON()).toMatchSnapshot(); + expect(testRenderer.toJSON()).toMatchSnapshot("server response"); }); diff --git a/src/core/client/stream/test/postReply.spec.tsx b/src/core/client/stream/test/postReply.spec.tsx new file mode 100644 index 000000000..3c64bc098 --- /dev/null +++ b/src/core/client/stream/test/postReply.spec.tsx @@ -0,0 +1,102 @@ +import { ReactTestRenderer } from "react-test-renderer"; +import timekeeper from "timekeeper"; + +import { timeout } from "talk-common/utils"; +import { createSinonStub } from "talk-framework/testHelpers"; + +import create from "./create"; +import { assets, users } from "./fixtures"; + +let testRenderer: ReactTestRenderer; +beforeEach(() => { + const resolvers = { + Query: { + asset: createSinonStub( + s => s.throws(), + s => s.withArgs(undefined, { id: assets[0].id }).returns(assets[0]) + ), + me: createSinonStub( + s => s.throws(), + s => s.withArgs(undefined, { clientAuthRevision: 0 }).returns(users[0]) + ), + }, + Mutation: { + createComment: createSinonStub( + s => s.throws(), + s => + s + .withArgs(undefined, { + input: { + assetID: assets[0].id, + parentID: assets[0].comments.edges[0].node.id, + body: "Hello world!", + clientMutationId: "0", + }, + }) + .returns({ + edge: { + cursor: null, + node: { + id: "comment-x", + author: users[0], + body: "Hello world! (from server)", + createdAt: "2018-07-06T18:24:00.000Z", + replies: { + edges: [], + pageInfo: { endCursor: null, hasNextPage: false }, + }, + }, + }, + clientMutationId: "0", + }) + ), + }, + }; + + ({ testRenderer } = create({ + // Set this to true, to see graphql responses. + logNetwork: false, + resolvers, + initLocalState: localRecord => { + localRecord.setValue(assets[0].id, "assetID"); + }, + })); +}); + +it("renders comment stream", async () => { + // Wait for loading. + await timeout(); + expect(testRenderer.toJSON()).toMatchSnapshot(); +}); + +it("post a reply", async () => { + // Wait for loading. + await timeout(); + + // Open reply form. + testRenderer.root + .findByProps({ id: "comments-commentContainer-replyButton-comment-0" }) + .props.onClick(); + + await timeout(); + expect(testRenderer.toJSON()).toMatchSnapshot("open reply form"); + + // Write reply . + testRenderer.root + .findByProps({ inputId: "comments-replyCommentForm-rte-comment-0" }) + .props.onChange({ html: "Hello world!" }); + + timekeeper.freeze(new Date("2018-07-06T18:24:00.000Z")); + testRenderer.root + .findByProps({ id: "comments-replyCommentForm-form-comment-0" }) + .props.onSubmit(); + // Test optimistic response. + expect(testRenderer.toJSON()).toMatchSnapshot("optimistic response"); + timekeeper.reset(); + + // Wait for loading. + await timeout(); + + // Test after server response. + expect(testRenderer.toJSON()).toMatchSnapshot("server response"); +}); diff --git a/src/core/client/test/mocks.ts b/src/core/client/test/mocks.ts new file mode 100644 index 000000000..06308436e --- /dev/null +++ b/src/core/client/test/mocks.ts @@ -0,0 +1,22 @@ +// TODO: Remove when fixed. +// Mock React.createContext because of https://github.com/airbnb/enzyme/issues/1509. +function mockReact() { + const originalReact = require.requireActual("react"); + return { + ...originalReact, + createContext: jest.fn(defaultValue => { + let value = defaultValue; + const Provider = (props: any) => { + value = props.value; + return props.children; + }; + const Consumer = (props: any) => props.children(value); + return { + Provider, + Consumer, + }; + }), + }; +} + +jest.mock("react", () => mockReact()); diff --git a/src/core/client/test/setup.ts b/src/core/client/test/setup.ts index de1ba3c31..b818ce265 100644 --- a/src/core/client/test/setup.ts +++ b/src/core/client/test/setup.ts @@ -1,25 +1,4 @@ import "jest-localstorage-mock"; import "./enzyme"; import "./jsdom"; - -// TODO: Remove when fixed. -// Mock React.createContext because of https://github.com/airbnb/enzyme/issues/1509. -function mockReact() { - const originalReact = require.requireActual("react"); - return { - ...originalReact, - createContext: jest.fn(defaultValue => { - let value = defaultValue; - const Provider = (props: any) => { - value = props.value; - return props.children; - }; - const Consumer = (props: any) => props.children(value); - return { - Provider, - Consumer, - }; - }), - }; -} -jest.mock("react", () => mockReact()); +import "./mocks"; diff --git a/src/core/client/tsconfig.json b/src/core/client/tsconfig.json index ce10a53a0..38ecfa50a 100644 --- a/src/core/client/tsconfig.json +++ b/src/core/client/tsconfig.json @@ -13,6 +13,7 @@ "talk-stream/*": ["./stream/*"], "talk-framework/*": ["./framework/*"], "talk-ui/*": ["./ui/*"], + "talk-test/*": ["./test/*"], "talk-common/*": ["../common/*"] } }, 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/core/server/graph/tenant/resolvers/mutation.ts b/src/core/server/graph/tenant/resolvers/mutation.ts index 8f96079ff..5b7c65dc4 100644 --- a/src/core/server/graph/tenant/resolvers/mutation.ts +++ b/src/core/server/graph/tenant/resolvers/mutation.ts @@ -9,8 +9,11 @@ const Mutation: GQLMutationTypeResolver = { const comment = await ctx.mutators.Comment.create(input); return { edge: { - // FIXME: (wyattjoh) when we're using a replies/respect sort, it is index based instead of date based, needs some work! - cursor: comment.created_at, + // (cvle) + // Depending on the sort we can't determine the accurate cursor + // in a performant way, so we return null instead. + // It seems that Relay does not directly use this value... + cursor: null, node: comment, }, clientMutationId: input.clientMutationId, diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 79d9a73a8..dc689f403 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -679,7 +679,7 @@ type CommentEdge { """ """ - cursor: Cursor! + cursor: Cursor } """ diff --git a/src/docs/workarounds.mdx b/src/docs/workarounds.mdx index f511e64c8..e9a0b684c 100644 --- a/src/docs/workarounds.mdx +++ b/src/docs/workarounds.mdx @@ -12,16 +12,6 @@ Babel versions are currently locked to 7.0.0-beta.49 because of this bug: https://github.com/babel/babel/issues/8167#issuecomment-397295483 -## Relay Typescript - -We are using a patched version of `Relay` that adds support for `Typescript`. Big thanks to [@alloy](https://github.com/alloy) for his fork on https://github.com/alloy/relay. Patched packages can be found in the top level `patches` folder. - -This is no longer needed once https://github.com/facebook/relay/pull/2293 has been merged and released. - -## relay-compiler-language-typescript - -We have patched [relay-compiler-language-typescript](https://github.com/relay-tools/relay-compiler-language-typescript) with an hack to fix an issue with `--noImplicitAny` support (https://github.com/relay-tools/relay-compiler-language-typescript/issues/48). Patched packages can be found in the top level `patches` folder. - ## Relay Client Side Schema Extensions We use Client Side Schema Extension in `Relay` to store client and UI related state. It works great, the only limitation currently is that locally created `Records` are garbage collected. We created a little helper in `talk-framework/lib/relay/createAndRetain.ts` that creates and retains these `Records` forever. Hopefully this gets resolved and we don't need to do this kind of manual lifecycle management. @@ -87,7 +77,7 @@ const enhanced = withFragmentContainer<{ data: Data }>({ fragment PermalinkViewContainerQuery on Query @argumentDefinitions(commentID: { type: "ID!" }) { comment(id: $commentID) { - ...CommentContainer + ...CommentContainer_comment } } `, diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index 009c52e99..29aac39c9 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -44,3 +44,13 @@ comments-postCommentForm-rte = comments-postCommentFormFake-rte = .placeholder = { comments-postCommentForm-rteLabel } + +comments-replyButton-reply = Reply + +comments-permalinkViewQuery-assetNotFound = { comments-streamQuery-assetNotFound } + +comments-replyCommentForm-submit = Submit +comments-replyCommentForm-cancel = Cancel +comments-replyCommentForm-rteLabel = Write a reply +comments-replyCommentForm-rte = + .placeholder = { comments-postCommentForm-rteLabel }