Merge branch 'next' of github.com:coralproject/talk into ui-tab

* 'next' of github.com:coralproject/talk: (26 commits)
  Adapt snapshots
  Disable RTE when submitting
  Wrap long words
  Disable submit button when empty
  Fix test
  Fix types and tests
  Remove outdated workarounds
  Remove accidently commited files
  Move uuid generation to TalkContext
  Full PromisifiedStorage + Simplifications
  Update package-lock
  Return null cursor when creating comment
  Change commentEdge to edge
  Update snapshots
  Fix types
  Reply opens auth popup when not logged in
  Better tests
  Stream should work outside of iframe for debugging
  Add test
  Focus RTE when opening reply
  ...
This commit is contained in:
Belén Curcio
2018-09-10 12:40:29 -03:00
127 changed files with 4779 additions and 869 deletions
+2 -1
View File
@@ -16,6 +16,7 @@ module.exports = {
transform: {
"^.+\\.tsx?$": "<rootDir>/node_modules/ts-jest",
"^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
"^.+\\.ftl$": "<rootDir>/config/jest/contentTransform.js",
"^(?!.*\\.(js|jsx|mjs|ts|tsx|css|json|ftl)$)":
"<rootDir>/config/jest/fileTransform.js",
},
@@ -30,7 +31,7 @@ module.exports = {
"^talk-framework/(.*)$": "<rootDir>/src/core/client/framework/$1",
"^talk-common/(.*)$": "<rootDir>/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": {
+15
View File
@@ -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()
)};`;
},
};
+3 -7
View File
@@ -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",
+2 -1
View File
@@ -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",
+57
View File
@@ -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<any, any>;
initLocalState?: (
local: RecordProxy,
source: RecordSourceProxy,
environment: Environment
) => void;
}
export default function create(params: CreateParams) {
const environment = createEnvironment({
// Set this to true, to see graphql responses.
logNetwork: params.logNetwork,
resolvers: params.resolvers,
initLocalState: (localRecord, source, env) => {
localRecord.setValue(0, "authRevision");
if (params.initLocalState) {
params.initLocalState(localRecord, source, env);
}
},
});
const context: TalkContext = {
relayEnvironment: environment,
localeBundles: [createFluentBundle()],
localStorage: createPromisifiedStorage(),
sessionStorage: createPromisifiedStorage(),
rest: new RestClient("http://localhost/api"),
postMessage: new PostMessageService(),
browserInfo: { ios: false },
uuidGenerator: createUUIDGenerator(),
};
const testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>
);
return { context, testRenderer };
}
+7 -29
View File
@@ -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(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>
);
return testRenderer;
}
it("renders sign in form", async () => {
+8 -31
View File
@@ -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(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>
);
}));
form = testRenderer.root.findByType("form");
});
+8 -32
View File
@@ -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(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>
);
}));
form = testRenderer.root.findByType("form");
});
@@ -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\\\\\\"}\\"}]"`;
@@ -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<string, string> = {};
public setItem(key: string, value: string) {
this.store[key] = value;
}
public removeItem(key: string) {
delete this.store[key];
}
public getItem(key: string) {
return this.store[key];
}
}
class PymStub {
public listeners: Record<string, ((msg: string) => void)> = {};
public messages: Array<{ key: string; value: string }> = [];
@@ -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();
});
});
@@ -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}`);
@@ -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();
});
@@ -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<string, string>;
constructor(data: Record<string, string> = {}) {
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<string, string>) {
return new InMemoryStorage(data);
}
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`accepts predefined data 1`] = `"{\\"a\\":\\"0\\",\\"b\\":\\"1\\",\\"c\\":\\"2\\"}"`;
+1
View File
@@ -0,0 +1 @@
export { default as createInMemoryStorage } from "./InMemoryStorage";
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`should call clear 1`] = `"{\\"a\\":\\"0\\",\\"b\\":\\"1\\",\\"d\\":\\"3\\"}"`;
+2
View File
@@ -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";
@@ -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();
});
@@ -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);
}
@@ -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);
});
@@ -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;
}
+3 -1
View File
@@ -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;
}
@@ -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<TalkContext>({} as any);
@@ -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<TalkContext> {
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.
@@ -0,0 +1,11 @@
import bowser from "bowser";
export interface BrowserInfo {
ios: boolean;
}
export function getBrowserInfo(): BrowserInfo {
return {
ios: bowser.ios,
};
}
@@ -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();
});
@@ -5,18 +5,18 @@
* https://developer.mozilla.org/en-US/docs/Web/API/Storage
*/
class InMemoryStorage implements Storage {
private storage: Record<string, string>;
private data: Record<string, string>;
constructor() {
this.storage = {};
constructor(data: Record<string, string> = {}) {
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<string, string>) {
return new InMemoryStorage(data);
}
@@ -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);
}
@@ -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");
});
@@ -0,0 +1,63 @@
import createInMemoryStorage from "./InMemoryStorage";
export interface PromisifiedStorage {
length: Promise<number>;
clear(): Promise<void>;
key(n: number): Promise<string | null>;
/**
* value = storage[key]
*/
getItem(key: string): Promise<string | null>;
/**
* delete storage[key]
*/
removeItem(key: string): Promise<void>;
/**
* storage[key] = value
*/
setItem(key: string, value: string): Promise<void>;
}
/**
* 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);
}
@@ -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");
@@ -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<string | null>;
/**
* delete storage[key]
*/
removeItem(key: string): Promise<void>;
/**
* storage[key] = value
*/
setItem(key: string, value: string): Promise<void>;
}
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<T>(
method: string,
parameters: { key: string; value?: string }
parameters: Record<string, any> = {}
): Promise<T> {
const id = uuid();
return new Promise((resolve, reject) => {
@@ -69,6 +55,15 @@ class PymStorageImpl implements PymStorage {
this.listen();
}
get length() {
return this.call<number>("length");
}
public key(n: number) {
return this.call<string | null>("key", { n });
}
public clear() {
return this.call<void>("clear");
}
public setItem(key: string, value: string) {
return this.call<void>("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);
}
@@ -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);
}
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`accepts predefined data 1`] = `"{\\"a\\":\\"0\\",\\"b\\":\\"1\\",\\"c\\":\\"2\\"}"`;
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`should call clear 1`] = `"{\\"a\\":\\"0\\",\\"b\\":\\"1\\",\\"d\\":\\"3\\"}"`;
@@ -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";
@@ -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();
});
@@ -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}`);
}
}
@@ -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();
});
@@ -1,21 +0,0 @@
import { PymStorage } from "talk-framework/lib/storage";
export class FakeStorage implements PymStorage {
public store: Record<string, string> = {};
public setItem(key: string, value: string) {
this.store[key] = value;
return Promise.resolve();
}
public removeItem(key: string) {
delete this.store[key];
return Promise.resolve();
}
public getItem(key: string) {
return Promise.resolve(this.store[key]);
}
}
export default function createFakePymStorage() {
return new FakeStorage();
}
@@ -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;
@@ -0,0 +1,4 @@
export default function createUUIDGenerator() {
let counter = 0;
return () => `uuid-${counter++}`;
}
@@ -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";
@@ -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<T> = Pick<
T,
{
[P in keyof T]: P extends " $fragmentRefs" | " $refType" ? never : P
}[keyof T]
>;
export type NoFragmentRefs<T> = T extends object
? {
[P in Exclude<keyof T, " $fragmentRefs" | " $refType">]: NoFragmentRefs<
T[P]
>
}
? T extends ((...args: any[]) => any)
? T
: T extends ReadonlyArray<infer U>
? ReadonlyArray<NoFragmentRefs2<U>> // TODO: (cvle) this should normally reference itself but it complains about a circular reference.
: { [P in keyof OmitFragments<T>]: NoFragmentRefs<T[P]> }
: T;
// TODO: (cvle) these NoFragmentRefX are a workaround for above issue
export type NoFragmentRefs2<T> = T extends object
? T extends ((...args: any[]) => any)
? T
: T extends ReadonlyArray<infer U>
? ReadonlyArray<NoFragmentRefs3<U>>
: { [P in keyof OmitFragments<T>]: NoFragmentRefs<T[P]> }
: T;
export type NoFragmentRefs3<T> = T extends object
? T extends ((...args: any[]) => any)
? T
: T extends ReadonlyArray<infer U>
? ReadonlyArray<NoFragmentRefs4<U>>
: { [P in keyof OmitFragments<T>]: NoFragmentRefs<T[P]> }
: T;
export type NoFragmentRefs4<T> = T extends object
? T extends ((...args: any[]) => any)
? T
: { [P in keyof OmitFragments<T>]: NoFragmentRefs<T[P]> }
: T;
export default function removeFragmentRefs<T>(
@@ -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);
}
@@ -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<any> | Array<ReactElement<any>>;
}
const Comment: StatelessComponent<CommentProps> = props => {
return (
<div role="article" className={styles.root}>
<TopBar>
<TopBar className={styles.topBar}>
{props.author &&
props.author.username && <Username>{props.author.username}</Username>}
<Timestamp>{props.createdAt}</Timestamp>
</TopBar>
<HTMLContent>{props.body || ""}</HTMLContent>
<div className={styles.footer}>
<PermalinkButtonContainer commentID={props.id} />
</div>
<Flex className={styles.footer} direction="row" itemGutter="half">
{props.footer}
</Flex>
</div>
);
};
@@ -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<typeof IndentedComment> = {
indentLevel: 1,
id: "comment-id",
author: {
username: "Marvin",
},
body: "Woof",
createdAt: "1995-12-17T03:24:00.000Z",
};
const wrapper = shallow(<IndentedComment {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -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<typeof Comment> {
indentLevel?: number;
}
const IndentedComment: StatelessComponent<IndentedCommentProps> = props => {
const { indentLevel, ...rest } = props;
const CommentElement = <Comment {...rest} />;
const CommentwithIndent =
(indentLevel && <Indent level={indentLevel}>{CommentElement}</Indent>) ||
CommentElement;
return CommentwithIndent;
};
export default IndentedComment;
@@ -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<typeof ReplyButton> = {
id: "id",
onClick: noop,
active: true,
};
const wrapper = shallow(<ReplyButton {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -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<MouseEvent<HTMLButtonElement>>;
active?: boolean;
}
const ReplyButton: StatelessComponent<Props> = props => (
<Button
id={props.id}
onClick={props.onClick}
variant="ghost"
size="small"
active={props.active}
>
<MatchMedia gtWidth="xs">
<ButtonIcon>reply</ButtonIcon>
</MatchMedia>
<Localized id="comments-replyButton-reply">
<span>Reply</span>
</Localized>
</Button>
);
export default ReplyButton;
@@ -1,3 +0,0 @@
.root {
margin-bottom: calc(0.5 * var(--spacing-unit));
}
@@ -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<TopBarProps> = props => {
const rootClassName = cn(styles.root, props.className);
const rootClassName = cn(props.className);
return (
<MatchMedia gtWidth="xs">
{matches => (
@@ -5,7 +5,9 @@ exports[`renders username and body 1`] = `
className="Comment-root"
role="article"
>
<TopBar>
<TopBar
className="Comment-topBar"
>
<Username>
Marvin
</Username>
@@ -16,12 +18,10 @@ exports[`renders username and body 1`] = `
<HTMLContent>
Woof
</HTMLContent>
<div
<withPropsOnChange(Flex)
className="Comment-footer"
>
<withContext(withLocalStateContainer(PermalinkContainer))
commentID="comment-id"
/>
</div>
direction="row"
itemGutter="half"
/>
</div>
`;
@@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<Indent
level={1}
>
<Comment
author={
Object {
"username": "Marvin",
}
}
body="Woof"
createdAt="1995-12-17T03:24:00.000Z"
id="comment-id"
/>
</Indent>
`;
@@ -0,0 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<withPropsOnChange(Button)
active={true}
id="id"
onClick={[Function]}
size="small"
variant="ghost"
>
<MatchMediaWithContext
gtWidth="xs"
>
<withPropsOnChange(ButtonIcon)>
reply
</withPropsOnChange(ButtonIcon)>
</MatchMediaWithContext>
<Localized
id="comments-replyButton-reply"
>
<span>
Reply
</span>
</Localized>
</withPropsOnChange(Button)>
`;
@@ -2,7 +2,7 @@
exports[`renders correctly on big screens 1`] = `
<div
className="Flex-root TopBar-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow"
className="Flex-root Flex-flex Flex-itemGutter Flex-alignBaseline Flex-directionRow"
>
<div>
Hello World
@@ -12,7 +12,7 @@ exports[`renders correctly on big screens 1`] = `
exports[`renders correctly on small screens 1`] = `
<div
className="Flex-root TopBar-root Flex-flex Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
className="Flex-root Flex-flex Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
>
<div>
Hello World
@@ -1 +1 @@
export { default, default as Comment, CommentProps } from "./Comment";
export { default, default as IndentedComment } from "./IndentedComment";
+7 -4
View File
@@ -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;
}
@@ -5,10 +5,29 @@ import { PropTypesOf } from "talk-framework/types";
import Indent from "./Indent";
it("renders correctly", () => {
it("renders level0", () => {
const props: PropTypesOf<typeof Indent> = {
children: <div>Hello World</div>,
};
const wrapper = shallow(<Indent {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("renders level1", () => {
const props: PropTypesOf<typeof Indent> = {
level: 1,
children: <div>Hello World</div>,
};
const wrapper = shallow(<Indent {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("renders without border", () => {
const props: PropTypesOf<typeof Indent> = {
level: 1,
noBorder: true,
children: <div>Hello World</div>,
};
const wrapper = shallow(<Indent {...props} />);
expect(wrapper).toMatchSnapshot();
});
+11 -1
View File
@@ -5,11 +5,21 @@ import * as styles from "./Indent.css";
export interface IndentProps {
level?: number;
noBorder?: boolean;
children: React.ReactNode;
}
const Indent: StatelessComponent<IndentProps> = props => {
return <div className={cn(styles.root, styles.level0)}>{props.children}</div>;
return (
<div
className={cn(styles.root, {
[styles.level1]: props.level === 1,
[styles.noBorder]: props.noBorder,
})}
>
{props.children}
</div>
);
};
export default Indent;
@@ -8,7 +8,9 @@ import CommentContainer from "../containers/CommentContainer";
import * as styles from "./PermalinkView.css";
export interface PermalinkViewProps {
comment: PropTypesOf<typeof CommentContainer>["data"] | null;
me: PropTypesOf<typeof CommentContainer>["me"];
asset: PropTypesOf<typeof CommentContainer>["asset"];
comment: PropTypesOf<typeof CommentContainer>["comment"] | null;
showAllCommentsHref: string | null;
onShowAllComments: (e: MouseEvent<any>) => void;
}
@@ -16,7 +18,9 @@ export interface PermalinkViewProps {
const PermalinkView: StatelessComponent<PermalinkViewProps> = ({
showAllCommentsHref,
comment,
asset,
onShowAllComments,
me,
}) => {
return (
<div className={styles.root}>
@@ -42,7 +46,7 @@ const PermalinkView: StatelessComponent<PermalinkViewProps> = ({
<Typography>Comment not found</Typography>
</Localized>
)}
{comment && <CommentContainer data={comment} />}
{comment && <CommentContainer me={me} comment={comment} asset={asset} />}
</div>
);
};
@@ -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));
}
@@ -29,7 +29,7 @@ export interface PostCommentFormProps {
const PostCommentForm: StatelessComponent<PostCommentFormProps> = props => (
<Form onSubmit={props.onSubmit} initialValues={props.initialValues}>
{({ handleSubmit, submitting }) => (
{({ handleSubmit, submitting, hasValidationErrors }) => (
<form
autoComplete="off"
onSubmit={handleSubmit}
@@ -58,6 +58,7 @@ const PostCommentForm: StatelessComponent<PostCommentFormProps> = props => (
onChange={({ html }) => input.onChange(html)}
value={input.value}
placeholder="Post a comment"
disabled={submitting}
/>
</Localized>
{meta.touched &&
@@ -79,7 +80,7 @@ const PostCommentForm: StatelessComponent<PostCommentFormProps> = props => (
<Button
color="primary"
variant="filled"
disabled={submitting}
disabled={submitting || hasValidationErrors}
type="submit"
>
Submit
+8 -4
View File
@@ -1,6 +1,6 @@
import { Blockquote, Bold, CoralRTE, Italic } from "@coralproject/rte";
import { Localized as LocalizedOriginal } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import React, { Ref, StatelessComponent } from "react";
import { Icon } from "talk-ui/components";
@@ -47,18 +47,20 @@ export interface RTEProps {
onChange?: (data: { html: string; text: string }) => void;
disabled?: boolean;
forwardRef?: Ref<CoralRTE>;
}
// tslint:disable:jsx-wrap-multiline
const features = [
<Localized key="bold" id="comments-rte-bold" attrs={{ title: true }}>
<Bold>
<Icon>format_bold</Icon>
<Icon size="md">format_bold</Icon>
</Bold>
</Localized>,
<Localized key="italic" id="comments-rte-italic" attrs={{ title: true }}>
<Italic>
<Icon>format_italic</Icon>
<Icon size="md">format_italic</Icon>
</Italic>
</Localized>,
<Localized
@@ -67,7 +69,7 @@ const features = [
attrs={{ title: true }}
>
<Blockquote key="blockquote">
<Icon>format_quote</Icon>
<Icon size="md">format_quote</Icon>
</Blockquote>
</Localized>,
];
@@ -83,6 +85,7 @@ const RTE: StatelessComponent<RTEProps> = props => {
onChange,
disabled,
defaultValue,
forwardRef,
...rest
} = props;
return (
@@ -97,6 +100,7 @@ const RTE: StatelessComponent<RTEProps> = props => {
disabled={disabled}
placeholder={placeholder}
features={features}
ref={forwardRef}
{...rest}
/>
</div>
@@ -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<FormProps>;
onCancel?: EventHandler<MouseEvent<any>>;
onChange?: (state: FormState) => void;
initialValues?: FormProps;
rteRef?: Ref<CoralRTE>;
}
const ReplyCommentForm: StatelessComponent<ReplyCommentFormProps> = props => {
const inputID = `comments-replyCommentForm-rte-${props.id}`;
return (
<Form onSubmit={props.onSubmit} initialValues={props.initialValues}>
{({ handleSubmit, submitting, hasValidationErrors }) => (
<form
className={props.className}
autoComplete="off"
onSubmit={handleSubmit}
id={`comments-replyCommentForm-form-${props.id}`}
>
<FormSpy onChange={props.onChange} />
<HorizontalGutter>
<Field name="body" validate={required}>
{({ input, meta }) => (
<div>
<Localized id="comments-replyCommentForm-rteLabel">
<AriaInfo component="label" htmlFor={inputID}>
Write a reply
</AriaInfo>
</Localized>
<Localized
id="comments-replyCommentForm-rte"
attrs={{ placeholder: true }}
>
<RTE
inputId={inputID}
onChange={({ html }) => input.onChange(html)}
value={input.value}
placeholder="Write a reply"
forwardRef={props.rteRef}
disabled={submitting}
/>
</Localized>
{meta.touched &&
(meta.error || meta.submitError) && (
<Typography align="right" color="error" gutterBottom>
{meta.error || meta.submitError}
</Typography>
)}
</div>
)}
</Field>
<Flex direction="row" justifyContent="flex-end" itemGutter="half">
<Localized id="comments-replyCommentForm-cancel">
<Button
variant="outlined"
disabled={submitting}
onClick={props.onCancel}
>
Cancel
</Button>
</Localized>
<Localized id="comments-replyCommentForm-submit">
<Button
color="primary"
variant="filled"
disabled={submitting || hasValidationErrors}
type="submit"
>
Submit
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
);
};
export default ReplyCommentForm;
@@ -12,11 +12,14 @@ const ReplyListN = removeFragmentRefs(ReplyList);
it("renders correctly", () => {
const props: PropTypesOf<typeof ReplyListN> = {
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(<ReplyListN {...props} />);
expect(wrapper).toMatchSnapshot();
@@ -24,11 +27,14 @@ it("renders correctly", () => {
describe("when there is more", () => {
const props: PropTypesOf<typeof ReplyListN> = {
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(<ReplyListN {...props} />);
+27 -16
View File
@@ -9,30 +9,41 @@ import CommentContainer from "../containers/CommentContainer";
import Indent from "./Indent";
export interface ReplyListProps {
commentID: string;
asset: PropTypesOf<typeof CommentContainer>["asset"];
me: PropTypesOf<typeof CommentContainer>["me"];
comment: {
id: string;
};
comments: ReadonlyArray<
{ id: string } & PropTypesOf<typeof CommentContainer>["data"]
{ id: string } & PropTypesOf<typeof CommentContainer>["comment"]
>;
onShowAll: () => void;
hasMore: boolean;
disableShowAll: boolean;
indentLevel?: number;
}
const ReplyList: StatelessComponent<ReplyListProps> = props => {
return (
<Indent>
<HorizontalGutter
id={`talk-comments-replyList-log--${props.commentID}`}
role="log"
>
{props.comments.map(comment => (
<CommentContainer key={comment.id} data={comment} />
))}
{props.hasMore && (
<HorizontalGutter
id={`talk-comments-replyList-log--${props.comment.id}`}
role="log"
>
{props.comments.map(comment => (
<CommentContainer
key={comment.id}
me={props.me}
comment={comment}
asset={props.asset}
indentLevel={props.indentLevel}
/>
))}
{props.hasMore && (
<Indent level={props.indentLevel} noBorder>
<Localized id="comments-replyList-showAll">
<Button
id={`talk-comments-replyList-showAll--${props.commentID}`}
aria-controls={`talk-comments-replyList-log--${props.commentID}`}
id={`talk-comments-replyList-showAll--${props.comment.id}`}
aria-controls={`talk-comments-replyList-log--${props.comment.id}`}
onClick={props.onShowAll}
disabled={props.disableShowAll}
variant="outlined"
@@ -41,9 +52,9 @@ const ReplyList: StatelessComponent<ReplyListProps> = props => {
Show All Replies
</Button>
</Localized>
)}
</HorizontalGutter>
</Indent>
</Indent>
)}
</HorizontalGutter>
);
};
@@ -12,13 +12,15 @@ const StreamN = removeFragmentRefs(Stream);
it("renders correctly", () => {
const props: PropTypesOf<typeof StreamN> = {
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(<StreamN {...props} />);
expect(wrapper).toMatchSnapshot();
@@ -27,13 +29,15 @@ it("renders correctly", () => {
describe("when use is logged in", () => {
it("renders correctly", () => {
const props: PropTypesOf<typeof StreamN> = {
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(<StreamN {...props} />);
expect(wrapper).toMatchSnapshot();
@@ -42,13 +46,15 @@ describe("when use is logged in", () => {
describe("when there is more", () => {
const props: PropTypesOf<typeof StreamN> = {
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(<StreamN {...props} />);
+24 -9
View File
@@ -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<typeof CommentContainer>["asset"] &
PropTypesOf<typeof ReplyListContainer>["asset"];
comments: ReadonlyArray<
{ id: string } & PropTypesOf<typeof CommentContainer>["data"] &
{ id: string } & PropTypesOf<typeof CommentContainer>["comment"] &
PropTypesOf<typeof ReplyListContainer>["comment"]
>;
onLoadMore?: () => void;
hasMore?: boolean;
disableLoadMore?: boolean;
user: PropTypesOf<typeof UserBoxContainer>["user"] | null;
me:
| PropTypesOf<typeof UserBoxContainer>["me"] &
PropTypesOf<typeof CommentContainer>["me"] &
PropTypesOf<typeof ReplyListContainer>["me"]
| null;
}
const Stream: StatelessComponent<StreamProps> = props => {
return (
<HorizontalGutter className={styles.root} size="double">
<HorizontalGutter size="half">
<UserBoxContainer user={props.user} />
{props.user ? (
<PostCommentFormContainer assetID={props.assetID} />
<UserBoxContainer me={props.me} />
{props.me ? (
<PostCommentFormContainer assetID={props.asset.id} />
) : (
<PostCommentFormFake />
)}
@@ -43,8 +50,16 @@ const Stream: StatelessComponent<StreamProps> = props => {
>
{props.comments.map(comment => (
<HorizontalGutter key={comment.id}>
<CommentContainer data={comment} />
<ReplyListContainer comment={comment} />
<CommentContainer
me={props.me}
comment={comment}
asset={props.asset}
/>
<ReplyListContainer
me={props.me}
comment={comment}
asset={props.asset}
/>
</HorizontalGutter>
))}
{props.hasMore && (
@@ -1,8 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
exports[`renders level0 1`] = `
<div
className="Indent-root Indent-level0"
className="Indent-root"
>
<div>
Hello World
</div>
</div>
`;
exports[`renders level1 1`] = `
<div
className="Indent-root Indent-level1"
>
<div>
Hello World
</div>
</div>
`;
exports[`renders without border 1`] = `
<div
className="Indent-root Indent-level1 Indent-noBorder"
>
<div>
Hello World
@@ -18,7 +18,9 @@ exports[`renders correctly 1`] = `
id="comments-rte-bold"
>
<Toggle>
<withPropsOnChange(Icon)>
<withPropsOnChange(Icon)
size="md"
>
format_bold
</withPropsOnChange(Icon)>
</Toggle>
@@ -32,7 +34,9 @@ exports[`renders correctly 1`] = `
id="comments-rte-italic"
>
<Toggle>
<withPropsOnChange(Icon)>
<withPropsOnChange(Icon)
size="md"
>
format_italic
</withPropsOnChange(Icon)>
</Toggle>
@@ -46,7 +50,9 @@ exports[`renders correctly 1`] = `
id="comments-rte-blockquote"
>
<Toggle>
<withPropsOnChange(Icon)>
<withPropsOnChange(Icon)
size="md"
>
format_quote
</withPropsOnChange(Icon)>
</Toggle>
@@ -1,53 +1,82 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<Indent>
<withPropsOnChange(HorizontalGutter)
id="talk-comments-replyList-log--comment-id"
role="log"
>
<Relay(CommentContainer)
data={
Object {
"id": "comment-1",
}
<withPropsOnChange(HorizontalGutter)
id="talk-comments-replyList-log--comment-id"
role="log"
>
<withContext(createMutationContainer(Relay(CommentContainer)))
asset={
Object {
"id": "asset-id",
}
key="comment-1"
/>
<Relay(CommentContainer)
data={
Object {
"id": "comment-2",
}
}
comment={
Object {
"id": "comment-1",
}
key="comment-2"
/>
</withPropsOnChange(HorizontalGutter)>
</Indent>
}
indentLevel={1}
key="comment-1"
me={null}
/>
<withContext(createMutationContainer(Relay(CommentContainer)))
asset={
Object {
"id": "asset-id",
}
}
comment={
Object {
"id": "comment-2",
}
}
indentLevel={1}
key="comment-2"
me={null}
/>
</withPropsOnChange(HorizontalGutter)>
`;
exports[`when there is more disables load more button 1`] = `
<Indent>
<withPropsOnChange(HorizontalGutter)
id="talk-comments-replyList-log--comment-id"
role="log"
<withPropsOnChange(HorizontalGutter)
id="talk-comments-replyList-log--comment-id"
role="log"
>
<withContext(createMutationContainer(Relay(CommentContainer)))
asset={
Object {
"id": "asset-id",
}
}
comment={
Object {
"id": "comment-1",
}
}
indentLevel={1}
key="comment-1"
me={null}
/>
<withContext(createMutationContainer(Relay(CommentContainer)))
asset={
Object {
"id": "asset-id",
}
}
comment={
Object {
"id": "comment-2",
}
}
indentLevel={1}
key="comment-2"
me={null}
/>
<Indent
level={1}
noBorder={true}
>
<Relay(CommentContainer)
data={
Object {
"id": "comment-1",
}
}
key="comment-1"
/>
<Relay(CommentContainer)
data={
Object {
"id": "comment-2",
}
}
key="comment-2"
/>
<Localized
id="comments-replyList-showAll"
>
@@ -62,32 +91,49 @@ exports[`when there is more disables load more button 1`] = `
Show All Replies
</withPropsOnChange(Button)>
</Localized>
</withPropsOnChange(HorizontalGutter)>
</Indent>
</Indent>
</withPropsOnChange(HorizontalGutter)>
`;
exports[`when there is more renders a load more button 1`] = `
<Indent>
<withPropsOnChange(HorizontalGutter)
id="talk-comments-replyList-log--comment-id"
role="log"
<withPropsOnChange(HorizontalGutter)
id="talk-comments-replyList-log--comment-id"
role="log"
>
<withContext(createMutationContainer(Relay(CommentContainer)))
asset={
Object {
"id": "asset-id",
}
}
comment={
Object {
"id": "comment-1",
}
}
indentLevel={1}
key="comment-1"
me={null}
/>
<withContext(createMutationContainer(Relay(CommentContainer)))
asset={
Object {
"id": "asset-id",
}
}
comment={
Object {
"id": "comment-2",
}
}
indentLevel={1}
key="comment-2"
me={null}
/>
<Indent
level={1}
noBorder={true}
>
<Relay(CommentContainer)
data={
Object {
"id": "comment-1",
}
}
key="comment-1"
/>
<Relay(CommentContainer)
data={
Object {
"id": "comment-2",
}
}
key="comment-2"
/>
<Localized
id="comments-replyList-showAll"
>
@@ -102,6 +148,6 @@ exports[`when there is more renders a load more button 1`] = `
Show All Replies
</withPropsOnChange(Button)>
</Localized>
</withPropsOnChange(HorizontalGutter)>
</Indent>
</Indent>
</withPropsOnChange(HorizontalGutter)>
`;
@@ -9,7 +9,7 @@ exports[`renders correctly 1`] = `
size="half"
>
<withContext(createMutationContainer(withContext(createMutationContainer(withContext(createMutationContainer(withContext(withLocalStateContainer(Relay(UserBoxContainer)))))))))
user={null}
me={null}
/>
<PostCommentFormFake />
</withPropsOnChange(HorizontalGutter)>
@@ -21,37 +21,65 @@ exports[`renders correctly 1`] = `
<withPropsOnChange(HorizontalGutter)
key="comment-1"
>
<Relay(CommentContainer)
data={
<withContext(createMutationContainer(Relay(CommentContainer)))
asset={
Object {
"id": "comment-1",
"id": "asset-id",
"isClosed": false,
}
}
/>
<Relay(ReplyListContainer)
comment={
Object {
"id": "comment-1",
}
}
me={null}
/>
<Relay(ReplyListContainer)
asset={
Object {
"id": "asset-id",
"isClosed": false,
}
}
comment={
Object {
"id": "comment-1",
}
}
me={null}
/>
</withPropsOnChange(HorizontalGutter)>
<withPropsOnChange(HorizontalGutter)
key="comment-2"
>
<Relay(CommentContainer)
data={
<withContext(createMutationContainer(Relay(CommentContainer)))
asset={
Object {
"id": "comment-2",
"id": "asset-id",
"isClosed": false,
}
}
/>
<Relay(ReplyListContainer)
comment={
Object {
"id": "comment-2",
}
}
me={null}
/>
<Relay(ReplyListContainer)
asset={
Object {
"id": "asset-id",
"isClosed": false,
}
}
comment={
Object {
"id": "comment-2",
}
}
me={null}
/>
</withPropsOnChange(HorizontalGutter)>
</withPropsOnChange(HorizontalGutter)>
@@ -67,7 +95,7 @@ exports[`when there is more disables load more button 1`] = `
size="half"
>
<withContext(createMutationContainer(withContext(createMutationContainer(withContext(createMutationContainer(withContext(withLocalStateContainer(Relay(UserBoxContainer)))))))))
user={null}
me={null}
/>
<PostCommentFormFake />
</withPropsOnChange(HorizontalGutter)>
@@ -79,37 +107,65 @@ exports[`when there is more disables load more button 1`] = `
<withPropsOnChange(HorizontalGutter)
key="comment-1"
>
<Relay(CommentContainer)
data={
<withContext(createMutationContainer(Relay(CommentContainer)))
asset={
Object {
"id": "comment-1",
"id": "asset-id",
"isClosed": false,
}
}
/>
<Relay(ReplyListContainer)
comment={
Object {
"id": "comment-1",
}
}
me={null}
/>
<Relay(ReplyListContainer)
asset={
Object {
"id": "asset-id",
"isClosed": false,
}
}
comment={
Object {
"id": "comment-1",
}
}
me={null}
/>
</withPropsOnChange(HorizontalGutter)>
<withPropsOnChange(HorizontalGutter)
key="comment-2"
>
<Relay(CommentContainer)
data={
<withContext(createMutationContainer(Relay(CommentContainer)))
asset={
Object {
"id": "comment-2",
"id": "asset-id",
"isClosed": false,
}
}
/>
<Relay(ReplyListContainer)
comment={
Object {
"id": "comment-2",
}
}
me={null}
/>
<Relay(ReplyListContainer)
asset={
Object {
"id": "asset-id",
"isClosed": false,
}
}
comment={
Object {
"id": "comment-2",
}
}
me={null}
/>
</withPropsOnChange(HorizontalGutter)>
<Localized
@@ -139,7 +195,7 @@ exports[`when there is more renders a load more button 1`] = `
size="half"
>
<withContext(createMutationContainer(withContext(createMutationContainer(withContext(createMutationContainer(withContext(withLocalStateContainer(Relay(UserBoxContainer)))))))))
user={null}
me={null}
/>
<PostCommentFormFake />
</withPropsOnChange(HorizontalGutter)>
@@ -151,37 +207,65 @@ exports[`when there is more renders a load more button 1`] = `
<withPropsOnChange(HorizontalGutter)
key="comment-1"
>
<Relay(CommentContainer)
data={
<withContext(createMutationContainer(Relay(CommentContainer)))
asset={
Object {
"id": "comment-1",
"id": "asset-id",
"isClosed": false,
}
}
/>
<Relay(ReplyListContainer)
comment={
Object {
"id": "comment-1",
}
}
me={null}
/>
<Relay(ReplyListContainer)
asset={
Object {
"id": "asset-id",
"isClosed": false,
}
}
comment={
Object {
"id": "comment-1",
}
}
me={null}
/>
</withPropsOnChange(HorizontalGutter)>
<withPropsOnChange(HorizontalGutter)
key="comment-2"
>
<Relay(CommentContainer)
data={
<withContext(createMutationContainer(Relay(CommentContainer)))
asset={
Object {
"id": "comment-2",
"id": "asset-id",
"isClosed": false,
}
}
/>
<Relay(ReplyListContainer)
comment={
Object {
"id": "comment-2",
}
}
me={null}
/>
<Relay(ReplyListContainer)
asset={
Object {
"id": "asset-id",
"isClosed": false,
}
}
comment={
Object {
"id": "comment-2",
}
}
me={null}
/>
</withPropsOnChange(HorizontalGutter)>
<Localized
@@ -211,7 +295,7 @@ exports[`when use is logged in renders correctly 1`] = `
size="half"
>
<withContext(createMutationContainer(withContext(createMutationContainer(withContext(createMutationContainer(withContext(withLocalStateContainer(Relay(UserBoxContainer)))))))))
user={Object {}}
me={Object {}}
/>
<withContext(withContext(createMutationContainer(PostCommentFormContainer)))
assetID="asset-id"
@@ -225,37 +309,65 @@ exports[`when use is logged in renders correctly 1`] = `
<withPropsOnChange(HorizontalGutter)
key="comment-1"
>
<Relay(CommentContainer)
data={
<withContext(createMutationContainer(Relay(CommentContainer)))
asset={
Object {
"id": "comment-1",
"id": "asset-id",
"isClosed": false,
}
}
/>
<Relay(ReplyListContainer)
comment={
Object {
"id": "comment-1",
}
}
me={Object {}}
/>
<Relay(ReplyListContainer)
asset={
Object {
"id": "asset-id",
"isClosed": false,
}
}
comment={
Object {
"id": "comment-1",
}
}
me={Object {}}
/>
</withPropsOnChange(HorizontalGutter)>
<withPropsOnChange(HorizontalGutter)
key="comment-2"
>
<Relay(CommentContainer)
data={
<withContext(createMutationContainer(Relay(CommentContainer)))
asset={
Object {
"id": "comment-2",
"id": "asset-id",
"isClosed": false,
}
}
/>
<Relay(ReplyListContainer)
comment={
Object {
"id": "comment-2",
}
}
me={Object {}}
/>
<Relay(ReplyListContainer)
asset={
Object {
"id": "asset-id",
"isClosed": false,
}
}
comment={
Object {
"id": "comment-2",
}
}
me={Object {}}
/>
</withPropsOnChange(HorizontalGutter)>
</withPropsOnChange(HorizontalGutter)>
@@ -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<typeof CommentContainerN> = {
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(<CommentContainerN {...props} />);
@@ -27,7 +34,11 @@ it("renders username and body", () => {
it("renders body only", () => {
const props: PropTypesOf<typeof CommentContainerN> = {
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(<CommentContainerN {...props} />);
@@ -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<InnerProps, State> {
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 (
<>
<Comment
{...rest}
{...comment}
footer={
<>
<ReplyButton
id={`comments-commentContainer-replyButton-${comment.id}`}
onClick={this.openReplyDialog}
active={showReplyDialog}
/>
<PermalinkButtonContainer commentID={comment.id} />
</>
}
/>
{showReplyDialog && (
<ReplyCommentFormContainer
comment={comment}
asset={asset}
onClose={this.closeReplyDialog}
/>
)}
</>
);
}
`;
}
export const CommentContainer: StatelessComponent<InnerProps> = props => {
const { data, ...rest } = props;
return <Comment {...rest} {...props.data} />;
};
const enhanced = withFragmentContainer<InnerProps>({
data: graphql`
fragment CommentContainer on Comment {
...CommentContainer_comment @relay(mask: false)
}
`,
})(CommentContainer);
const enhanced = withShowAuthPopupMutation(
withFragmentContainer<InnerProps>({
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<typeof enhanced>;
export default enhanced;
@@ -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 (
<PermalinkView
me={me}
asset={asset}
comment={comment}
showAllCommentsHref={this.getShowAllCommentsHref()}
onShowAllComments={this.showAllComments}
@@ -53,9 +59,19 @@ const enhanced = withContext(ctx => ({
}))(
withSetCommentIDMutation(
withFragmentContainer<PermalinkViewContainerProps>({
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)
@@ -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(<PostCommentFormContainer {...props} />);
@@ -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(<PostCommentFormContainer {...props} />);
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(<PostCommentFormContainer {...props} />);
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(<PostCommentFormContainer {...props} />);
await timeout();
@@ -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<InnerProps, State> {
}
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<InnerProps, State> {
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<InnerProps, State> {
}
}
const enhanced = withContext(({ pymSessionStorage }) => ({
pymSessionStorage,
const enhanced = withContext(({ sessionStorage }) => ({
sessionStorage,
}))(withCreateCommentMutation(PostCommentFormContainer));
export type PostCommentFormContainerProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -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<typeof ReplyCommentFormContainerN> = {
createComment: noop as any,
asset: {
id: "asset-id",
},
comment: {
id: "comment-id",
},
sessionStorage: createPromisifiedStorage(),
autofocus: false,
};
const wrapper = shallow(<ReplyCommentFormContainerN {...props} />);
await timeout();
wrapper.update();
expect(wrapper).toMatchSnapshot();
});
it("renders with initialValues", async () => {
const props: PropTypesOf<typeof ReplyCommentFormContainerN> = {
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(<ReplyCommentFormContainerN {...props} />);
await timeout();
wrapper.update();
expect(wrapper).toMatchSnapshot();
});
it("save values", async () => {
const props: PropTypesOf<typeof ReplyCommentFormContainerN> = {
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(<ReplyCommentFormContainerN {...props} />);
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<typeof ReplyCommentFormContainerN> = {
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(<ReplyCommentFormContainerN {...props} />);
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<typeof ReplyCommentFormContainerN> = {
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(<ReplyCommentFormContainerN {...props} />);
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<typeof ReplyCommentFormContainerN> = {
createComment: noop as any,
asset: {
id: "asset-id",
},
comment: {
id: "comment-id",
},
sessionStorage: createPromisifiedStorage(),
autofocus: true,
};
const wrapper = shallow(<ReplyCommentFormContainerN {...props} />);
await timeout();
wrapper.update();
wrapper
.findWhere(n => n.prop("rteRef"))
.props()
.rteRef(rte);
expect(focusStub.calledOnce).toBe(true);
});
@@ -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<InnerProps, State> {
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 (
<ReplyCommentForm
id={this.props.comment.id}
onSubmit={this.handleOnSubmit}
onChange={this.handleOnChange}
initialValues={this.state.initialValues}
onCancel={this.handleOnCancel}
rteRef={this.handleRTERef}
/>
);
}
}
const enhanced = withContext(({ sessionStorage, browserInfo }) => ({
sessionStorage,
// Disable autofocus on ios and enable for the rest.
autofocus: !browserInfo.ios,
}))(
withCreateCommentMutation(
withFragmentContainer<InnerProps>({
asset: graphql`
fragment ReplyCommentFormContainer_asset on Asset {
id
}
`,
comment: graphql`
fragment ReplyCommentFormContainer_comment on Comment {
id
}
`,
})(ReplyCommentFormContainer)
)
);
export type PostCommentFormContainerProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -13,6 +13,9 @@ const ReplyListContainerN = removeFragmentRefs(ReplyListContainer);
it("renders correctly", () => {
const props: PropTypesOf<typeof ReplyListContainerN> = {
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(<ReplyListContainerN {...props} />);
expect(wrapper).toMatchSnapshot();
@@ -30,6 +34,9 @@ it("renders correctly", () => {
it("renders correctly when replies are null", () => {
const props: PropTypesOf<typeof ReplyListContainerN> = {
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(<ReplyListContainerN {...props} />);
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<typeof ReplyListContainerN> = {
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;
@@ -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<InnerProps> {
const comments = this.props.comment.replies.edges.map(edge => edge.node);
return (
<ReplyList
commentID={this.props.comment.id}
me={this.props.me}
comment={this.props.comment}
comments={comments}
asset={this.props.asset}
onShowAll={this.showAll}
hasMore={this.props.relay.hasMore()}
disableShowAll={this.state.disableShowAll}
indentLevel={1}
/>
);
}
@@ -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
}
}
}
@@ -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,
@@ -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<InnerProps> {
public state = {
disableLoadMore: false,
@@ -27,13 +36,12 @@ export class StreamContainer extends React.Component<InnerProps> {
const comments = this.props.asset.comments.edges.map(edge => edge.node);
return (
<Stream
assetID={this.props.asset.id}
isClosed={this.props.asset.isClosed}
asset={this.props.asset}
comments={comments}
onLoadMore={this.loadMore}
hasMore={this.props.relay.hasMore()}
disableLoadMore={this.state.disableLoadMore}
user={this.props.user}
me={this.props.me}
/>
);
}
@@ -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
}
`,
},
@@ -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
@@ -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<InnerProps> {
local: {
authPopup: { open, focus, view },
},
user,
me,
signOut,
} = this.props;
if (user) {
if (me) {
return (
<UserBoxAuthenticated
onSignOut={signOut}
// TODO: why nullable?
username={user.username!}
username={me.username!}
/>
);
}
@@ -90,8 +90,8 @@ const enhanced = withSignOutMutation(
`
)(
withFragmentContainer<InnerProps>({
user: graphql`
fragment UserBoxContainer_user on User {
me: graphql`
fragment UserBoxContainer_me on User {
username
}
`,
@@ -1,27 +1,61 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders body only 1`] = `
<Comment
author={
Object {
"username": null,
<React.Fragment>
<IndentedComment
author={
Object {
"username": null,
}
}
}
body="Woof"
createdAt="1995-12-17T03:24:00.000Z"
id="comment-id"
/>
body="Woof"
createdAt="1995-12-17T03:24:00.000Z"
footer={
<React.Fragment>
<ReplyButton
active={false}
id="comments-commentContainer-replyButton-comment-id"
onClick={[Function]}
/>
<withContext(withLocalStateContainer(PermalinkContainer))
commentID="comment-id"
/>
</React.Fragment>
}
id="comment-id"
indentLevel={1}
me={null}
showAuthPopup={[Function]}
/>
</React.Fragment>
`;
exports[`renders username and body 1`] = `
<Comment
author={
Object {
"username": "Marvin",
<React.Fragment>
<IndentedComment
author={
Object {
"username": "Marvin",
}
}
}
body="Woof"
createdAt="1995-12-17T03:24:00.000Z"
id="comment-id"
/>
body="Woof"
createdAt="1995-12-17T03:24:00.000Z"
footer={
<React.Fragment>
<ReplyButton
active={false}
id="comments-commentContainer-replyButton-comment-id"
onClick={[Function]}
/>
<withContext(withLocalStateContainer(PermalinkContainer))
commentID="comment-id"
/>
</React.Fragment>
}
id="comment-id"
indentLevel={1}
me={null}
showAuthPopup={[Function]}
/>
</React.Fragment>
`;
@@ -0,0 +1,26 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<ReplyCommentForm
id="comment-id"
onCancel={[Function]}
onChange={[Function]}
onSubmit={[Function]}
rteRef={[Function]}
/>
`;
exports[`renders with initialValues 1`] = `
<ReplyCommentForm
id="comment-id"
initialValues={
Object {
"body": "Hello World!",
}
}
onCancel={[Function]}
onChange={[Function]}
onSubmit={[Function]}
rteRef={[Function]}
/>
`;
@@ -2,7 +2,30 @@
exports[`renders correctly 1`] = `
<ReplyList
commentID="comment-id"
asset={
Object {
"id": "asset-id",
}
}
comment={
Object {
"id": "comment-id",
"replies": Object {
"edges": Array [
Object {
"node": Object {
"id": "comment-1",
},
},
Object {
"node": Object {
"id": "comment-2",
},
},
],
},
}
}
comments={
Array [
Object {
@@ -14,6 +37,8 @@ exports[`renders correctly 1`] = `
]
}
disableShowAll={false}
indentLevel={1}
me={null}
onShowAll={[Function]}
/>
`;
@@ -22,7 +47,30 @@ exports[`renders correctly when replies are null 1`] = `""`;
exports[`when has more replies renders hasMore 1`] = `
<ReplyList
commentID="comment-id"
asset={
Object {
"id": "asset-id",
}
}
comment={
Object {
"id": "comment-id",
"replies": Object {
"edges": Array [
Object {
"node": Object {
"id": "comment-1",
},
},
Object {
"node": Object {
"id": "comment-2",
},
},
],
},
}
}
comments={
Array [
Object {
@@ -35,13 +83,38 @@ exports[`when has more replies renders hasMore 1`] = `
}
disableShowAll={false}
hasMore={true}
indentLevel={1}
me={null}
onShowAll={[Function]}
/>
`;
exports[`when has more replies when showing all disables show all button 1`] = `
<ReplyList
commentID="comment-id"
asset={
Object {
"id": "asset-id",
}
}
comment={
Object {
"id": "comment-id",
"replies": Object {
"edges": Array [
Object {
"node": Object {
"id": "comment-1",
},
},
Object {
"node": Object {
"id": "comment-2",
},
},
],
},
}
}
comments={
Array [
Object {
@@ -54,13 +127,38 @@ exports[`when has more replies when showing all disables show all button 1`] = `
}
disableShowAll={true}
hasMore={true}
indentLevel={1}
me={null}
onShowAll={[Function]}
/>
`;
exports[`when has more replies when showing all enable show all button after loading is done 1`] = `
<ReplyList
commentID="comment-id"
asset={
Object {
"id": "asset-id",
}
}
comment={
Object {
"id": "comment-id",
"replies": Object {
"edges": Array [
Object {
"node": Object {
"id": "comment-1",
},
},
Object {
"node": Object {
"id": "comment-2",
},
},
],
},
}
}
comments={
Array [
Object {
@@ -73,6 +171,8 @@ exports[`when has more replies when showing all enable show all button after loa
}
disableShowAll={false}
hasMore={true}
indentLevel={1}
me={null}
onShowAll={[Function]}
/>
`;
@@ -2,7 +2,26 @@
exports[`renders correctly 1`] = `
<Stream
assetID="asset-id"
asset={
Object {
"comments": Object {
"edges": Array [
Object {
"node": Object {
"id": "comment-1",
},
},
Object {
"node": Object {
"id": "comment-2",
},
},
],
},
"id": "asset-id",
"isClosed": false,
}
}
comments={
Array [
Object {
@@ -14,15 +33,33 @@ exports[`renders correctly 1`] = `
]
}
disableLoadMore={false}
isClosed={false}
me={null}
onLoadMore={[Function]}
user={null}
/>
`;
exports[`when has more comments renders hasMore 1`] = `
<Stream
assetID="asset-id"
asset={
Object {
"comments": Object {
"edges": Array [
Object {
"node": Object {
"id": "comment-1",
},
},
Object {
"node": Object {
"id": "comment-2",
},
},
],
},
"id": "asset-id",
"isClosed": false,
}
}
comments={
Array [
Object {
@@ -35,15 +72,33 @@ exports[`when has more comments renders hasMore 1`] = `
}
disableLoadMore={false}
hasMore={true}
isClosed={false}
me={null}
onLoadMore={[Function]}
user={null}
/>
`;
exports[`when has more comments when loading more disables load more button 1`] = `
<Stream
assetID="asset-id"
asset={
Object {
"comments": Object {
"edges": Array [
Object {
"node": Object {
"id": "comment-1",
},
},
Object {
"node": Object {
"id": "comment-2",
},
},
],
},
"id": "asset-id",
"isClosed": false,
}
}
comments={
Array [
Object {
@@ -56,15 +111,33 @@ exports[`when has more comments when loading more disables load more button 1`]
}
disableLoadMore={true}
hasMore={true}
isClosed={false}
me={null}
onLoadMore={[Function]}
user={null}
/>
`;
exports[`when has more comments when loading more enable load more button after loading is done 1`] = `
<Stream
assetID="asset-id"
asset={
Object {
"comments": Object {
"edges": Array [
Object {
"node": Object {
"id": "comment-1",
},
},
Object {
"node": Object {
"id": "comment-2",
},
},
],
},
"id": "asset-id",
"isClosed": false,
}
}
comments={
Array [
Object {
@@ -77,8 +150,7 @@ exports[`when has more comments when loading more enable load more button after
}
disableLoadMore={false}
hasMore={true}
isClosed={false}
me={null}
onLoadMore={[Function]}
user={null}
/>
`;
@@ -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<TalkContext> = {
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<TalkContext> = {
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<TalkContext> = {
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<TalkContext> = {
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");
});
@@ -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.
@@ -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<MutationTypes>(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),
});
}
@@ -6,6 +6,7 @@ import { render } from "./PermalinkViewQuery";
it("renders permalink view container", () => {
const data = {
props: {
asset: {},
comment: {},
} as any,
error: null,
@@ -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 <div>{error.message}</div>;
}
if (props) {
return <PermalinkViewContainer comment={props.comment} />;
if (!props.asset) {
return (
<Localized id="comments-permalinkViewQuery-assetNotFound">
<div>Asset not found</div>
</Localized>
);
}
return (
<PermalinkViewContainer
me={props.me}
comment={props.comment}
asset={props.asset}
/>
);
}
return <Spinner />;
};
const PermalinkViewQuery: StatelessComponent<InnerProps> = ({
local: { commentID },
local: { commentID, assetID, authRevision },
}) => (
<QueryRenderer<QueryTypes>
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<InnerProps> = ({
const enhanced = withLocalStateContainer(
graphql`
fragment PermalinkViewQueryLocal on Local {
assetID
authRevision
commentID
}
`
@@ -31,7 +31,7 @@ export const render = ({
</Localized>
);
}
return <StreamContainer asset={props.asset} user={props.me} />;
return <StreamContainer me={props.me} asset={props.asset} />;
}
return <Spinner />;
@@ -43,14 +43,14 @@ const StreamQuery: StatelessComponent<InnerProps> = ({
<QueryRenderer<QueryTypes>
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
}
}
`}
@@ -10,6 +10,7 @@ exports[`renders loading 1`] = `<withPropsOnChange(Spinner) />`;
exports[`renders permalink view container 1`] = `
<withContext(withContext(createMutationContainer(Relay(PermalinkViewContainer))))
asset={Object {}}
comment={Object {}}
/>
`;
@@ -1,5 +1,6 @@
.root {
composes: bodyCopy from "talk-ui/shared/typography.css";
overflow-wrap: break-word;
& * bold,
& * strong {
@@ -62,7 +62,7 @@ exports[`loads more comments 1`] = `
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
className="Icon-root Icon-md"
>
format_bold
</span>
@@ -76,7 +76,7 @@ exports[`loads more comments 1`] = `
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
className="Icon-root Icon-md"
>
format_italic
</span>
@@ -90,7 +90,7 @@ exports[`loads more comments 1`] = `
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
className="Icon-root Icon-md"
>
format_quote
</span>
@@ -153,7 +153,7 @@ exports[`loads more comments 1`] = `
role="article"
>
<div
className="Flex-root TopBar-root Flex-flex Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
className="Flex-root Comment-topBar Flex-flex Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
>
<span
className="Typography-root Typography-heading3 Typography-colorTextPrimary Username-root"
@@ -177,8 +177,25 @@ exports[`loads more comments 1`] = `
}
/>
<div
className="Comment-footer"
/>
className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow"
>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
id="comments-commentContainer-replyButton-comment-0"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Reply
</span>
</button>
</div>
</div>
</div>
<div
@@ -189,7 +206,7 @@ exports[`loads more comments 1`] = `
role="article"
>
<div
className="Flex-root TopBar-root Flex-flex Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
className="Flex-root Comment-topBar Flex-flex Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
>
<span
className="Typography-root Typography-heading3 Typography-colorTextPrimary Username-root"
@@ -213,8 +230,25 @@ exports[`loads more comments 1`] = `
}
/>
<div
className="Comment-footer"
/>
className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow"
>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
id="comments-commentContainer-replyButton-comment-1"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Reply
</span>
</button>
</div>
</div>
</div>
<div
@@ -225,7 +259,7 @@ exports[`loads more comments 1`] = `
role="article"
>
<div
className="Flex-root TopBar-root Flex-flex Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
className="Flex-root Comment-topBar Flex-flex Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
>
<span
className="Typography-root Typography-heading3 Typography-colorTextPrimary Username-root"
@@ -249,8 +283,25 @@ exports[`loads more comments 1`] = `
}
/>
<div
className="Comment-footer"
/>
className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow"
>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
id="comments-commentContainer-replyButton-comment-2"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Reply
</span>
</button>
</div>
</div>
</div>
</div>
@@ -320,7 +371,7 @@ exports[`renders comment stream 1`] = `
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
className="Icon-root Icon-md"
>
format_bold
</span>
@@ -334,7 +385,7 @@ exports[`renders comment stream 1`] = `
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
className="Icon-root Icon-md"
>
format_italic
</span>
@@ -348,7 +399,7 @@ exports[`renders comment stream 1`] = `
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
className="Icon-root Icon-md"
>
format_quote
</span>
@@ -411,7 +462,7 @@ exports[`renders comment stream 1`] = `
role="article"
>
<div
className="Flex-root TopBar-root Flex-flex Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
className="Flex-root Comment-topBar Flex-flex Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
>
<span
className="Typography-root Typography-heading3 Typography-colorTextPrimary Username-root"
@@ -435,8 +486,25 @@ exports[`renders comment stream 1`] = `
}
/>
<div
className="Comment-footer"
/>
className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow"
>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
id="comments-commentContainer-replyButton-comment-0"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Reply
</span>
</button>
</div>
</div>
</div>
<div
@@ -447,7 +515,7 @@ exports[`renders comment stream 1`] = `
role="article"
>
<div
className="Flex-root TopBar-root Flex-flex Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
className="Flex-root Comment-topBar Flex-flex Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
>
<span
className="Typography-root Typography-heading3 Typography-colorTextPrimary Username-root"
@@ -471,8 +539,25 @@ exports[`renders comment stream 1`] = `
}
/>
<div
className="Comment-footer"
/>
className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow"
>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
id="comments-commentContainer-replyButton-comment-1"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Reply
</span>
</button>
</div>
</div>
</div>
<button
@@ -28,7 +28,7 @@ exports[`renders permalink view 1`] = `
role="article"
>
<div
className="Flex-root TopBar-root Flex-flex Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
className="Flex-root Comment-topBar Flex-flex Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
>
<span
className="Typography-root Typography-heading3 Typography-colorTextPrimary Username-root"
@@ -52,8 +52,25 @@ exports[`renders permalink view 1`] = `
}
/>
<div
className="Comment-footer"
/>
className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow"
>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
id="comments-commentContainer-replyButton-comment-0"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Reply
</span>
</button>
</div>
</div>
</div>
</div>
@@ -121,7 +138,7 @@ exports[`show all comments 1`] = `
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
className="Icon-root Icon-md"
>
format_bold
</span>
@@ -135,7 +152,7 @@ exports[`show all comments 1`] = `
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
className="Icon-root Icon-md"
>
format_italic
</span>
@@ -149,7 +166,7 @@ exports[`show all comments 1`] = `
>
<span
aria-hidden="true"
className="Icon-root Icon-sm"
className="Icon-root Icon-md"
>
format_quote
</span>
@@ -212,7 +229,7 @@ exports[`show all comments 1`] = `
role="article"
>
<div
className="Flex-root TopBar-root Flex-flex Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
className="Flex-root Comment-topBar Flex-flex Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
>
<span
className="Typography-root Typography-heading3 Typography-colorTextPrimary Username-root"
@@ -236,8 +253,25 @@ exports[`show all comments 1`] = `
}
/>
<div
className="Comment-footer"
/>
className="Flex-root Comment-footer Flex-flex Flex-halfItemGutter Flex-directionRow"
>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantGhost"
id="comments-commentContainer-replyButton-comment-0"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Reply
</span>
</button>
</div>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More