@@ -2180,11 +2180,11 @@ exports[`shows error when submitting empty form 1`] = `
value=""
/>
warning
@@ -2280,7 +2280,7 @@ exports[`shows server error 1`] = `
Username
A unique identifier displayed on your comments. You may use “_” and “.”
@@ -2303,7 +2303,7 @@ exports[`shows server error 1`] = `
Password
Must be at least 8 characters
@@ -2427,7 +2427,7 @@ exports[`shows server error 2`] = `
Username
A unique identifier displayed on your comments. You may use “_” and “.”
@@ -2450,7 +2450,7 @@ exports[`shows server error 2`] = `
Password
Must be at least 8 characters
@@ -2569,7 +2569,7 @@ exports[`submits form successfully 1`] = `
Username
A unique identifier displayed on your comments. You may use “_” and “.”
@@ -2592,7 +2592,7 @@ exports[`submits form successfully 1`] = `
Password
Must be at least 8 characters
@@ -2711,7 +2711,7 @@ exports[`submits form successfully 2`] = `
Username
A unique identifier displayed on your comments. You may use “_” and “.”
@@ -2734,7 +2734,7 @@ exports[`submits form successfully 2`] = `
Password
Must be at least 8 characters
diff --git a/src/core/client/auth/test/create.tsx b/src/core/client/auth/test/create.tsx
index a0b9d9da9..fc03cd354 100644
--- a/src/core/client/auth/test/create.tsx
+++ b/src/core/client/auth/test/create.tsx
@@ -1,4 +1,6 @@
+import { EventEmitter2 } from "eventemitter2";
import { IResolvers } from "graphql-tools";
+import { noop } from "lodash";
import React from "react";
import TestRenderer from "react-test-renderer";
import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime";
@@ -29,7 +31,6 @@ export default function create(params: CreateParams) {
logNetwork: params.logNetwork,
resolvers: params.resolvers,
initLocalState: (localRecord, source, env) => {
- localRecord.setValue(0, "authRevision");
if (params.initLocalState) {
params.initLocalState(localRecord, source, env);
}
@@ -45,6 +46,8 @@ export default function create(params: CreateParams) {
postMessage: new PostMessageService(),
browserInfo: { ios: false },
uuidGenerator: createUUIDGenerator(),
+ eventEmitter: new EventEmitter2({ wildcard: true, maxListeners: 20 }),
+ clearSession: noop,
};
const testRenderer = TestRenderer.create(
diff --git a/src/core/client/framework/helpers/getMe.ts b/src/core/client/framework/helpers/getMe.ts
index fb5bafcf9..d989b5be2 100644
--- a/src/core/client/framework/helpers/getMe.ts
+++ b/src/core/client/framework/helpers/getMe.ts
@@ -3,12 +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)
- .reverse()
- .find(s => s.startsWith("me("))!;
- if (!root[meKey]) {
+ if (!root.me) {
return null;
}
- const meID = root[meKey].__ref;
+ const meID = root.me.__ref;
return source.get(meID)!;
}
diff --git a/src/core/client/framework/lib/bootstrap/TalkContext.tsx b/src/core/client/framework/lib/bootstrap/TalkContext.tsx
index d855a884e..ece409674 100644
--- a/src/core/client/framework/lib/bootstrap/TalkContext.tsx
+++ b/src/core/client/framework/lib/bootstrap/TalkContext.tsx
@@ -1,3 +1,4 @@
+import { EventEmitter2 } from "eventemitter2";
import { LocalizationProvider } from "fluent-react/compat";
import { FluentBundle } from "fluent/compat";
import { Child as PymChild } from "pym.js";
@@ -52,6 +53,12 @@ export interface TalkContext {
/** Generates uuids. */
uuidGenerator: () => string;
+
+ /** A event emitter */
+ eventEmitter: EventEmitter2;
+
+ /** Clear session data. */
+ clearSession: () => void;
}
const { Provider, Consumer } = React.createContext
({} as any);
diff --git a/src/core/client/framework/lib/bootstrap/createContext.tsx b/src/core/client/framework/lib/bootstrap/createContext.tsx
deleted file mode 100644
index 839cee453..000000000
--- a/src/core/client/framework/lib/bootstrap/createContext.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-import { EventEmitter2 } from "eventemitter2";
-import { Localized } from "fluent-react/compat";
-import { noop } from "lodash";
-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";
-
-import { RestClient } from "talk-framework/lib/rest";
-import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside";
-
-import { generateBundles, LocalesData, negotiateLanguages } from "../i18n";
-import { createFetch, TokenGetter } from "../network";
-import { PostMessageService } from "../postMessage";
-import { TalkContext } from "./TalkContext";
-
-interface CreateContextArguments {
- /** Locales that the user accepts, usually `navigator.languages`. */
- userLocales: ReadonlyArray;
-
- /** Locales data that is returned by our `locales-loader`. */
- localesData: LocalesData;
-
- /** Init will be called after the context has been created. */
- init?: ((context: TalkContext) => void | Promise);
-
- /** A pym child that interacts with the pym parent. */
- pym?: PymChild;
-
- /** Supports emitting and listening to events. */
- eventEmitter?: EventEmitter2;
-}
-
-/**
- * timeagoFormatter integrates timeago into our translation
- * framework. It gets injected into the UIContext.
- */
-export const timeagoFormatter: Formatter = (value, unit, suffix) => {
- // We use 'in' instead of 'from now' for language consistency
- const ourSuffix = suffix === "from now" ? "in" : suffix;
- return (
-
- now
-
- );
-};
-
-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
- * `TalkContextProvider`.
- */
-export default async function createContext({
- init = noop,
- userLocales,
- localesData,
- pym,
- eventEmitter = new EventEmitter2({ wildcard: true }),
-}: CreateContextArguments): Promise {
- const inIframe = areWeInIframe();
- // Initialize Relay.
- const source = new RecordSource();
- const tokenGetter: TokenGetter = () => {
- const localState = source.get(LOCAL_ID);
- if (localState) {
- return localState.authToken || "";
- }
- return "";
- };
- const relayEnvironment = new Environment({
- network: Network.create(createFetch(tokenGetter)),
- store: new Store(source),
- });
-
- // Listen for outside clicks.
- let registerClickFarAway: ClickFarAwayRegister | undefined;
- if (pym) {
- registerClickFarAway = cb => {
- pym.onMessage("click", cb);
- // Return unlisten callback.
- return () => {
- const index = pym.messageHandlers.click.indexOf(cb);
- if (index > -1) {
- pym.messageHandlers.click.splice(index, 1);
- }
- };
- };
- }
-
- // Initialize i18n.
- const locales = negotiateLanguages(userLocales, localesData);
-
- if (process.env.NODE_ENV !== "production") {
- // tslint:disable:next-line: no-console
- console.log(`Negotiated locales ${JSON.stringify(locales)}`);
- }
-
- const localeBundles = await generateBundles(locales, localesData);
-
- // Assemble context.
- const context = {
- relayEnvironment,
- localeBundles,
- timeagoFormatter,
- pym,
- eventEmitter,
- registerClickFarAway,
- rest: new RestClient("/api", tokenGetter),
- postMessage: new PostMessageService(),
- localStorage:
- (pym && inIframe && createPymStorage(pym, "localStorage")) ||
- createPromisifiedStorage(createLocalStorage()),
- sessionStorage:
- (pym && inIframe && createPymStorage(pym, "sessionStorage")) ||
- createPromisifiedStorage(createSessionStorage()),
- browserInfo: getBrowserInfo(),
- uuidGenerator: uuid,
- };
-
- // Run custom initializations.
- await init(context);
-
- return context;
-}
diff --git a/src/core/client/framework/lib/bootstrap/createManaged.tsx b/src/core/client/framework/lib/bootstrap/createManaged.tsx
new file mode 100644
index 000000000..bc1219427
--- /dev/null
+++ b/src/core/client/framework/lib/bootstrap/createManaged.tsx
@@ -0,0 +1,261 @@
+import { EventEmitter2 } from "eventemitter2";
+import { Localized } from "fluent-react/compat";
+import { noop } from "lodash";
+import { Child as PymChild } from "pym.js";
+import React, { Component, ComponentType } 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,
+ PromisifiedStorage,
+} from "talk-framework/lib/storage";
+
+import { RestClient } from "talk-framework/lib/rest";
+import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside";
+
+import { generateBundles, LocalesData, negotiateLanguages } from "../i18n";
+import { createFetch, TokenGetter } from "../network";
+import { PostMessageService } from "../postMessage";
+import { TalkContext, TalkContextProvider } from "./TalkContext";
+
+export type InitLocalState = ((
+ environment: Environment,
+ context: TalkContext
+) => void | Promise);
+
+interface CreateContextArguments {
+ /** Locales that the user accepts, usually `navigator.languages`. */
+ userLocales: ReadonlyArray;
+
+ /** Locales data that is returned by our `locales-loader`. */
+ localesData: LocalesData;
+
+ /** Init will be called after the context has been created. */
+ initLocalState?: InitLocalState;
+
+ /** A pym child that interacts with the pym parent. */
+ pym?: PymChild;
+
+ /** Supports emitting and listening to events. */
+ eventEmitter?: EventEmitter2;
+}
+
+/**
+ * timeagoFormatter integrates timeago into our translation
+ * framework. It gets injected into the UIContext.
+ */
+export const timeagoFormatter: Formatter = (value, unit, suffix) => {
+ // We use 'in' instead of 'from now' for language consistency
+ const ourSuffix = suffix === "from now" ? "noSuffix" : suffix;
+
+ if (unit === "second" && suffix === "ago") {
+ return (
+
+ Just now
+
+ );
+ }
+
+ return (
+
+ now
+
+ );
+};
+
+/**
+ * Returns true if we are in an iframe.
+ */
+function areWeInIframe() {
+ try {
+ return window.self !== window.top;
+ } catch (e) {
+ return true;
+ }
+}
+
+function createRelayEnvironment() {
+ // Initialize Relay.
+ const source = new RecordSource();
+ const tokenGetter: TokenGetter = () => {
+ const localState = source.get(LOCAL_ID);
+ if (localState) {
+ return localState.authToken || "";
+ }
+ return "";
+ };
+ const environment = new Environment({
+ network: Network.create(createFetch(tokenGetter)),
+ store: new Store(source),
+ });
+ return { environment, tokenGetter };
+}
+
+function createRestAPI(tokenGetter: (() => string)) {
+ return new RestClient("/api", tokenGetter);
+}
+
+/**
+ * Returns a managed TalkContextProvider, that includes given context
+ * and handles context changes, e.g. when a user session changes.
+ */
+function createMangedTalkContextProvider(
+ context: TalkContext,
+ initLocalState: InitLocalState
+) {
+ const ManagedTalkContextProvider = class extends Component<
+ {},
+ { context: TalkContext }
+ > {
+ constructor(props: {}) {
+ super(props);
+ this.state = {
+ context: {
+ ...context,
+ clearSession: this.clearSession,
+ },
+ };
+ }
+
+ // This is called every time a user session starts or ends.
+ private clearSession = async () => {
+ // Clear session storage.
+ this.state.context.sessionStorage.clear();
+
+ // Create a new context with a new Relay Environment.
+ const {
+ environment: newEnvironment,
+ tokenGetter: newTokenGetter,
+ } = createRelayEnvironment();
+
+ const newContext = {
+ ...this.state.context,
+ relayEnvironment: newEnvironment,
+ rest: createRestAPI(newTokenGetter),
+ };
+
+ // Initialize local state.
+ await initLocalState(newContext.relayEnvironment, newContext);
+
+ // Propagate new context.
+ this.setState({
+ context: newContext,
+ });
+ };
+
+ public render() {
+ return (
+
+ {this.props.children}
+
+ );
+ }
+ };
+
+ return ManagedTalkContextProvider;
+}
+
+/**
+ * resolveLocalStorage decides which local storage to use in the context
+ */
+function resolveLocalStorage(pym?: PymChild): PromisifiedStorage {
+ if (pym && areWeInIframe()) {
+ // Use local storage over pym when we have pym and are in an iframe.
+ return createPymStorage(pym, "localStorage");
+ }
+ // Use promisified, prefixed local storage.
+ return createPromisifiedStorage(createLocalStorage());
+}
+
+/**
+ * resolveSessionStorage decides which session storage to use in the context
+ */
+function resolveSessionStorage(pym?: PymChild): PromisifiedStorage {
+ if (pym && areWeInIframe()) {
+ // Use session storage over pym when we have pym and are in an iframe.
+ return createPymStorage(pym, "sessionStorage");
+ }
+ // Use promisified, prefixed session storage.
+ return createPromisifiedStorage(createSessionStorage());
+}
+
+/**
+ * `createManaged` establishes the dependencies of our framework
+ * and returns a `ManagedTalkContextProvider` that provides the context
+ * to the rest of the application.
+ */
+export default async function createManaged({
+ initLocalState = noop,
+ userLocales,
+ localesData,
+ pym,
+ eventEmitter = new EventEmitter2({ wildcard: true, maxListeners: 20 }),
+}: CreateContextArguments): Promise {
+ // Listen for outside clicks.
+ let registerClickFarAway: ClickFarAwayRegister | undefined;
+ if (pym) {
+ registerClickFarAway = cb => {
+ pym.onMessage("click", cb);
+ // Return unlisten callback.
+ return () => {
+ const index = pym.messageHandlers.click.indexOf(cb);
+ if (index > -1) {
+ pym.messageHandlers.click.splice(index, 1);
+ }
+ };
+ };
+ }
+
+ // Initialize i18n.
+ const locales = negotiateLanguages(userLocales, localesData);
+
+ if (process.env.NODE_ENV !== "production") {
+ // tslint:disable:next-line: no-console
+ console.log(`Negotiated locales ${JSON.stringify(locales)}`);
+ }
+
+ const localeBundles = await generateBundles(locales, localesData);
+
+ const localStorage = resolveLocalStorage(pym);
+ const sessionStorage = resolveSessionStorage(pym);
+
+ const { environment, tokenGetter } = createRelayEnvironment();
+
+ // Assemble context.
+ const context: TalkContext = {
+ relayEnvironment: environment,
+ localeBundles,
+ timeagoFormatter,
+ pym,
+ eventEmitter,
+ registerClickFarAway,
+ rest: createRestAPI(tokenGetter),
+ postMessage: new PostMessageService(),
+ localStorage,
+ sessionStorage,
+ browserInfo: getBrowserInfo(),
+ uuidGenerator: uuid,
+ // Noop, this is later replaced by the
+ // managed TalkContextProvider.
+ clearSession: noop,
+ };
+
+ // Initialize local state.
+ await initLocalState(context.relayEnvironment, context);
+
+ // Returns a managed TalkContextProvider, that includes the above
+ // context and handles context changes, e.g. when a user session changes.
+ return createMangedTalkContextProvider(context, initLocalState);
+}
diff --git a/src/core/client/framework/lib/bootstrap/index.ts b/src/core/client/framework/lib/bootstrap/index.ts
index a6bd57eaa..0ef2ee79b 100644
--- a/src/core/client/framework/lib/bootstrap/index.ts
+++ b/src/core/client/framework/lib/bootstrap/index.ts
@@ -1,3 +1,7 @@
-export * from "./TalkContext";
-export { default as createContext } from "./createContext";
+export {
+ TalkContext,
+ TalkContextConsumer,
+ TalkContextProvider,
+} from "./TalkContext";
+export { default as createManaged } from "./createManaged";
export { default as withContext } from "./withContext";
diff --git a/src/core/client/framework/lib/relay/commitLocalUpdatePromisified.ts b/src/core/client/framework/lib/relay/commitLocalUpdatePromisified.ts
new file mode 100644
index 000000000..92d341cef
--- /dev/null
+++ b/src/core/client/framework/lib/relay/commitLocalUpdatePromisified.ts
@@ -0,0 +1,18 @@
+import {
+ commitLocalUpdate,
+ Environment,
+ RecordSourceProxy,
+} from "relay-runtime";
+
+export default function commitLocalUpdatePromisified(
+ environment: Environment,
+ updater: (store: RecordSourceProxy) => Promise
+) {
+ return new Promise((resolve, reject) => {
+ commitLocalUpdate(environment, store => {
+ updater(store)
+ .then(() => resolve())
+ .catch(err => reject(err));
+ });
+ });
+}
diff --git a/src/core/client/framework/lib/relay/createMutationContainer.tsx b/src/core/client/framework/lib/relay/createMutationContainer.tsx
index b8c74be18..3084635c9 100644
--- a/src/core/client/framework/lib/relay/createMutationContainer.tsx
+++ b/src/core/client/framework/lib/relay/createMutationContainer.tsx
@@ -42,7 +42,7 @@ function createMutationContainer(
};
public render() {
- const { relayEnvironment: _, ...rest } = this.props;
+ const { context: _, ...rest } = this.props;
const inject = {
[propName]: this.commit,
};
diff --git a/src/core/client/framework/lib/relay/index.ts b/src/core/client/framework/lib/relay/index.ts
index 43dca5178..31bae3d63 100644
--- a/src/core/client/framework/lib/relay/index.ts
+++ b/src/core/client/framework/lib/relay/index.ts
@@ -13,3 +13,6 @@ export {
commitMutationPromiseNormalized,
} from "./commitMutationPromise";
export { graphql } from "react-relay";
+export {
+ default as commitLocalUpdatePromisified,
+} from "./commitLocalUpdatePromisified";
diff --git a/src/core/client/framework/lib/relay/withLocalStateContainer.tsx b/src/core/client/framework/lib/relay/withLocalStateContainer.tsx
index 444b67fa2..484928fc7 100644
--- a/src/core/client/framework/lib/relay/withLocalStateContainer.tsx
+++ b/src/core/client/framework/lib/relay/withLocalStateContainer.tsx
@@ -39,6 +39,7 @@ export const LOCAL_ID = "client:root.local";
function withLocalStateContainer(
fragmentSpec: GraphQLTaggedNode
): InferableComponentEnhancer<{ local: _RefType }> {
+ const fragment = (fragmentSpec as any).data().default;
return compose(
withContext(({ relayEnvironment }) => ({ relayEnvironment })),
hoistStatics((BaseComponent: React.ComponentType) => {
@@ -49,9 +50,7 @@ function withLocalStateContainer(
);
private subscription: Disposable;
- constructor(props: Props) {
- super(props);
- const fragment = (fragmentSpec as any).data().default;
+ private subscribe(environment: Environment) {
if (fragment.kind !== "Fragment") {
throw new Error("Expected fragment");
}
@@ -65,24 +64,44 @@ function withLocalStateContainer(
node: { selections: fragment.selections },
variables: {},
};
- const snapshot = props.relayEnvironment.lookup(selector);
- this.subscription = props.relayEnvironment.subscribe(
+ const snapshot = environment.lookup(selector);
+ this.subscription = environment.subscribe(
snapshot,
this.updateSnapshot
);
- this.state = {
- data: snapshot.data,
- };
+ this.updateSnapshot(snapshot);
+ }
+
+ constructor(props: Props) {
+ super(props);
+ this.subscribe(props.relayEnvironment);
}
private updateSnapshot = (snapshot: CSnapshot) => {
- this.setState({ data: snapshot.data });
+ const nextState = { data: snapshot.data };
+ // State has not been initialized yet.
+ if (!this.state) {
+ this.state = nextState;
+ return;
+ }
+ this.setState(nextState);
};
- public componentWillUnmount() {
+ private unsubscribe() {
this.subscription.dispose();
}
+ public componentWillReceiveProps(next: Props) {
+ if (this.props.relayEnvironment !== next.relayEnvironment) {
+ this.unsubscribe();
+ this.subscribe(next.relayEnvironment);
+ }
+ }
+
+ public componentWillUnmount() {
+ this.unsubscribe();
+ }
+
public render() {
const { relayEnvironment: _, ...rest } = this.props;
return ;
diff --git a/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts b/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts
index 887e6fd84..158dd0854 100644
--- a/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts
+++ b/src/core/client/framework/mutations/SetAuthTokenMutation.spec.ts
@@ -1,7 +1,9 @@
import { Environment, RecordSource } from "relay-runtime";
+import sinon from "sinon";
+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 { commit } from "./SetAuthTokenMutation";
@@ -15,21 +17,29 @@ beforeAll(() => {
});
});
-it("Sets auth token to localStorage", () => {
- const context = {
- localStorage: createInMemoryStorage(),
+it("Sets auth token to localStorage", async () => {
+ const clearSessionStub = sinon.stub();
+ const context: Partial = {
+ localStorage: createPromisifiedStorage(),
+ clearSession: clearSessionStub,
};
const authToken = "auth token";
- commit(environment, { authToken }, context as any);
+ await commit(environment, { authToken }, context as any);
expect(source.get(LOCAL_ID)!.authToken).toEqual(authToken);
- expect(context.localStorage.getItem("authToken")).toEqual(authToken);
+ await expect(context.localStorage!.getItem("authToken")).resolves.toEqual(
+ authToken
+ );
+ expect(clearSessionStub.calledOnce).toBe(true);
});
-it("Removes auth token from localStorage", () => {
- const context = {
- localStorage: createInMemoryStorage(),
+it("Removes auth token from localStorage", async () => {
+ const clearSessionStub = sinon.stub();
+ const context: Partial = {
+ localStorage: createPromisifiedStorage(),
+ clearSession: clearSessionStub,
};
localStorage.setItem("authToken", "tmp");
- commit(environment, { authToken: null }, context as any);
- expect(context.localStorage.getItem("authToken")).toBeNull();
+ await commit(environment, { authToken: null }, context as any);
+ await expect(context.localStorage!.getItem("authToken")).resolves.toBeNull();
+ expect(clearSessionStub.calledOnce).toBe(true);
});
diff --git a/src/core/client/framework/mutations/SetAuthTokenMutation.ts b/src/core/client/framework/mutations/SetAuthTokenMutation.ts
index 90e520155..f0da6df92 100644
--- a/src/core/client/framework/mutations/SetAuthTokenMutation.ts
+++ b/src/core/client/framework/mutations/SetAuthTokenMutation.ts
@@ -1,7 +1,10 @@
-import { commitLocalUpdate, Environment } from "relay-runtime";
+import { Environment } from "relay-runtime";
import { TalkContext } from "talk-framework/lib/bootstrap";
-import { createMutationContainer } from "talk-framework/lib/relay";
+import {
+ commitLocalUpdatePromisified,
+ createMutationContainer,
+} from "talk-framework/lib/relay";
import { LOCAL_ID } from "talk-framework/lib/relay/withLocalStateContainer";
export interface SetAuthTokenInput {
@@ -13,27 +16,18 @@ export type SetAuthTokenMutation = (input: SetAuthTokenInput) => Promise;
export async function commit(
environment: Environment,
input: SetAuthTokenInput,
- { localStorage }: TalkContext
+ { localStorage, clearSession }: TalkContext
) {
- return commitLocalUpdate(environment, store => {
+ return await commitLocalUpdatePromisified(environment, async store => {
const record = store.get(LOCAL_ID)!;
record.setValue(input.authToken, "authToken");
if (input.authToken) {
- localStorage.setItem("authToken", input.authToken);
+ await localStorage.setItem("authToken", input.authToken);
} else {
- localStorage.removeItem("authToken");
+ await localStorage.removeItem("authToken");
}
- // Increment auth revision to indicate a change in auth state.
- record.setValue(record.getValue("authRevision") + 1, "authRevision");
-
- // Force gc to trigger.
- environment
- .retain({
- dataID: "tmp",
- node: { selections: [] },
- variables: {},
- })
- .dispose();
+ // Clear current session, as we are starting a new one.
+ clearSession();
});
}
diff --git a/src/core/client/stream/components/App.spec.tsx b/src/core/client/stream/components/App.spec.tsx
index d7dfdc3a7..dcd979099 100644
--- a/src/core/client/stream/components/App.spec.tsx
+++ b/src/core/client/stream/components/App.spec.tsx
@@ -5,17 +5,9 @@ import { PropTypesOf } from "talk-framework/types";
import App from "./App";
-it("renders stream", () => {
+it("renders comments", () => {
const props: PropTypesOf = {
- showPermalinkView: false,
- };
- const wrapper = shallow( );
- expect(wrapper).toMatchSnapshot();
-});
-
-it("renders permalink view", () => {
- const props: PropTypesOf = {
- showPermalinkView: true,
+ activeTab: "COMMENTS",
};
const wrapper = shallow( );
expect(wrapper).toMatchSnapshot();
diff --git a/src/core/client/stream/components/App.tsx b/src/core/client/stream/components/App.tsx
index b7d2d09d9..421e6af9c 100644
--- a/src/core/client/stream/components/App.tsx
+++ b/src/core/client/stream/components/App.tsx
@@ -3,20 +3,22 @@ import { StatelessComponent } from "react";
import { Flex } from "talk-ui/components";
-import PermalinkViewQuery from "../queries/PermalinkViewQuery";
-import StreamQuery from "../queries/StreamQuery";
+import CommentsPaneContainer from "../tabs/comments/containers/CommentsPaneContainer";
import * as styles from "./App.css";
export interface AppProps {
- showPermalinkView: boolean;
+ activeTab: "COMMENTS" | "%future added value";
}
const App: StatelessComponent = props => {
- const view = props.showPermalinkView ? (
-
- ) : (
-
- );
+ let view: React.ReactElement;
+ switch (props.activeTab) {
+ case "COMMENTS":
+ view = ;
+ break;
+ default:
+ throw new Error(`Unknown tab ${props.activeTab}`);
+ }
return (
{view}
diff --git a/src/core/client/stream/components/Comment/Comment.tsx b/src/core/client/stream/components/Comment/Comment.tsx
deleted file mode 100644
index bd4fc17f7..000000000
--- a/src/core/client/stream/components/Comment/Comment.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import React, { ReactElement, StatelessComponent } from "react";
-
-import { Flex } from "talk-ui/components";
-
-import * as styles from "./Comment.css";
-import HTMLContent from "./HTMLContent";
-import Timestamp from "./Timestamp";
-import TopBar from "./TopBar";
-import Username from "./Username";
-
-export interface CommentProps {
- id: string;
- className?: string;
- author: {
- username: string | null;
- } | null;
- body: string | null;
- createdAt: string;
- footer?: ReactElement | Array>;
-}
-
-const Comment: StatelessComponent = props => {
- return (
-
-
- {props.author &&
- props.author.username && {props.author.username} }
- {props.createdAt}
-
- {props.body || ""}
-
- {props.footer}
-
-
- );
-};
-
-export default Comment;
diff --git a/src/core/client/stream/components/Comment/HTMLContent.tsx b/src/core/client/stream/components/Comment/HTMLContent.tsx
deleted file mode 100644
index b9d61c945..000000000
--- a/src/core/client/stream/components/Comment/HTMLContent.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import React from "react";
-
-import dompurify from "dompurify";
-import styles from "./HTMLContent.css";
-
-interface ContentProps {
- children: string;
-}
-
-class HTMLContent extends React.Component {
- public render() {
- const { children } = this.props;
- return (
-
- );
- }
-}
-
-export default HTMLContent;
diff --git a/src/core/client/stream/components/Comment/__snapshots__/Comment.spec.tsx.snap b/src/core/client/stream/components/Comment/__snapshots__/Comment.spec.tsx.snap
deleted file mode 100644
index 246444f0d..000000000
--- a/src/core/client/stream/components/Comment/__snapshots__/Comment.spec.tsx.snap
+++ /dev/null
@@ -1,27 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders username and body 1`] = `
-
-
-
- Marvin
-
-
- 1995-12-17T03:24:00.000Z
-
-
-
- Woof
-
-
-
-`;
diff --git a/src/core/client/stream/components/Comment/index.ts b/src/core/client/stream/components/Comment/index.ts
deleted file mode 100644
index abb1d79ae..000000000
--- a/src/core/client/stream/components/Comment/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default, default as IndentedComment } from "./IndentedComment";
diff --git a/src/core/client/stream/components/Comment/HTMLContent.css b/src/core/client/stream/components/HTMLContent.css
similarity index 100%
rename from src/core/client/stream/components/Comment/HTMLContent.css
rename to src/core/client/stream/components/HTMLContent.css
diff --git a/src/core/client/stream/components/Comment/HTMLContent.spec.tsx b/src/core/client/stream/components/HTMLContent.spec.tsx
similarity index 100%
rename from src/core/client/stream/components/Comment/HTMLContent.spec.tsx
rename to src/core/client/stream/components/HTMLContent.spec.tsx
diff --git a/src/core/client/stream/components/HTMLContent.tsx b/src/core/client/stream/components/HTMLContent.tsx
new file mode 100644
index 000000000..62bd62b78
--- /dev/null
+++ b/src/core/client/stream/components/HTMLContent.tsx
@@ -0,0 +1,17 @@
+import dompurify from "dompurify";
+import React, { StatelessComponent } from "react";
+
+import styles from "./HTMLContent.css";
+
+interface HTMLContentProps {
+ children: string;
+}
+
+const HTMLContent: StatelessComponent = ({ children }) => (
+
+);
+
+export default HTMLContent;
diff --git a/src/core/client/stream/components/Comment/Timestamp.css b/src/core/client/stream/components/Timestamp.css
similarity index 100%
rename from src/core/client/stream/components/Comment/Timestamp.css
rename to src/core/client/stream/components/Timestamp.css
diff --git a/src/core/client/stream/components/Comment/Timestamp.spec.tsx b/src/core/client/stream/components/Timestamp.spec.tsx
similarity index 100%
rename from src/core/client/stream/components/Comment/Timestamp.spec.tsx
rename to src/core/client/stream/components/Timestamp.spec.tsx
diff --git a/src/core/client/stream/components/Comment/Timestamp.tsx b/src/core/client/stream/components/Timestamp.tsx
similarity index 100%
rename from src/core/client/stream/components/Comment/Timestamp.tsx
rename to src/core/client/stream/components/Timestamp.tsx
diff --git a/src/core/client/stream/components/UserBoxAuthenticated.tsx b/src/core/client/stream/components/UserBoxAuthenticated.tsx
index 94ed90ac6..68c7fba00 100644
--- a/src/core/client/stream/components/UserBoxAuthenticated.tsx
+++ b/src/core/client/stream/components/UserBoxAuthenticated.tsx
@@ -20,7 +20,7 @@ const UserBoxAuthenticated: StatelessComponent<
return (
}
>
@@ -28,7 +28,7 @@ const UserBoxAuthenticated: StatelessComponent<
- Post a comment
+ Write a reply
{
+ if (params.initLocalState) {
+ localRecord.setValue("COMMENTS", "activeTab");
+ params.initLocalState(localRecord, source, environment);
+ }
+ },
+ });
+}
diff --git a/src/core/client/stream/test/comments/editComment.spec.tsx b/src/core/client/stream/test/comments/editComment.spec.tsx
new file mode 100644
index 000000000..733f30eb2
--- /dev/null
+++ b/src/core/client/stream/test/comments/editComment.spec.tsx
@@ -0,0 +1,135 @@
+import timekeeper from "timekeeper";
+
+import { timeout } from "talk-common/utils";
+import { createSinonStub } from "talk-framework/testHelpers";
+
+import { assets, users } from "../fixtures";
+import create from "./create";
+
+function createTestRenderer() {
+ const resolvers = {
+ Query: {
+ asset: createSinonStub(
+ s => s.throws(),
+ s => s.withArgs(undefined, { id: assets[0].id }).returns(assets[0])
+ ),
+ me: createSinonStub(
+ s => s.throws(),
+ s => s.withArgs(undefined).returns(users[0])
+ ),
+ },
+ Mutation: {
+ editComment: createSinonStub(
+ s => s.throws(),
+ s =>
+ s
+ .withArgs(undefined, {
+ input: {
+ commentID: assets[0].comments.edges[0].node.id,
+ body: "Edited!",
+ clientMutationId: "0",
+ },
+ })
+ .returns({
+ // TODO: add a type assertion here to ensure that if the type changes, that the test will fail
+ comment: {
+ id: assets[0].comments.edges[0].node.id,
+ body: "Edited! (from server)",
+ editing: {
+ edited: true,
+ },
+ },
+ clientMutationId: "0",
+ })
+ ),
+ },
+ };
+
+ const { testRenderer } = create({
+ // Set this to true, to see graphql responses.
+ logNetwork: false,
+ resolvers,
+ initLocalState: localRecord => {
+ localRecord.setValue(assets[0].id, "assetID");
+ },
+ });
+ return testRenderer;
+}
+
+afterAll(() => {
+ timekeeper.reset();
+});
+
+it("edit a comment", async () => {
+ timekeeper.freeze(assets[0].comments.edges[0].node.createdAt);
+ const testRenderer = createTestRenderer();
+
+ // Wait for loading.
+ await timeout();
+ expect(testRenderer.toJSON()).toMatchSnapshot("render stream");
+
+ // Open edit form.
+ testRenderer.root
+ .findByProps({ id: "comments-commentContainer-editButton-comment-0" })
+ .props.onClick();
+ expect(testRenderer.toJSON()).toMatchSnapshot("edit form");
+
+ testRenderer.root
+ .findByProps({ inputId: "comments-editCommentForm-rte-comment-0" })
+ .props.onChange({ html: "Edited!" });
+
+ testRenderer.root
+ .findByProps({ id: "comments-editCommentForm-form-comment-0" })
+ .props.onSubmit();
+
+ // Test optimistic response.
+ expect(testRenderer.toJSON()).toMatchSnapshot("optimistic response");
+
+ // Wait for loading.
+ await timeout();
+
+ // Test after server response.
+ expect(testRenderer.toJSON()).toMatchSnapshot("server response");
+});
+
+it("cancel edit", async () => {
+ timekeeper.freeze(assets[0].comments.edges[0].node.createdAt);
+ const testRenderer = createTestRenderer();
+
+ await timeout();
+
+ // Open edit form.
+ testRenderer.root
+ .findByProps({ id: "comments-commentContainer-editButton-comment-0" })
+ .props.onClick();
+
+ // Cacnel edit form.
+ testRenderer.root
+ .findByProps({ id: "comments-editCommentForm-cancelButton-comment-0" })
+ .props.onClick();
+ expect(testRenderer.toJSON()).toMatchSnapshot("edit canceled");
+});
+
+it("shows expiry message", async () => {
+ timekeeper.freeze(assets[0].comments.edges[0].node.createdAt);
+ const testRenderer = createTestRenderer();
+
+ await timeout();
+ jest.useFakeTimers();
+ // Open edit form.
+ testRenderer.root
+ .findByProps({ id: "comments-commentContainer-editButton-comment-0" })
+ .props.onClick();
+
+ timekeeper.reset();
+ jest.runOnlyPendingTimers();
+
+ // Show edit time expired.
+ expect(testRenderer.toJSON()).toMatchSnapshot("edit time expired");
+
+ // Close edit form.
+ testRenderer.root
+ .findByProps({ id: "comments-editCommentForm-closeButton-comment-0" })
+ .props.onClick();
+ expect(testRenderer.toJSON()).toMatchSnapshot("edit form closed");
+});
diff --git a/src/core/client/stream/test/loadMore.spec.tsx b/src/core/client/stream/test/comments/loadMore.spec.tsx
similarity index 97%
rename from src/core/client/stream/test/loadMore.spec.tsx
rename to src/core/client/stream/test/comments/loadMore.spec.tsx
index ea72b5510..404d3c702 100644
--- a/src/core/client/stream/test/loadMore.spec.tsx
+++ b/src/core/client/stream/test/comments/loadMore.spec.tsx
@@ -3,8 +3,8 @@ import { ReactTestRenderer } from "react-test-renderer";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
+import { assets, comments } from "../fixtures";
import create from "./create";
-import { assets, comments } from "./fixtures";
let testRenderer: ReactTestRenderer;
beforeEach(() => {
diff --git a/src/core/client/stream/test/permalinkView.spec.tsx b/src/core/client/stream/test/comments/permalinkView.spec.tsx
similarity index 97%
rename from src/core/client/stream/test/permalinkView.spec.tsx
rename to src/core/client/stream/test/comments/permalinkView.spec.tsx
index 438ca2cea..0bbe3e260 100644
--- a/src/core/client/stream/test/permalinkView.spec.tsx
+++ b/src/core/client/stream/test/comments/permalinkView.spec.tsx
@@ -4,8 +4,8 @@ import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
+import { assets, comments } from "../fixtures";
import create from "./create";
-import { assets, comments } from "./fixtures";
let testRenderer: ReactTestRenderer;
beforeEach(() => {
diff --git a/src/core/client/stream/test/permalinkViewAssetNotFound.spec.tsx b/src/core/client/stream/test/comments/permalinkViewAssetNotFound.spec.tsx
similarity index 100%
rename from src/core/client/stream/test/permalinkViewAssetNotFound.spec.tsx
rename to src/core/client/stream/test/comments/permalinkViewAssetNotFound.spec.tsx
diff --git a/src/core/client/stream/test/permalinkViewCommentNotFound.spec.tsx b/src/core/client/stream/test/comments/permalinkViewCommentNotFound.spec.tsx
similarity index 97%
rename from src/core/client/stream/test/permalinkViewCommentNotFound.spec.tsx
rename to src/core/client/stream/test/comments/permalinkViewCommentNotFound.spec.tsx
index 6a7279669..9c4517351 100644
--- a/src/core/client/stream/test/permalinkViewCommentNotFound.spec.tsx
+++ b/src/core/client/stream/test/comments/permalinkViewCommentNotFound.spec.tsx
@@ -4,8 +4,8 @@ import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
+import { assets, comments } from "../fixtures";
import create from "./create";
-import { assets, comments } from "./fixtures";
let testRenderer: ReactTestRenderer;
beforeEach(() => {
diff --git a/src/core/client/stream/test/postComment.spec.tsx b/src/core/client/stream/test/comments/postComment.spec.tsx
similarity index 86%
rename from src/core/client/stream/test/postComment.spec.tsx
rename to src/core/client/stream/test/comments/postComment.spec.tsx
index 658a9d036..01f296ffc 100644
--- a/src/core/client/stream/test/postComment.spec.tsx
+++ b/src/core/client/stream/test/comments/postComment.spec.tsx
@@ -4,21 +4,15 @@ import timekeeper from "timekeeper";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
+import { assets, users } from "../fixtures";
import create from "./create";
-import { assets, users } from "./fixtures";
let testRenderer: ReactTestRenderer;
beforeEach(() => {
const resolvers = {
Query: {
- asset: createSinonStub(
- s => s.throws(),
- s => s.withArgs(undefined, { id: assets[0].id }).returns(assets[0])
- ),
- me: createSinonStub(
- s => s.throws(),
- s => s.withArgs(undefined, { clientAuthRevision: 0 }).returns(users[0])
- ),
+ asset: createSinonStub(s => s.throws(), s => s.returns(assets[0])),
+ me: createSinonStub(s => s.throws(), s => s.returns(users[0])),
},
Mutation: {
createComment: createSinonStub(
@@ -41,6 +35,10 @@ beforeEach(() => {
author: users[0],
body: "
Hello world! (from server) ",
createdAt: "2018-07-06T18:24:00.000Z",
+ editing: {
+ edited: false,
+ editableUntil: "2018-07-06T18:24:30.000Z",
+ },
},
},
clientMutationId: "0",
diff --git a/src/core/client/stream/test/postReply.spec.tsx b/src/core/client/stream/test/comments/postReply.spec.tsx
similarity index 88%
rename from src/core/client/stream/test/postReply.spec.tsx
rename to src/core/client/stream/test/comments/postReply.spec.tsx
index 3c64bc098..6f75b57a8 100644
--- a/src/core/client/stream/test/postReply.spec.tsx
+++ b/src/core/client/stream/test/comments/postReply.spec.tsx
@@ -4,21 +4,15 @@ import timekeeper from "timekeeper";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
+import { assets, users } from "../fixtures";
import create from "./create";
-import { assets, users } from "./fixtures";
let testRenderer: ReactTestRenderer;
beforeEach(() => {
const resolvers = {
Query: {
- asset: createSinonStub(
- s => s.throws(),
- s => s.withArgs(undefined, { id: assets[0].id }).returns(assets[0])
- ),
- me: createSinonStub(
- s => s.throws(),
- s => s.withArgs(undefined, { clientAuthRevision: 0 }).returns(users[0])
- ),
+ asset: createSinonStub(s => s.throws(), s => s.returns(assets[0])),
+ me: createSinonStub(s => s.throws(), s => s.returns(users[0])),
},
Mutation: {
createComment: createSinonStub(
@@ -45,6 +39,10 @@ beforeEach(() => {
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
},
+ editing: {
+ edited: false,
+ editableUntil: "2018-07-06T18:24:30.000Z",
+ },
},
},
clientMutationId: "0",
diff --git a/src/core/client/stream/test/renderReplies.spec.tsx b/src/core/client/stream/test/comments/renderReplies.spec.tsx
similarity index 94%
rename from src/core/client/stream/test/renderReplies.spec.tsx
rename to src/core/client/stream/test/comments/renderReplies.spec.tsx
index b508d78d8..3c361740d 100644
--- a/src/core/client/stream/test/renderReplies.spec.tsx
+++ b/src/core/client/stream/test/comments/renderReplies.spec.tsx
@@ -3,8 +3,8 @@ import { ReactTestRenderer } from "react-test-renderer";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
+import { assetWithReplies } from "../fixtures";
import create from "./create";
-import { assetWithReplies } from "./fixtures";
let testRenderer: ReactTestRenderer;
beforeEach(() => {
diff --git a/src/core/client/stream/test/renderStream.spec.tsx b/src/core/client/stream/test/comments/renderStream.spec.tsx
similarity index 95%
rename from src/core/client/stream/test/renderStream.spec.tsx
rename to src/core/client/stream/test/comments/renderStream.spec.tsx
index 131bb678c..2778acb02 100644
--- a/src/core/client/stream/test/renderStream.spec.tsx
+++ b/src/core/client/stream/test/comments/renderStream.spec.tsx
@@ -3,8 +3,8 @@ import { ReactTestRenderer } from "react-test-renderer";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
+import { assets } from "../fixtures";
import create from "./create";
-import { assets } from "./fixtures";
let testRenderer: ReactTestRenderer;
beforeEach(() => {
diff --git a/src/core/client/stream/test/showAllReplies.spec.tsx b/src/core/client/stream/test/comments/showAllReplies.spec.tsx
similarity index 98%
rename from src/core/client/stream/test/showAllReplies.spec.tsx
rename to src/core/client/stream/test/comments/showAllReplies.spec.tsx
index 52d302e55..547ac7b33 100644
--- a/src/core/client/stream/test/showAllReplies.spec.tsx
+++ b/src/core/client/stream/test/comments/showAllReplies.spec.tsx
@@ -4,8 +4,8 @@ import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
+import { assets, comments } from "../fixtures";
import create from "./create";
-import { assets, comments } from "./fixtures";
let testRenderer: ReactTestRenderer;
beforeEach(() => {
diff --git a/src/core/client/stream/test/create.tsx b/src/core/client/stream/test/create.tsx
index 49d885c66..f105162c4 100644
--- a/src/core/client/stream/test/create.tsx
+++ b/src/core/client/stream/test/create.tsx
@@ -1,4 +1,6 @@
+import { EventEmitter2 } from "eventemitter2";
import { IResolvers } from "graphql-tools";
+import { noop } from "lodash";
import React from "react";
import TestRenderer from "react-test-renderer";
import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime";
@@ -14,7 +16,7 @@ import createEnvironment from "./createEnvironment";
import createFluentBundle from "./createFluentBundle";
import createNodeMock from "./createNodeMock";
-interface CreateParams {
+export interface CreateParams {
logNetwork?: boolean;
resolvers: IResolvers
;
initLocalState?: (
@@ -30,7 +32,6 @@ export default function create(params: CreateParams) {
logNetwork: params.logNetwork,
resolvers: params.resolvers,
initLocalState: (localRecord, source, env) => {
- localRecord.setValue(0, "authRevision");
if (params.initLocalState) {
params.initLocalState(localRecord, source, env);
}
@@ -46,6 +47,8 @@ export default function create(params: CreateParams) {
postMessage: new PostMessageService(),
browserInfo: { ios: false },
uuidGenerator: createUUIDGenerator(),
+ eventEmitter: new EventEmitter2({ wildcard: true, maxListeners: 20 }),
+ clearSession: noop,
};
const testRenderer = TestRenderer.create(
diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts
index b4e207978..ec219fb67 100644
--- a/src/core/client/stream/test/fixtures.ts
+++ b/src/core/client/stream/test/fixtures.ts
@@ -20,6 +20,10 @@ export const comments = [
body: "Joining Too",
createdAt: "2018-07-06T18:24:00.000Z",
replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } },
+ editing: {
+ edited: false,
+ editableUntil: "2018-07-06T18:24:30.000Z",
+ },
},
{
id: "comment-1",
@@ -27,6 +31,10 @@ export const comments = [
body: "What's up?",
createdAt: "2018-07-06T18:20:00.000Z",
replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } },
+ editing: {
+ edited: false,
+ editableUntil: "2018-07-06T18:20:30.000Z",
+ },
},
{
id: "comment-2",
@@ -34,6 +42,10 @@ export const comments = [
body: "Hey!",
createdAt: "2018-07-06T18:14:00.000Z",
replies: { edges: [], pageInfo: { endCursor: null, hasNextPage: false } },
+ editing: {
+ edited: false,
+ editableUntil: "2018-07-06T18:14:30.000Z",
+ },
},
];
@@ -68,6 +80,10 @@ export const commentWithReplies = {
hasNextPage: false,
},
},
+ editing: {
+ edited: false,
+ editableUntil: "2018-07-06T18:24:30.000Z",
+ },
};
export const assetWithReplies = {
diff --git a/src/core/client/ui/components/FormField/__snapshots__/FormField.spec.tsx.snap b/src/core/client/ui/components/FormField/__snapshots__/FormField.spec.tsx.snap
index 874adc015..011020568 100644
--- a/src/core/client/ui/components/FormField/__snapshots__/FormField.spec.tsx.snap
+++ b/src/core/client/ui/components/FormField/__snapshots__/FormField.spec.tsx.snap
@@ -18,7 +18,7 @@ exports[`works with multiple form components 1`] = `
Username
A unique identifier displayed on your comments. You may use “_” and “.”
diff --git a/src/core/client/ui/components/InputDescription/InputDescription.tsx b/src/core/client/ui/components/InputDescription/InputDescription.tsx
index d36d5fe7a..ae84ed141 100644
--- a/src/core/client/ui/components/InputDescription/InputDescription.tsx
+++ b/src/core/client/ui/components/InputDescription/InputDescription.tsx
@@ -11,7 +11,7 @@ const InputDescription: StatelessComponent = props => {
const { className, children, ...rest } = props;
return (
Form Components should go here
diff --git a/src/core/client/ui/components/ValidationMessage/ValidationMessage.css b/src/core/client/ui/components/Message/Message.css
similarity index 75%
rename from src/core/client/ui/components/ValidationMessage/ValidationMessage.css
rename to src/core/client/ui/components/Message/Message.css
index b3d6a6290..5811ad377 100644
--- a/src/core/client/ui/components/ValidationMessage/ValidationMessage.css
+++ b/src/core/client/ui/components/Message/Message.css
@@ -7,8 +7,15 @@
padding: calc(0.5 * var(--spacing-unit)) var(--spacing-unit);
box-sizing: border-box;
border-radius: var(--round-corners);
+ border-width: 1px;
+ border-style: solid;
border-left-width: calc(0.5 * var(--spacing-unit));
- border-left-style: solid;
+}
+
+.colorGrey {
+ background-color: var(--palette-common-white);
+ border-color: var(--palette-grey-main);
+ color: var(--palette-grey-main);
}
.colorError {
@@ -21,7 +28,3 @@
display: flex;
width: 100%;
}
-
-.icon {
- margin-right: var(--spacing-unit);
-}
diff --git a/src/core/client/ui/components/Message/Message.mdx b/src/core/client/ui/components/Message/Message.mdx
new file mode 100644
index 000000000..828d18036
--- /dev/null
+++ b/src/core/client/ui/components/Message/Message.mdx
@@ -0,0 +1,28 @@
+---
+name: Message
+menu: UI Kit
+---
+
+import { Playground } from 'docz'
+import Message from './Message'
+import MessageIcon from './MessageIcon'
+import HorizontalGutter from '../HorizontalGutter'
+
+# Message
+
+## Basic Use
+
+
+ This is a message
+ Contrary to popular belief, Lorem Ipsum is not simply random text.
+
+
+
+## Usage with icon
+
+
+ alarm Edit: 1 min 23 secs Remaining
+
+
+
+
diff --git a/src/core/client/ui/components/Message/Message.spec.tsx b/src/core/client/ui/components/Message/Message.spec.tsx
new file mode 100644
index 000000000..ac69cea85
--- /dev/null
+++ b/src/core/client/ui/components/Message/Message.spec.tsx
@@ -0,0 +1,25 @@
+import React from "react";
+import TestRenderer from "react-test-renderer";
+
+import { PropTypesOf } from "talk-ui/types";
+
+import Message from "./Message";
+import MessageIcon from "./MessageIcon";
+
+it("renders correctly", () => {
+ const props: PropTypesOf = {
+ className: "custom",
+ children: "Hello World",
+ };
+ const renderer = TestRenderer.create( );
+ expect(renderer.toJSON()).toMatchSnapshot();
+});
+
+it("renders icon", () => {
+ const renderer = TestRenderer.create(
+
+ alert Alert Message
+
+ );
+ expect(renderer.toJSON()).toMatchSnapshot();
+});
diff --git a/src/core/client/ui/components/Message/Message.tsx b/src/core/client/ui/components/Message/Message.tsx
new file mode 100644
index 000000000..7547d8442
--- /dev/null
+++ b/src/core/client/ui/components/Message/Message.tsx
@@ -0,0 +1,55 @@
+import cn from "classnames";
+import React, { ReactNode, StatelessComponent } from "react";
+import { withStyles } from "talk-ui/hocs";
+import * as styles from "./Message.css";
+
+export interface MessageProps {
+ /**
+ * The content of the component.
+ */
+ children: ReactNode;
+ /**
+ * Convenient prop to override the root styling.
+ */
+ className?: string;
+ /**
+ * Override or extend the styles applied to the component.
+ */
+ classes: typeof styles;
+ /*
+ * If set renders a full width message
+ */
+ fullWidth?: boolean;
+ /*
+ * Name of color, "grey" stays by default - common gray one
+ */
+ color?: "error" | "grey";
+}
+
+const Message: StatelessComponent = props => {
+ const { className, classes, fullWidth, children, color, ...rest } = props;
+
+ const rootClassName = cn(
+ classes.root,
+ {
+ [classes.colorGrey]: color === "grey",
+ [classes.colorError]: color === "error",
+ [classes.fullWidth]: fullWidth,
+ },
+ className
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+Message.defaultProps = {
+ color: "grey",
+ fullWidth: false,
+};
+
+const enhanced = withStyles(styles)(Message);
+export default enhanced;
diff --git a/src/core/client/ui/components/Message/MessageIcon.css b/src/core/client/ui/components/Message/MessageIcon.css
new file mode 100644
index 000000000..0104b6cd1
--- /dev/null
+++ b/src/core/client/ui/components/Message/MessageIcon.css
@@ -0,0 +1,7 @@
+.root {
+ align-self: flex-start;
+ margin-top: 1px;
+ &:first-child {
+ margin-right: calc(0.5 * var(--spacing-unit));
+ }
+}
diff --git a/src/core/client/ui/components/Message/MessageIcon.tsx b/src/core/client/ui/components/Message/MessageIcon.tsx
new file mode 100644
index 000000000..cd4d0ad13
--- /dev/null
+++ b/src/core/client/ui/components/Message/MessageIcon.tsx
@@ -0,0 +1,36 @@
+import cn from "classnames";
+import React, { HTMLAttributes, Ref, StatelessComponent } from "react";
+
+import Icon, { IconProps } from "talk-ui/components/Icon";
+import { withForwardRef, withStyles } from "talk-ui/hocs";
+
+import * as styles from "./MessageIcon.css";
+
+interface InnerProps extends HTMLAttributes {
+ /**
+ * This prop can be used to add custom classnames.
+ * It is handled by the `withStyles `HOC.
+ */
+ classes: typeof styles & IconProps["classes"];
+
+ size?: IconProps["size"];
+
+ /** The name of the icon to render */
+ children: string;
+
+ /** Internal: Forwarded Ref */
+ forwardRef?: Ref;
+}
+
+export const MessageIcon: StatelessComponent = props => {
+ const { classes, className, forwardRef, ...rest } = props;
+ const rootClassName = cn(classes.root, className);
+ return ;
+};
+
+MessageIcon.defaultProps = {
+ size: "sm",
+};
+
+const enhanced = withForwardRef(withStyles(styles)(MessageIcon));
+export default enhanced;
diff --git a/src/core/client/ui/components/Message/__snapshots__/Message.spec.tsx.snap b/src/core/client/ui/components/Message/__snapshots__/Message.spec.tsx.snap
new file mode 100644
index 000000000..38381f0c6
--- /dev/null
+++ b/src/core/client/ui/components/Message/__snapshots__/Message.spec.tsx.snap
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders correctly 1`] = `
+
+ Hello World
+
+`;
+
+exports[`renders icon 1`] = `
+
+
+ alert
+
+ Alert Message
+
+`;
diff --git a/src/core/client/ui/components/Message/index.ts b/src/core/client/ui/components/Message/index.ts
new file mode 100644
index 000000000..84f6b9009
--- /dev/null
+++ b/src/core/client/ui/components/Message/index.ts
@@ -0,0 +1,2 @@
+export { default, default as Message } from "./Message";
+export { default as MessageIcon } from "./MessageIcon";
diff --git a/src/core/client/ui/components/RelativeTime/RelativeTime.css b/src/core/client/ui/components/RelativeTime/RelativeTime.css
index e0450679f..c3a2af639 100644
--- a/src/core/client/ui/components/RelativeTime/RelativeTime.css
+++ b/src/core/client/ui/components/RelativeTime/RelativeTime.css
@@ -1,4 +1,2 @@
.root {
- composes: bodyCopy from "talk-ui/shared/typography.css";
- background-color: transparent;
}
diff --git a/src/core/client/ui/components/Tabs/Tab.css b/src/core/client/ui/components/Tabs/Tab.css
new file mode 100644
index 000000000..ead948a36
--- /dev/null
+++ b/src/core/client/ui/components/Tabs/Tab.css
@@ -0,0 +1,54 @@
+.root {
+ display: inline-block;
+ list-style: none;
+ margin-right: -1px;
+ margin-bottom: -1px;
+}
+
+.button {
+ height: 100%;
+ box-sizing: border-box;
+ border-bottom: 0;
+ list-style: none;
+ padding: var(--spacing-unit);
+ font-weight: var(--font-weight-regular);
+ font-family: var(--font-family-serif);
+
+ &:hover {
+ cursor: pointer;
+ }
+}
+
+.root:first-child .primary {
+ border-top-left-radius: var(--round-corners);
+}
+
+.root:last-child .primary {
+ border-top-right-radius: var(--round-corners);
+}
+
+.primary {
+ position: relative;
+ background: var(--palette-grey-lightest);
+ color: var(--palette-grey-main);
+ border: 1px solid var(--palette-grey-lighter);
+ padding: calc(0.5 * var(--spacing-unit)) calc(var(--spacing-unit) * 2);
+ &.active {
+ background-color: var(--palette-common-white);
+ color: var(--palette-common-black);
+ border-bottom: 0;
+ border-top-width: calc(0.5 * var(--spacing-unit));
+ border-top-color: var(--palette-primary-main);
+ border-radius: 0;
+ z-index: 10;
+ }
+}
+
+.secondary {
+ padding: calc(0.5 * var(--spacing-unit)) calc(var(--spacing-unit) * 2);
+
+ &.active {
+ font-weight: var(--font-weight-medium);
+ border-bottom: 3px solid var(--palette-primary-main);
+ }
+}
diff --git a/src/core/client/ui/components/Tabs/Tab.spec.tsx b/src/core/client/ui/components/Tabs/Tab.spec.tsx
new file mode 100644
index 000000000..3af6a904a
--- /dev/null
+++ b/src/core/client/ui/components/Tabs/Tab.spec.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+import TestRenderer from "react-test-renderer";
+
+import Tab from "./Tab";
+
+it("renders correctly", () => {
+ const renderer = TestRenderer.create(Three );
+ expect(renderer.toJSON()).toMatchSnapshot();
+});
diff --git a/src/core/client/ui/components/Tabs/Tab.tsx b/src/core/client/ui/components/Tabs/Tab.tsx
new file mode 100644
index 000000000..fd7e99e0f
--- /dev/null
+++ b/src/core/client/ui/components/Tabs/Tab.tsx
@@ -0,0 +1,76 @@
+import cn from "classnames";
+import React from "react";
+import { withStyles } from "talk-ui/hocs";
+import BaseButton from "../BaseButton";
+import * as styles from "./Tab.css";
+
+export interface TabProps {
+ /**
+ * Convenient prop to override the root styling.
+ */
+ className?: string;
+ /**
+ * Override or extend the styles applied to the component.
+ */
+ classes: typeof styles;
+ /**
+ * The id/name of the tab
+ */
+ tabId: string;
+ /**
+ * Active status
+ */
+ active?: boolean;
+ /**
+ * Style variant
+ */
+ variant?: "primary" | "secondary";
+ /**
+ * Action taken on tab click
+ */
+ onTabClick?: (tabId: string) => void;
+}
+
+class Tab extends React.Component {
+ public handleTabClick = () => {
+ if (this.props.onTabClick) {
+ this.props.onTabClick(this.props.tabId);
+ }
+ };
+
+ public render() {
+ const { className, classes, children, tabId, active, variant } = this.props;
+
+ const buttonClassName = cn(
+ classes.button,
+ {
+ [classes.primary]: variant === "primary",
+ [classes.secondary]: variant === "secondary",
+ [classes.active]: active,
+ },
+ className
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+}
+
+const enhanced = withStyles(styles)(Tab);
+export default enhanced;
diff --git a/src/core/client/ui/components/Tabs/TabBar.css b/src/core/client/ui/components/Tabs/TabBar.css
new file mode 100644
index 000000000..0ba3a25f2
--- /dev/null
+++ b/src/core/client/ui/components/Tabs/TabBar.css
@@ -0,0 +1,13 @@
+.root {
+ display: flex;
+ padding: 0;
+ margin: 0;
+}
+
+.primary {
+ border-bottom: 1px solid var(--palette-grey-lighter);
+}
+
+.secondary {
+ border-bottom: 1px solid var(--palette-divider);
+}
diff --git a/src/core/client/ui/components/Tabs/TabBar.spec.tsx b/src/core/client/ui/components/Tabs/TabBar.spec.tsx
new file mode 100644
index 000000000..ffe91d4fd
--- /dev/null
+++ b/src/core/client/ui/components/Tabs/TabBar.spec.tsx
@@ -0,0 +1,34 @@
+import React from "react";
+import TestRenderer from "react-test-renderer";
+
+import Tab from "./Tab";
+import TabBar from "./TabBar";
+
+it("renders correctly", () => {
+ const renderer = TestRenderer.create(
+
+ One
+ Two
+ Three
+
+ );
+ expect(renderer.toJSON()).toMatchSnapshot();
+});
+
+it("sets initial tab as active", () => {
+ const renderer = TestRenderer.create(
+
+ One
+ Two
+ Three
+
+ );
+
+ const testInstance = renderer.root;
+ expect(testInstance.findByType(TabBar).props.activeTab).toBe("one");
+ const tabs = testInstance.findAllByType(Tab);
+ expect(tabs.length).toBe(3);
+ expect(tabs[0].props.active).toBe(true);
+ expect(tabs[1].props.active).toBe(false);
+ expect(tabs[2].props.active).toBe(false);
+});
diff --git a/src/core/client/ui/components/Tabs/TabBar.tsx b/src/core/client/ui/components/Tabs/TabBar.tsx
new file mode 100644
index 000000000..29f993bf1
--- /dev/null
+++ b/src/core/client/ui/components/Tabs/TabBar.tsx
@@ -0,0 +1,80 @@
+import cn from "classnames";
+import React, { StatelessComponent } from "react";
+import { withStyles } from "talk-ui/hocs";
+import * as styles from "./TabBar.css";
+
+export interface TabBarProps {
+ /**
+ * Convenient prop to override the root styling.
+ */
+ className?: string;
+ /**
+ * Override or extend the styles applied to the component.
+ */
+ classes: typeof styles;
+ /**
+ * Style variant
+ */
+ variant?: "primary" | "secondary";
+ /**
+ * Active tab id/name
+ */
+ activeTab?: string;
+ /**
+ * Default active tab id/name
+ */
+ defaultActiveTab?: string;
+ /**
+ * Action taken on tab click
+ */
+ onTabClick?: (tabId: string) => void;
+}
+
+const TabBar: StatelessComponent = props => {
+ const {
+ className,
+ classes,
+ children,
+ onTabClick,
+ activeTab,
+ variant,
+ defaultActiveTab,
+ } = props;
+
+ const rootClassName = cn(
+ classes.root,
+ [
+ {
+ [classes.primary]: variant === "primary",
+ [classes.secondary]: variant === "secondary",
+ },
+ ],
+ className
+ );
+
+ const tabs = React.Children.toArray(children).map(
+ (child: React.ReactElement, index: number) =>
+ React.cloneElement(child, {
+ tabId: child.props.tabId ? child.props.tabId : index,
+ active:
+ defaultActiveTab && !activeTab
+ ? child.props.tabId === defaultActiveTab
+ : child.props.tabId === activeTab,
+ variant,
+ onTabClick,
+ })
+ );
+
+ return (
+
+ );
+};
+
+TabBar.defaultProps = {
+ variant: "primary",
+};
+
+const enhanced = withStyles(styles)(TabBar);
+export default enhanced;
diff --git a/src/core/client/ui/components/Tabs/TabContent.spec.tsx b/src/core/client/ui/components/Tabs/TabContent.spec.tsx
new file mode 100644
index 000000000..f411b53f5
--- /dev/null
+++ b/src/core/client/ui/components/Tabs/TabContent.spec.tsx
@@ -0,0 +1,31 @@
+import React from "react";
+import TestRenderer from "react-test-renderer";
+
+import TabContent from "./TabContent";
+import TabPane from "./TabPane";
+
+it("renders correctly", () => {
+ const renderer = TestRenderer.create(
+
+ Hola One
+ Hola Two
+ Hola Three
+
+ );
+ expect(renderer.toJSON()).toMatchSnapshot();
+});
+
+it("sets initial tab as active, renders only one", () => {
+ const renderer = TestRenderer.create(
+
+ Hola One
+ Hola Two
+ Hola Three
+
+ );
+
+ const testInstance = renderer.root;
+ expect(testInstance.findByType(TabContent).props.activeTab).toBe("one");
+ const panes = testInstance.findAllByType(TabPane);
+ expect(panes.length).toBe(1);
+});
diff --git a/src/core/client/ui/components/Tabs/TabContent.tsx b/src/core/client/ui/components/Tabs/TabContent.tsx
new file mode 100644
index 000000000..da68f05a9
--- /dev/null
+++ b/src/core/client/ui/components/Tabs/TabContent.tsx
@@ -0,0 +1,27 @@
+import React, { StatelessComponent } from "react";
+
+export interface TabContentProps {
+ /**
+ * Active tab id/name
+ */
+ activeTab?: string;
+}
+
+const TabContent: StatelessComponent = props => {
+ const { children, activeTab } = props;
+ return (
+ <>
+ {React.Children.toArray(children)
+ .filter(
+ (child: React.ReactElement) => child.props.tabId === activeTab
+ )
+ .map((child: React.ReactElement, i) =>
+ React.cloneElement(child, {
+ tabId: child.props.tabId ? child.props.tabId : i,
+ })
+ )}
+ >
+ );
+};
+
+export default TabContent;
diff --git a/src/core/client/ui/components/Tabs/TabPane.spec.tsx b/src/core/client/ui/components/Tabs/TabPane.spec.tsx
new file mode 100644
index 000000000..595e054de
--- /dev/null
+++ b/src/core/client/ui/components/Tabs/TabPane.spec.tsx
@@ -0,0 +1,9 @@
+import React from "react";
+import TestRenderer from "react-test-renderer";
+
+import TabPane from "./TabPane";
+
+it("renders correctly", () => {
+ const renderer = TestRenderer.create(Three );
+ expect(renderer.toJSON()).toMatchSnapshot();
+});
diff --git a/src/core/client/ui/components/Tabs/TabPane.tsx b/src/core/client/ui/components/Tabs/TabPane.tsx
new file mode 100644
index 000000000..a3c84480c
--- /dev/null
+++ b/src/core/client/ui/components/Tabs/TabPane.tsx
@@ -0,0 +1,29 @@
+import React, { StatelessComponent } from "react";
+
+export interface TabBarProps {
+ /**
+ * Convenient prop to override the root styling.
+ */
+ className?: string;
+ /**
+ * Name of the tab
+ */
+ tabId: string;
+}
+
+const TabPane: StatelessComponent = props => {
+ const { className, children, tabId } = props;
+ return (
+
+ );
+};
+
+export default TabPane;
diff --git a/src/core/client/ui/components/Tabs/__snapshots__/Tab.spec.tsx.snap b/src/core/client/ui/components/Tabs/__snapshots__/Tab.spec.tsx.snap
new file mode 100644
index 000000000..a30d114a8
--- /dev/null
+++ b/src/core/client/ui/components/Tabs/__snapshots__/Tab.spec.tsx.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders correctly 1`] = `
+
+
+ Three
+
+
+`;
diff --git a/src/core/client/ui/components/Tabs/__snapshots__/TabBar.spec.tsx.snap b/src/core/client/ui/components/Tabs/__snapshots__/TabBar.spec.tsx.snap
new file mode 100644
index 000000000..fd9f2ae4a
--- /dev/null
+++ b/src/core/client/ui/components/Tabs/__snapshots__/TabBar.spec.tsx.snap
@@ -0,0 +1,75 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders correctly 1`] = `
+
+
+
+ One
+
+
+
+
+ Two
+
+
+
+
+ Three
+
+
+
+`;
diff --git a/src/core/client/ui/components/Tabs/__snapshots__/TabContent.spec.tsx.snap b/src/core/client/ui/components/Tabs/__snapshots__/TabContent.spec.tsx.snap
new file mode 100644
index 000000000..cdaf48d7d
--- /dev/null
+++ b/src/core/client/ui/components/Tabs/__snapshots__/TabContent.spec.tsx.snap
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders correctly 1`] = `
+
+`;
diff --git a/src/core/client/ui/components/Tabs/__snapshots__/TabPane.spec.tsx.snap b/src/core/client/ui/components/Tabs/__snapshots__/TabPane.spec.tsx.snap
new file mode 100644
index 000000000..ad884add2
--- /dev/null
+++ b/src/core/client/ui/components/Tabs/__snapshots__/TabPane.spec.tsx.snap
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders correctly 1`] = `
+
+`;
diff --git a/src/core/client/ui/components/Tabs/index.ts b/src/core/client/ui/components/Tabs/index.ts
new file mode 100644
index 000000000..f8818e9ea
--- /dev/null
+++ b/src/core/client/ui/components/Tabs/index.ts
@@ -0,0 +1,4 @@
+export { default as TabBar } from "./TabBar";
+export { default as Tab } from "./Tab";
+export { default as TabPane } from "./TabPane";
+export { default as TabContent } from "./TabContent";
diff --git a/src/core/client/ui/components/Tabs/tabs.mdx b/src/core/client/ui/components/Tabs/tabs.mdx
new file mode 100644
index 000000000..f465209ae
--- /dev/null
+++ b/src/core/client/ui/components/Tabs/tabs.mdx
@@ -0,0 +1,52 @@
+---
+name: Tabs
+menu: UI Kit
+---
+
+# Tabs
+
+### Examples
+
+import { Playground } from 'docz'
+import { Flex, TabBar, Tab, TabContent, TabPane } from './index'
+import Container from "react-with-state-props"
+
+## Primary Tabs
+
+ (
+
+ props.setActiveId(id)}>
+ One
+ Two
+ Three
+
+
+ Hola One
+ Hola Two
+ Hola Three
+
+
+ )}/>
+
+
+## Secondary Tabs
+
+ (
+
+ props.setActiveId(id)}>
+ One
+ Two
+ Three
+
+
+ Hola One
+ Hola Two
+ Hola Three
+
+
+ )}/>
+
diff --git a/src/core/client/ui/components/Typography/Typography.css b/src/core/client/ui/components/Typography/Typography.css
index a2bd115fe..be3e0954e 100644
--- a/src/core/client/ui/components/Typography/Typography.css
+++ b/src/core/client/ui/components/Typography/Typography.css
@@ -40,8 +40,8 @@
composes: inputLabel from "talk-ui/shared/typography.css";
}
-.inputDescription {
- composes: inputDescription from "talk-ui/shared/typography.css";
+.detail {
+ composes: detail from "talk-ui/shared/typography.css";
}
.timestamp {
diff --git a/src/core/client/ui/components/Typography/Typography.tsx b/src/core/client/ui/components/Typography/Typography.tsx
index 2c4a20d94..b8f6d2396 100644
--- a/src/core/client/ui/components/Typography/Typography.tsx
+++ b/src/core/client/ui/components/Typography/Typography.tsx
@@ -15,7 +15,7 @@ type Variant =
| "bodyCopy"
| "bodyCopyBold"
| "inputLabel"
- | "inputDescription"
+ | "detail"
| "timestamp";
// Based on Typography Component of Material UI.
@@ -146,7 +146,7 @@ Typography.defaultProps = {
bodyCopyBold: "p",
timestamp: "span",
inputLabel: "label",
- inputDescription: "p",
+ detail: "p",
},
noWrap: false,
paragraph: false,
diff --git a/src/core/client/ui/components/ValidationMessage/ValidationMessage.mdx b/src/core/client/ui/components/ValidationMessage/ValidationMessage.mdx
index c8e7b11ad..28bbec5a6 100644
--- a/src/core/client/ui/components/ValidationMessage/ValidationMessage.mdx
+++ b/src/core/client/ui/components/ValidationMessage/ValidationMessage.mdx
@@ -4,7 +4,7 @@ menu: UI Kit
---
import { Playground } from 'docz'
-import ValidationMessage from './ValidationMessage.tsx'
+import ValidationMessage from './ValidationMessage'
import HorizontalGutter from '../HorizontalGutter'
# ValidationMessage
diff --git a/src/core/client/ui/components/ValidationMessage/ValidationMessage.tsx b/src/core/client/ui/components/ValidationMessage/ValidationMessage.tsx
index 0cc781560..8d1343f8b 100644
--- a/src/core/client/ui/components/ValidationMessage/ValidationMessage.tsx
+++ b/src/core/client/ui/components/ValidationMessage/ValidationMessage.tsx
@@ -1,10 +1,6 @@
-import cn from "classnames";
import React, { ReactNode, StatelessComponent } from "react";
-
-import { withStyles } from "talk-ui/hocs";
-
-import Icon from "../Icon";
-import * as styles from "./ValidationMessage.css";
+import Message from "../Message";
+import MessageIcon from "../Message/MessageIcon";
export interface ValidationMessageProps {
/**
@@ -16,34 +12,24 @@ export interface ValidationMessageProps {
*/
className?: string;
/**
- * Override or extend the styles applied to the component.
+ * If set renders a full width message
*/
- classes: typeof styles;
- /*
- * If set renders a full width message
- */
fullWidth?: boolean;
}
const ValidationMessage: StatelessComponent = props => {
- const { className, classes, fullWidth, children, ...rest } = props;
-
- const rootClassName = cn(
- classes.root,
- classes.colorError,
- {
- [classes.fullWidth]: fullWidth,
- },
- className
- );
+ const { className, fullWidth, children, ...rest } = props;
return (
-
-
- warning
-
+
+ warning
{children}
-
+
);
};
@@ -51,5 +37,4 @@ ValidationMessage.defaultProps = {
fullWidth: false,
};
-const enhanced = withStyles(styles)(ValidationMessage);
-export default enhanced;
+export default ValidationMessage;
diff --git a/src/core/client/ui/components/ValidationMessage/__snapshots__/ValidationMessage.spec.tsx.snap b/src/core/client/ui/components/ValidationMessage/__snapshots__/ValidationMessage.spec.tsx.snap
index 7f03e37e0..c0e1861bb 100644
--- a/src/core/client/ui/components/ValidationMessage/__snapshots__/ValidationMessage.spec.tsx.snap
+++ b/src/core/client/ui/components/ValidationMessage/__snapshots__/ValidationMessage.spec.tsx.snap
@@ -2,11 +2,11 @@
exports[`renders correctly 1`] = `
warning
diff --git a/src/core/client/ui/components/index.ts b/src/core/client/ui/components/index.ts
index f701660dd..6477af50e 100644
--- a/src/core/client/ui/components/index.ts
+++ b/src/core/client/ui/components/index.ts
@@ -20,3 +20,5 @@ export { default as Spinner } from "./Spinner";
export { default as HorizontalGutter } from "./HorizontalGutter";
export { default as Icon } from "./Icon";
export { default as AriaInfo } from "./AriaInfo";
+export { default as Message, MessageIcon } from "./Message";
+export { Tab, TabBar, TabContent, TabPane } from "./Tabs";
diff --git a/src/core/client/ui/shared/typography.css b/src/core/client/ui/shared/typography.css
index cb785f8fc..3012a3617 100644
--- a/src/core/client/ui/shared/typography.css
+++ b/src/core/client/ui/shared/typography.css
@@ -155,7 +155,7 @@
color: var(--palette-text-primary);
}
-.inputDescription {
+.detail {
font-size: calc(14rem / var(--rem-base));
font-weight: var(--font-weight-regular);
font-family: var(--font-family-sans-serif);
diff --git a/src/core/common/utils/index.ts b/src/core/common/utils/index.ts
index 28435047e..4d6df9fc5 100644
--- a/src/core/common/utils/index.ts
+++ b/src/core/common/utils/index.ts
@@ -2,3 +2,4 @@ export { default as timeout } from "./timeout";
export { default as animationFrame } from "./animationFrame";
export { default as pascalCase } from "./pascalCase";
export { default as oncePerFrame } from "./oncePerFrame";
+export { default as isBeforeDate } from "./isBeforeDate";
diff --git a/src/core/common/utils/isBeforeDate.spec.ts b/src/core/common/utils/isBeforeDate.spec.ts
new file mode 100644
index 000000000..4359ca55a
--- /dev/null
+++ b/src/core/common/utils/isBeforeDate.spec.ts
@@ -0,0 +1,9 @@
+import timekeeper from "timekeeper";
+import isBeforeDate from "./isBeforeDate";
+
+it("works correctly", () => {
+ timekeeper.freeze(new Date("2018-07-06T18:24:00.000Z"));
+ expect(isBeforeDate(new Date("2018-07-06T18:24:30.000Z"))).toBe(true);
+ expect(isBeforeDate(new Date("2018-07-06T18:23:30.000Z"))).toBe(false);
+ timekeeper.reset();
+});
diff --git a/src/core/common/utils/isBeforeDate.ts b/src/core/common/utils/isBeforeDate.ts
new file mode 100644
index 000000000..24d2a33e9
--- /dev/null
+++ b/src/core/common/utils/isBeforeDate.ts
@@ -0,0 +1,3 @@
+export default function isBeforeDate(date: string | number | Date) {
+ return new Date() < new Date(date);
+}
diff --git a/src/core/server/graph/tenant/mutators/comment.ts b/src/core/server/graph/tenant/mutators/comment.ts
index d0c26e431..aeaac70ee 100644
--- a/src/core/server/graph/tenant/mutators/comment.ts
+++ b/src/core/server/graph/tenant/mutators/comment.ts
@@ -26,7 +26,6 @@ export default (ctx: TenantContext) => ({
ctx.user!,
{
id: input.commentID,
- asset_id: input.assetID,
body: input.body,
},
ctx.req
diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql
index aab430f22..424a777e5 100644
--- a/src/core/server/graph/tenant/schema/schema.graphql
+++ b/src/core/server/graph/tenant/schema/schema.graphql
@@ -970,12 +970,8 @@ type Query {
"""
me is the current logged in User.
-
- clientAuthRevision is an implementation detail that is only
- used on the client to invalidate the cache.
- TODO: This should move to a client side directive if this becomes possible.
"""
- me(clientAuthRevision: Int): User
+ me: User
"""
settings is the Settings for a given Tenant.
@@ -1040,11 +1036,6 @@ type CreateCommentPayload {
EditCommentInput provides the input for the editComment Mutation.
"""
input EditCommentInput {
- """
- assetID is the ID of the Asset where we are editing a comment on.
- """
- assetID: ID!
-
"""
commentID is the ID of the comment being edited.
"""
diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts
index 91fa5c903..bea38ea20 100644
--- a/src/core/server/models/comment.ts
+++ b/src/core/server/models/comment.ts
@@ -104,7 +104,7 @@ export async function createComment(
export type EditCommentInput = Pick<
Comment,
- "id" | "author_id" | "body" | "status"
+ "id" | "author_id" | "body" | "status" | "metadata"
> & {
/**
* lastEditableCommentCreatedAt is the date that the last comment would have
@@ -126,7 +126,16 @@ export async function editComment(
];
const createdAt = new Date();
- const { id, body, lastEditableCommentCreatedAt, status, author_id } = input;
+ const {
+ id,
+ body,
+ lastEditableCommentCreatedAt,
+ status,
+ author_id,
+ metadata,
+ } = input;
+
+ // TODO: (wyattjoh) consider resetting the action counts if we're starting fresh with a new comment
const result = await collection(db).findOneAndUpdate(
{
@@ -145,6 +154,10 @@ export async function editComment(
$set: {
body,
status,
+ // Embed all the metadata properties, this may override the existing
+ // metadata, but we won't replace metadata that has been recalculated.
+ // TODO: (wyattjoh) consider if we want to replace the metadata for edited comments instead of supplementing it
+ ...dotize({ metadata }),
},
$push: {
body_history: {
diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts
index 5db49c751..0c0073b9f 100644
--- a/src/core/server/services/comments/index.ts
+++ b/src/core/server/services/comments/index.ts
@@ -136,12 +136,7 @@ async function addCommentActions(
export type EditComment = Omit<
EditCommentInput,
"status" | "author_id" | "lastEditableCommentCreatedAt"
-> & {
- /**
- * asset_id is the asset that the comment exists on.
- */
- asset_id: string;
-};
+>;
export async function edit(
mongo: Db,
@@ -150,15 +145,22 @@ export async function edit(
input: EditComment,
req?: Request
) {
+ // Get the comment that we're editing.
+ let comment = await retrieveComment(mongo, tenant.id, input.id);
+ if (!comment) {
+ // TODO: replace to match error returned by the models/comments.ts
+ throw new Error("comment not found");
+ }
+
// Grab the asset that we'll use to check moderation pieces with.
- const asset = await retrieveAsset(mongo, tenant.id, input.asset_id);
+ const asset = await retrieveAsset(mongo, tenant.id, comment.asset_id);
if (!asset) {
// TODO: (wyattjoh) return better error.
throw new Error("asset referenced does not exist");
}
// Run the comment through the moderation phases.
- const { status } = await processForModeration({
+ const { status, metadata } = await processForModeration({
asset,
tenant,
comment: input,
@@ -168,11 +170,12 @@ export async function edit(
// TODO: (wyattjoh) use the actions somehow.
- const comment = await editComment(mongo, tenant.id, {
+ comment = await editComment(mongo, tenant.id, {
id: input.id,
author_id: author.id,
body: input.body,
status,
+ metadata,
// The editable time is based on the current time, and the edit window
// length. By subtracting the current date from the edit window length, we
// get the maximum value for the `created_at` time that would be permitted
diff --git a/src/locales/de/framework.ftl b/src/locales/de/framework.ftl
index 55dca1c8c..7ea668be3 100644
--- a/src/locales/de/framework.ftl
+++ b/src/locales/de/framework.ftl
@@ -6,10 +6,10 @@
framework-validation-required = Dies ist ein Pflichtpfeld.
+framework-timeago-just-now = Gerade eben
framework-timeago =
{ $suffix ->
[ago] vor
- [in] in
}
{ $value }
{ $unit ->
diff --git a/src/locales/en-US/framework.ftl b/src/locales/en-US/framework.ftl
index f03b2a50a..89eee969b 100644
--- a/src/locales/en-US/framework.ftl
+++ b/src/locales/en-US/framework.ftl
@@ -13,6 +13,8 @@ framework-validation-invalidEmail = Please enter a valid email address.
framework-validation-passwordTooShort = Password must contain at least {$minLength} characters.
framework-validation-passwordsDoNotMatch = Passwords do not match. Try again.
+framework-timeago-just-now = Just now
+
framework-timeago-time =
{ $value }
{ $unit ->
@@ -48,12 +50,7 @@ framework-timeago-time =
}
framework-timeago =
- { $value ->
- [0] now
- *[other]
- { $suffix ->
- [ago] {framework-timeago-time} ago
- [in] in {framework-timeago-time}
- *[other] unknown suffix
- }
+ { $suffix ->
+ [ago] {framework-timeago-time} ago
+ [noSuffix] {framework-timeago-time}
}
diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl
index 29aac39c9..1a2b81f5f 100644
--- a/src/locales/en-US/stream.ftl
+++ b/src/locales/en-US/stream.ftl
@@ -1,5 +1,17 @@
### Localization for Embed Stream
+## General
+
+general-userBoxUnauthenticated-joinTheConversation = Join the conversation
+general-userBoxUnauthenticated-signIn = Sign in
+general-userBoxUnauthenticated-register = Register
+
+general-userBoxAuthenticated-signedInAs =
+ Signed in as .
+
+general-userBoxAuthenticated-notYou =
+ Not you? Sign Out
+
## Comments Tab
comments-streamQuery-assetNotFound = Asset not found
@@ -14,16 +26,6 @@ comments-permalinkPopover-copied = Copied
comments-permalinkView-showAllComments = Show all comments
comments-permalinkView-commentNotFound = Comment not found
-comments-userBoxUnauthenticated-joinTheConversation = Join the conversation
-comments-userBoxUnauthenticated-signIn = Sign in
-comments-userBoxUnauthenticated-register = Register
-
-comments-userBoxAuthenticated-signedInAs =
- Signed in as .
-
-comments-userBoxAuthenticated-notYou =
- Not you? Sign Out
-
comments-rte-bold =
.title = Bold
@@ -53,4 +55,16 @@ comments-replyCommentForm-submit = Submit
comments-replyCommentForm-cancel = Cancel
comments-replyCommentForm-rteLabel = Write a reply
comments-replyCommentForm-rte =
- .placeholder = { comments-postCommentForm-rteLabel }
+ .placeholder = { comments-replyCommentForm-rteLabel }
+
+comments-commentContainer-editButton = Edit
+
+comments-editCommentForm-saveChanges = Save Changes
+comments-editCommentForm-cancel = Cancel
+comments-editCommentForm-close = Close
+comments-editCommentForm-rteLabel = Edit comment
+comments-editCommentForm-rte =
+ .placeholder = { comments-editCommentForm-rteLabel }
+comments-editCommentForm-editRemainingTime = Edit: remaining
+comments-editCommentForm-editTimeExpired = Edit time has expired. You can no longer edit this comment. Why not post another one?
+comments-editedMarker-edited = Edited
diff --git a/src/locales/es/framework.ftl b/src/locales/es/framework.ftl
index a383c75bb..86b5e6cb4 100644
--- a/src/locales/es/framework.ftl
+++ b/src/locales/es/framework.ftl
@@ -2,14 +2,12 @@
### All keys must start with `framework` because this file is shared
### among different targets.
+framework-timeago-just-now = Hace poco
framework-timeago =
{ $value ->
- [0] ahora
*[other]
{ $suffix ->
[ago] hace
- [in] en
- *[other] unknown suffix
}
{ $value }
{ $unit ->
diff --git a/src/types/react-timeago.d.ts b/src/types/react-timeago.d.ts
index 431bbf88e..02161745c 100644
--- a/src/types/react-timeago.d.ts
+++ b/src/types/react-timeago.d.ts
@@ -36,6 +36,7 @@ declare module "react-timeago" {
live?: boolean;
className: string;
formatter?: Formatter;
+ minPeriod?: number;
}
const TimeAgo: React.ComponentType;