Merge branch 'next' into ui-message

This commit is contained in:
Kim Gardner
2018-09-10 10:54:38 -04:00
committed by GitHub
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