From 6d7056d831a29510599ce3cccf19ffcc01829475 Mon Sep 17 00:00:00 2001 From: Kiwi Date: Thu, 2 Aug 2018 17:29:18 +0200 Subject: [PATCH] [next] Add support for embed (#1762) * Move talk-server/config to talk-common/config * Refactor build into /src/core/build and use common config * Add embed webpack config * Start implementing embed * Implement embed * Add pym types * Add event emitter to Talk Context * Add MatchMedia test for passing values from the context * Add support for click far away * Integrate pym click events to registerClickFarAway * Add tests * Resolve merge issues * Apply PR review --- config/env.js | 93 ---- config/jest.config.js | 4 +- .../client.config.js} | 4 +- .../server.config.js} | 2 +- config/{ => jest}/tsconfig.jest.json | 0 config/paths.js | 66 --- config/paths.ts | 15 + config/watcher.ts | 2 - config/webpack.config.dev.js | 334 ------------- config/webpack.config.prod.js | 388 --------------- ...r.config.js => webpackDevServer.config.ts} | 57 +-- doczrc.js | 8 +- package-lock.json | 354 +++++++++++++- package.json | 38 +- scripts/{build.js => build.ts} | 56 +-- scripts/{start.js => start.ts} | 72 ++- scripts/test.js | 14 +- src/core/build/createWebpackConfig.ts | 447 ++++++++++++++++++ .../core/build/loaders}/locales-loader.js | 0 src/core/build/paths.ts | 33 ++ {config => src/core/build}/polyfills.js | 0 {config => src/core/build}/postcss.config.js | 5 +- src/core/client/embed/PymControl.spec.ts | 55 +++ src/core/client/embed/PymControl.ts | 41 ++ src/core/client/embed/Stream.spec.ts | 70 +++ src/core/client/embed/Stream.ts | 83 ++++ .../__snapshots__/PymControl.spec.ts.snap | 5 + .../embed/__snapshots__/index.spec.ts.snap | 3 + src/core/client/embed/decorators/index.ts | 11 + .../embed/decorators/withAutoHeight.spec.ts | 16 + .../client/embed/decorators/withAutoHeight.ts | 14 + .../embed/decorators/withClickEvent.spec.ts | 19 + .../client/embed/decorators/withClickEvent.ts | 16 + .../embed/decorators/withCommentID.spec.ts | 36 ++ .../client/embed/decorators/withCommentID.ts | 36 ++ .../embed/decorators/withEventEmitter.spec.ts | 21 + .../embed/decorators/withEventEmitter.ts | 13 + .../withIOSSafariWidthWorkaround.spec.ts | 12 + .../withIOSSafariWidthWorkaround.ts | 9 + src/core/client/embed/index.html | 19 + src/core/client/embed/index.spec.ts | 23 + src/core/client/embed/index.ts | 30 ++ src/core/client/embed/tsconfig.json | 14 + src/core/client/embed/utils/buildURL.spec.ts | 18 + src/core/client/embed/utils/buildURL.ts | 17 + .../client/embed/utils/ensureEndSlash.spec.ts | 11 + src/core/client/embed/utils/ensureEndSlash.ts | 3 + src/core/client/embed/utils/index.ts | 2 + .../framework/lib/bootstrap/TalkContext.tsx | 25 +- .../framework/lib/bootstrap/createContext.tsx | 36 +- src/core/client/stream/index.html | 6 +- src/core/client/stream/index.tsx | 2 + src/core/client/tsconfig.json | 2 +- .../ClickOutside/ClickOutside.spec.tsx | 63 ++- .../components/ClickOutside/ClickOutside.tsx | 49 +- .../ui/components/ClickOutside/index.ts | 1 + .../components/MatchMedia/MatchMedia.spec.tsx | 23 +- .../ui/components/UIContext/UIContext.ts | 9 + src/core/{server => common}/config.ts | 4 + src/core/server/app/index.ts | 2 +- .../app/middleware/passport/jwt.spec.ts | 2 +- .../server/app/middleware/passport/jwt.ts | 2 +- .../server/graph/common/middleware/index.ts | 2 +- .../graph/common/subscriptions/pubsub.ts | 2 +- .../server/graph/management/middleware.ts | 2 +- src/core/server/graph/tenant/middleware.ts | 2 +- src/core/server/index.ts | 2 +- src/core/server/services/mongodb/index.ts | 2 +- src/core/server/services/redis/index.ts | 2 +- src/types/pym.d.ts | 174 +++++++ src/types/react-dev-utils.d.ts | 28 ++ tsconfig.json | 10 +- 72 files changed, 1995 insertions(+), 1046 deletions(-) delete mode 100644 config/env.js rename config/{jest-client.config.js => jest/client.config.js} (95%) rename config/{jest-server.config.js => jest/server.config.js} (96%) rename config/{ => jest}/tsconfig.jest.json (100%) delete mode 100644 config/paths.js create mode 100644 config/paths.ts delete mode 100644 config/webpack.config.dev.js delete mode 100644 config/webpack.config.prod.js rename config/{webpackDevServer.config.js => webpackDevServer.config.ts} (61%) rename scripts/{build.js => build.ts} (74%) rename scripts/{start.js => start.ts} (55%) create mode 100644 src/core/build/createWebpackConfig.ts rename {loaders => src/core/build/loaders}/locales-loader.js (100%) create mode 100644 src/core/build/paths.ts rename {config => src/core/build}/polyfills.js (100%) rename {config => src/core/build}/postcss.config.js (96%) create mode 100644 src/core/client/embed/PymControl.spec.ts create mode 100644 src/core/client/embed/PymControl.ts create mode 100644 src/core/client/embed/Stream.spec.ts create mode 100644 src/core/client/embed/Stream.ts create mode 100644 src/core/client/embed/__snapshots__/PymControl.spec.ts.snap create mode 100644 src/core/client/embed/__snapshots__/index.spec.ts.snap create mode 100644 src/core/client/embed/decorators/index.ts create mode 100644 src/core/client/embed/decorators/withAutoHeight.spec.ts create mode 100644 src/core/client/embed/decorators/withAutoHeight.ts create mode 100644 src/core/client/embed/decorators/withClickEvent.spec.ts create mode 100644 src/core/client/embed/decorators/withClickEvent.ts create mode 100644 src/core/client/embed/decorators/withCommentID.spec.ts create mode 100644 src/core/client/embed/decorators/withCommentID.ts create mode 100644 src/core/client/embed/decorators/withEventEmitter.spec.ts create mode 100644 src/core/client/embed/decorators/withEventEmitter.ts create mode 100644 src/core/client/embed/decorators/withIOSSafariWidthWorkaround.spec.ts create mode 100644 src/core/client/embed/decorators/withIOSSafariWidthWorkaround.ts create mode 100644 src/core/client/embed/index.html create mode 100644 src/core/client/embed/index.spec.ts create mode 100644 src/core/client/embed/index.ts create mode 100644 src/core/client/embed/tsconfig.json create mode 100644 src/core/client/embed/utils/buildURL.spec.ts create mode 100644 src/core/client/embed/utils/buildURL.ts create mode 100644 src/core/client/embed/utils/ensureEndSlash.spec.ts create mode 100644 src/core/client/embed/utils/ensureEndSlash.ts create mode 100644 src/core/client/embed/utils/index.ts create mode 100644 src/core/client/ui/components/ClickOutside/index.ts rename src/core/{server => common}/config.ts (95%) create mode 100644 src/types/pym.d.ts create mode 100644 src/types/react-dev-utils.d.ts diff --git a/config/env.js b/config/env.js deleted file mode 100644 index 2240e824f..000000000 --- a/config/env.js +++ /dev/null @@ -1,93 +0,0 @@ -"use strict"; - -const fs = require("fs"); -const path = require("path"); -const paths = require("./paths"); - -// Make sure that including paths.js after env.js will read .env variables. -delete require.cache[require.resolve("./paths")]; - -const NODE_ENV = process.env.NODE_ENV; -if (!NODE_ENV) { - throw new Error( - "The NODE_ENV environment variable is required but was not specified." - ); -} - -// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use -var dotenvFiles = [ - `${paths.dotenv}.${NODE_ENV}.local`, - `${paths.dotenv}.${NODE_ENV}`, - // Don't include `.env.local` for `test` environment - // since normally you expect tests to produce the same - // results for everyone - NODE_ENV !== "test" && `${paths.dotenv}.local`, - paths.dotenv, -].filter(Boolean); - -// Load environment variables from .env* files. Suppress warnings using silent -// if this file is missing. dotenv will never modify any environment variables -// that have already been set. Variable expansion is supported in .env files. -// https://github.com/motdotla/dotenv -// https://github.com/motdotla/dotenv-expand -dotenvFiles.forEach(dotenvFile => { - if (fs.existsSync(dotenvFile)) { - require("dotenv-expand")( - require("dotenv").config({ - path: dotenvFile, - }) - ); - } -}); - -// We support resolving modules according to `NODE_PATH`. -// This lets you use absolute paths in imports inside large monorepos: -// https://github.com/facebookincubator/create-react-app/issues/253. -// It works similar to `NODE_PATH` in Node itself: -// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders -// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. -// Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims. -// https://github.com/facebookincubator/create-react-app/issues/1023#issuecomment-265344421 -// We also resolve them to make sure all tools using them work consistently. -const appDirectory = fs.realpathSync(process.cwd()); -process.env.NODE_PATH = (process.env.NODE_PATH || "") - .split(path.delimiter) - .filter(folder => folder && !path.isAbsolute(folder)) - .map(folder => path.resolve(appDirectory, folder)) - .join(path.delimiter); - -// Grab NODE_ENV and TALK_* environment variables and prepare them to be -// injected into the application via DefinePlugin in Webpack configuration. -const REACT_APP = /^TALK_/i; - -function getClientEnvironment(publicUrl) { - const raw = Object.keys(process.env) - .filter(key => REACT_APP.test(key)) - .reduce( - (env, key) => { - env[key] = process.env[key]; - return env; - }, - { - // Useful for determining whether we’re running in production mode. - // Most importantly, it switches React into the correct mode. - NODE_ENV: process.env.NODE_ENV || "development", - // Useful for resolving the correct path to static assets in `public`. - // For example, . - // This should only be used as an escape hatch. Normally you would put - // images into the `src` and `import` them in code to get their paths. - PUBLIC_URL: publicUrl, - } - ); - // Stringify all values so we can feed into Webpack DefinePlugin - const stringified = { - "process.env": Object.keys(raw).reduce((env, key) => { - env[key] = JSON.stringify(raw[key]); - return env; - }, {}), - }; - - return { raw, stringified }; -} - -module.exports = getClientEnvironment; diff --git a/config/jest.config.js b/config/jest.config.js index 3e7166a29..3d4bf802b 100644 --- a/config/jest.config.js +++ b/config/jest.config.js @@ -1,6 +1,6 @@ module.exports = { projects: [ - "/jest-client.config.js", - "/jest-server.config.js", + "/jest/client.config.js", + "/jest/server.config.js", ], }; diff --git a/config/jest-client.config.js b/config/jest/client.config.js similarity index 95% rename from config/jest-client.config.js rename to config/jest/client.config.js index 3bfbd195c..d5cc83ce3 100644 --- a/config/jest-client.config.js +++ b/config/jest/client.config.js @@ -2,12 +2,12 @@ const path = require("path"); module.exports = { displayName: "client", - rootDir: "../", + rootDir: "../../", roots: ["/src/core/client"], collectCoverageFrom: ["**/*.{js,jsx,mjs,ts,tsx}"], coveragePathIgnorePatterns: ["/node_modules/"], setupFiles: [ - "/config/polyfills.js", + "/src/core/build/polyfills.js", "/src/core/client/test/setup.ts", ], testMatch: ["**/*.spec.{js,jsx,mjs,ts,tsx}"], diff --git a/config/jest-server.config.js b/config/jest/server.config.js similarity index 96% rename from config/jest-server.config.js rename to config/jest/server.config.js index 6ac9556bf..36376dd06 100644 --- a/config/jest-server.config.js +++ b/config/jest/server.config.js @@ -1,6 +1,6 @@ module.exports = { displayName: "server", - rootDir: "../", + rootDir: "../../", roots: ["/src"], collectCoverageFrom: ["**/*.{js,jsx,mjs,ts,tsx}"], coveragePathIgnorePatterns: ["/node_modules/"], diff --git a/config/tsconfig.jest.json b/config/jest/tsconfig.jest.json similarity index 100% rename from config/tsconfig.jest.json rename to config/jest/tsconfig.jest.json diff --git a/config/paths.js b/config/paths.js deleted file mode 100644 index 73cc43370..000000000 --- a/config/paths.js +++ /dev/null @@ -1,66 +0,0 @@ -"use strict"; - -// A script from `create-react-app` ejected `25.06.2018`. - -const path = require("path"); -const fs = require("fs"); -const url = require("url"); - -// Make sure any symlinks in the project folder are resolved: -// https://github.com/facebookincubator/create-react-app/issues/637 -const appDirectory = fs.realpathSync(process.cwd()); -const resolveApp = relativePath => path.resolve(appDirectory, relativePath); - -const envPublicUrl = process.env.PUBLIC_URL; - -function ensureSlash(p, needsSlash) { - const hasSlash = p.endsWith("/"); - if (hasSlash && !needsSlash) { - return p.substr(p, p.length - 1); - } else if (!hasSlash && needsSlash) { - return `${p}/`; - } else { - return p; - } -} - -const getPublicUrl = appPackageJson => - envPublicUrl || require(appPackageJson).homepage; - -// We use `PUBLIC_URL` environment variable or "homepage" field to infer -// "public path" at which the app is served. -// Webpack needs to know it to put the right + + + diff --git a/src/core/client/embed/index.spec.ts b/src/core/client/embed/index.spec.ts new file mode 100644 index 000000000..66613f1c7 --- /dev/null +++ b/src/core/client/embed/index.spec.ts @@ -0,0 +1,23 @@ +import * as Talk from "./"; + +describe("Basic integration test", () => { + const container: HTMLElement = document.createElement("div"); + let streamInterface: ReturnType; + beforeAll(() => { + container.id = "basic-integration-test-id"; + document.body.appendChild(container); + }); + afterAll(() => { + document.body.removeChild(container); + }); + it("should render iframe", () => { + streamInterface = Talk.render({ + id: "basic-integration-test-id", + }); + expect(container.innerHTML).toMatchSnapshot(); + }); + it("should remove iframe", () => { + streamInterface.remove(); + expect(container.innerHTML).toBe(""); + }); +}); diff --git a/src/core/client/embed/index.ts b/src/core/client/embed/index.ts new file mode 100644 index 000000000..6c6ebc36d --- /dev/null +++ b/src/core/client/embed/index.ts @@ -0,0 +1,30 @@ +import { EventEmitter2 } from "eventemitter2"; +import qs from "query-string"; + +import createStreamInterface from "./Stream"; + +export interface Config { + assetID?: string; + assetURL?: string; + rootURL?: string; + id?: string; + events?: (eventEmitter: EventEmitter2) => void; +} + +export function render(config: Config = {}) { + // Parse query params + const query = qs.parse(location.search); + const eventEmitter = new EventEmitter2({ wildcard: true }); + + if (config.events) { + config.events(eventEmitter); + } + + return createStreamInterface({ + assetID: config.assetID || query.assetID, + assetURL: config.assetURL || query.assetURL, + id: config.id || "talk-embed-stream", + rootURL: config.rootURL || location.origin, + eventEmitter, + }); +} diff --git a/src/core/client/embed/tsconfig.json b/src/core/client/embed/tsconfig.json new file mode 100644 index 000000000..11f8c14ce --- /dev/null +++ b/src/core/client/embed/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "lib": ["dom", "es5"], + "types": ["jest"], + "paths": {} + }, + "include": [ + "./**/*", + "../../../types/pym.d.ts", + "../../../types/simulant.d.ts" + ], + "exclude": ["node_modules"] +} diff --git a/src/core/client/embed/utils/buildURL.spec.ts b/src/core/client/embed/utils/buildURL.spec.ts new file mode 100644 index 000000000..07e4e2ddc --- /dev/null +++ b/src/core/client/embed/utils/buildURL.spec.ts @@ -0,0 +1,18 @@ +import buildURL from "./buildURL"; + +it("should default to window.location", () => { + const url = buildURL(); + expect(url).toBe("http://localhost/"); +}); + +it("should build from parameters", () => { + const url = buildURL({ + protocol: "https", + hostname: "hostname", + port: "8080", + pathname: "/pathname", + search: "search", + hash: "#hash", + }); + expect(url).toBe("https//hostname:8080/pathname?search#hash"); +}); diff --git a/src/core/client/embed/utils/buildURL.ts b/src/core/client/embed/utils/buildURL.ts new file mode 100644 index 000000000..77353427f --- /dev/null +++ b/src/core/client/embed/utils/buildURL.ts @@ -0,0 +1,17 @@ +export default function buildURL({ + protocol = window.location.protocol, + hostname = window.location.hostname, + port = window.location.port, + pathname = window.location.pathname, + search = window.location.search, + hash = window.location.hash, +} = {}) { + if (search && search[0] !== "?") { + search = `?${search}`; + } else if (search === "?") { + search = ""; + } + return `${protocol}//${hostname}${ + port ? `:${port}` : "" + }${pathname}${search}${hash}`; +} diff --git a/src/core/client/embed/utils/ensureEndSlash.spec.ts b/src/core/client/embed/utils/ensureEndSlash.spec.ts new file mode 100644 index 000000000..9ab3f8a94 --- /dev/null +++ b/src/core/client/embed/utils/ensureEndSlash.spec.ts @@ -0,0 +1,11 @@ +import ensureEndSlash from "./ensureEndSlash"; + +it("should add slash to the end", () => { + const path = ensureEndSlash("/test"); + expect(path).toBe("/test/"); +}); + +it("should not add slash to the end if it's already there", () => { + const path = ensureEndSlash("/test/"); + expect(path).toBe("/test/"); +}); diff --git a/src/core/client/embed/utils/ensureEndSlash.ts b/src/core/client/embed/utils/ensureEndSlash.ts new file mode 100644 index 000000000..4adf9a451 --- /dev/null +++ b/src/core/client/embed/utils/ensureEndSlash.ts @@ -0,0 +1,3 @@ +export default function ensureEndSlash(p: string) { + return p.match(/\/$/) ? p : `${p}/`; +} diff --git a/src/core/client/embed/utils/index.ts b/src/core/client/embed/utils/index.ts new file mode 100644 index 000000000..0cf1aaa7f --- /dev/null +++ b/src/core/client/embed/utils/index.ts @@ -0,0 +1,2 @@ +export { default as buildURL } from "./buildURL"; +export { default as ensureEndSlash } from "./ensureEndSlash"; diff --git a/src/core/client/framework/lib/bootstrap/TalkContext.tsx b/src/core/client/framework/lib/bootstrap/TalkContext.tsx index d14c245f5..90ed10530 100644 --- a/src/core/client/framework/lib/bootstrap/TalkContext.tsx +++ b/src/core/client/framework/lib/bootstrap/TalkContext.tsx @@ -1,19 +1,31 @@ import { LocalizationProvider } from "fluent-react/compat"; import { MessageContext } from "fluent/compat"; +import { Child as PymChild } from "pym.js"; import React, { StatelessComponent } from "react"; import { Formatter } from "react-timeago"; import { Environment } from "relay-runtime"; + import { UIContext } from "talk-ui/components"; +import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside"; export interface TalkContext { - // relayEnvironment for our relay framework. + /** relayEnvironment for our relay framework. */ relayEnvironment: Environment; - // localMessages for our i18n framework. + /** localMessages for our i18n framework. */ localeMessages: MessageContext[]; - // formatter for timeago. + /** formatter for timeago. */ timeagoFormatter?: Formatter; + + /** + * A way to listen for clicks that are e.g. outside of the + * current frame for `ClickOutside` + */ + registerClickFarAway?: ClickFarAwayRegister; + + /** A pym child that interacts with the pym parent. */ + pym?: PymChild; } const { Provider, Consumer } = React.createContext({} as any); @@ -32,7 +44,12 @@ export const TalkContextProvider: StatelessComponent<{ }> = ({ value, children }) => ( - + {children} diff --git a/src/core/client/framework/lib/bootstrap/createContext.tsx b/src/core/client/framework/lib/bootstrap/createContext.tsx index 6043eacb9..2e6bd2aff 100644 --- a/src/core/client/framework/lib/bootstrap/createContext.tsx +++ b/src/core/client/framework/lib/bootstrap/createContext.tsx @@ -1,22 +1,32 @@ +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 { ClickFarAwayRegister } from "talk-ui/components/ClickOutside"; + import { generateMessages, LocalesData, negotiateLanguages } from "../i18n"; import { fetchQuery } from "../network"; import { TalkContext } from "./TalkContext"; interface CreateContextArguments { - // Locales that the user accepts, usually `navigator.languages`. + /** Locales that the user accepts, usually `navigator.languages`. */ userLocales: ReadonlyArray; - // Locales data that is returned by our `locales-loader`. + /** Locales data that is returned by our `locales-loader`. */ localesData: LocalesData; - // Init will be called after the context has been created. + /** 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; } /** @@ -47,6 +57,8 @@ export default async function createContext({ init = noop, userLocales, localesData, + pym, + eventEmitter = new EventEmitter2({ wildcard: true }), }: CreateContextArguments): Promise { // Initialize Relay. const relayEnvironment = new Environment({ @@ -54,6 +66,21 @@ export default async function createContext({ store: new Store(new RecordSource()), }); + // 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); @@ -69,6 +96,9 @@ export default async function createContext({ relayEnvironment, localeMessages, timeagoFormatter, + pym, + eventEmitter, + registerClickFarAway, }; // Run custom initializations. diff --git a/src/core/client/stream/index.html b/src/core/client/stream/index.html index 7578f176f..5c2f7b60e 100644 --- a/src/core/client/stream/index.html +++ b/src/core/client/stream/index.html @@ -1,15 +1,15 @@ - + - Relay Experiments + Talk - Stream -
+
diff --git a/src/core/client/stream/index.tsx b/src/core/client/stream/index.tsx index 584e5e230..c6f9059e0 100644 --- a/src/core/client/stream/index.tsx +++ b/src/core/client/stream/index.tsx @@ -1,3 +1,4 @@ +import pym from "pym.js"; import React from "react"; import { StatelessComponent } from "react"; import ReactDOM from "react-dom"; @@ -23,6 +24,7 @@ async function main() { init, localesData, userLocales: navigator.languages, + pym: new pym.Child({ polling: 100 }), }); const Index: StatelessComponent = () => ( diff --git a/src/core/client/tsconfig.json b/src/core/client/tsconfig.json index a62cea6c5..847c9f1c5 100644 --- a/src/core/client/tsconfig.json +++ b/src/core/client/tsconfig.json @@ -16,5 +16,5 @@ } }, "include": ["./**/*", "../../types/**/*.d.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "./embed"] } diff --git a/src/core/client/ui/components/ClickOutside/ClickOutside.spec.tsx b/src/core/client/ui/components/ClickOutside/ClickOutside.spec.tsx index fd5ba04b8..e7da49bda 100644 --- a/src/core/client/ui/components/ClickOutside/ClickOutside.spec.tsx +++ b/src/core/client/ui/components/ClickOutside/ClickOutside.spec.tsx @@ -3,7 +3,14 @@ import React from "react"; import simulant from "simulant"; import sinon from "sinon"; -import ClickOutside from "./ClickOutside"; +import UIContext from "../UIContext"; + +import { + ClickFarAwayCallback, + ClickFarAwayRegister, + ClickOutside, + default as ClickOutsideWithContext, +} from "./ClickOutside"; let container: HTMLElement; @@ -60,3 +67,57 @@ it("should ignore click inside", () => { expect(onClickOutside.calledOnce).toEqual(false); wrapper.unmount(); }); + +it("should detect click far away", () => { + let emitFarAwayClick: ClickFarAwayCallback = Function; + const unlisten = sinon.spy(); + const registerClickFarAway: ClickFarAwayRegister = cb => { + emitFarAwayClick = cb; + return unlisten; + }; + const onClickOutside = sinon.spy(); + const wrapper = mount( + + + , + { + attachTo: container, + } + ); + + expect(onClickOutside.calledOnce).toEqual(false); + emitFarAwayClick(); + expect(onClickOutside.calledOnce).toEqual(true); + expect(unlisten.calledOnce).toEqual(false); + wrapper.unmount(); + expect(unlisten.calledOnce).toEqual(true); +}); + +it("should get registerClickFarAway from context", () => { + const registerClickFarAway: ClickFarAwayRegister = sinon.spy(); + const onClickOutside = sinon.spy(); + const context: any = { + registerClickFarAway, + }; + const wrapper = mount( + + + + + , + { + attachTo: container, + } + ); + + expect(wrapper.find(ClickOutside).prop("registerClickFarAway")).toEqual( + registerClickFarAway + ); + wrapper.unmount(); +}); diff --git a/src/core/client/ui/components/ClickOutside/ClickOutside.tsx b/src/core/client/ui/components/ClickOutside/ClickOutside.tsx index cf13762f0..2d79994ad 100644 --- a/src/core/client/ui/components/ClickOutside/ClickOutside.tsx +++ b/src/core/client/ui/components/ClickOutside/ClickOutside.tsx @@ -1,13 +1,30 @@ -import React from "react"; +import React, { StatelessComponent } from "react"; import { findDOMNode } from "react-dom"; +import UIContext from "../UIContext"; + +export type ClickFarAwayCallback = () => void; +export type ClickFarAwayUnlistenCallback = () => void; + +export type ClickFarAwayRegister = ( + callback: ClickFarAwayCallback +) => ClickFarAwayUnlistenCallback; + interface Props { onClickOutside: () => void; + + /** + * A way to listen for clicks that are e.g. outside of the + * current frame for `ClickOutside` + */ + registerClickFarAway?: ClickFarAwayRegister; + children: React.ReactNode; } -class ClickOutside extends React.Component { +export class ClickOutside extends React.Component { public domNode: Element | null = null; + private unlisten?: ClickFarAwayUnlistenCallback; public handleClick = (e: MouseEvent) => { const { onClickOutside } = this.props; @@ -17,17 +34,43 @@ class ClickOutside extends React.Component { } }; + public handleClickFarAway = () => { + const { onClickOutside } = this.props; + // tslint:disable-next-line:no-unused-expression + onClickOutside && onClickOutside(); + }; + public componentDidMount() { this.domNode = findDOMNode(this) as Element; document.addEventListener("click", this.handleClick, true); + + // Listen to far away clicks. + if (this.props.registerClickFarAway) { + this.unlisten = this.props.registerClickFarAway(this.handleClickFarAway); + } } public componentWillUnmount() { document.removeEventListener("click", this.handleClick, true); + + // Unlisten to far away clicks. + if (this.unlisten) { + this.unlisten(); + this.unlisten = undefined; + } } public render() { return this.props.children; } } -export default ClickOutside; + +const ClickOutsideWithContext: StatelessComponent = props => ( + + {({ registerClickFarAway }) => ( + + )} + +); + +export default ClickOutsideWithContext; diff --git a/src/core/client/ui/components/ClickOutside/index.ts b/src/core/client/ui/components/ClickOutside/index.ts new file mode 100644 index 000000000..b70132125 --- /dev/null +++ b/src/core/client/ui/components/ClickOutside/index.ts @@ -0,0 +1 @@ +export { default as ClickOutside, ClickFarAwayRegister } from "./ClickOutside"; diff --git a/src/core/client/ui/components/MatchMedia/MatchMedia.spec.tsx b/src/core/client/ui/components/MatchMedia/MatchMedia.spec.tsx index 1e600dd76..6f53df511 100644 --- a/src/core/client/ui/components/MatchMedia/MatchMedia.spec.tsx +++ b/src/core/client/ui/components/MatchMedia/MatchMedia.spec.tsx @@ -1,9 +1,11 @@ -import { shallow } from "enzyme"; +import { mount, shallow } from "enzyme"; import React from "react"; +import { MediaQueryMatchers } from "react-responsive"; import { PropTypesOf } from "talk-ui/types"; -import { MatchMedia } from "./MatchMedia"; +import UIContext from "../UIContext"; +import { default as MatchMediaWithContext, MatchMedia } from "./MatchMedia"; it("renders correctly", () => { const props: PropTypesOf = { @@ -25,3 +27,20 @@ it("map new speech prop to older aural prop", () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); + +it("should get mediaQueryValues from context", () => { + const mediaQueryValues: Partial = { + width: 100, + }; + const context: any = { + mediaQueryValues, + }; + const wrapper = mount( + + + Hello World + + + ); + expect(wrapper.find(MatchMedia).prop("values")).toEqual(mediaQueryValues); +}); diff --git a/src/core/client/ui/components/UIContext/UIContext.ts b/src/core/client/ui/components/UIContext/UIContext.ts index b8372d1f3..6e734d277 100644 --- a/src/core/client/ui/components/UIContext/UIContext.ts +++ b/src/core/client/ui/components/UIContext/UIContext.ts @@ -2,9 +2,18 @@ import React from "react"; import { MediaQueryMatchers } from "react-responsive"; import { Formatter } from "react-timeago"; +import { ClickFarAwayRegister } from "../ClickOutside"; + export interface UIContextProps { + /** Allows to integrate translated strings into `RelativeTime` Component */ timeagoFormatter?: Formatter | null; + /** Allows testing `MatchMedia` by setting media query values */ mediaQueryValues?: Partial; + /** + * A way to listen for clicks that are e.g. outside of the + * current frame for `ClickOutside` + */ + registerClickFarAway?: ClickFarAwayRegister; } const UIContext = React.createContext({} as any); diff --git a/src/core/server/config.ts b/src/core/common/config.ts similarity index 95% rename from src/core/server/config.ts rename to src/core/common/config.ts index d4b5ca3fc..099bcc4b2 100644 --- a/src/core/server/config.ts +++ b/src/core/common/config.ts @@ -90,5 +90,9 @@ const config = convict({ export type Config = typeof config; +export const createClientEnv = (c: Config) => ({ + NODE_ENV: c.get("env"), +}); + // Setup the base configuration. export default config; diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index 4594c9e81..d1310eb41 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -3,10 +3,10 @@ import http from "http"; import { Redis } from "ioredis"; import { Db } from "mongodb"; +import { Config } from "talk-common/config"; import { notFoundMiddleware } from "talk-server/app/middleware/notFound"; import { createPassport } from "talk-server/app/middleware/passport"; import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; -import { Config } from "talk-server/config"; import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware"; import { Schemas } from "talk-server/graph/schemas"; diff --git a/src/core/server/app/middleware/passport/jwt.spec.ts b/src/core/server/app/middleware/passport/jwt.spec.ts index fa7b87d9d..bede65426 100644 --- a/src/core/server/app/middleware/passport/jwt.spec.ts +++ b/src/core/server/app/middleware/passport/jwt.spec.ts @@ -1,11 +1,11 @@ import sinon from "sinon"; +import { Config } from "talk-common/config"; import { createJWTSigningConfig, extractJWTFromRequest, parseAuthHeader, } from "talk-server/app/middleware/passport/jwt"; -import { Config } from "talk-server/config"; import { Request } from "talk-server/types/express"; describe("parseAuthHeader", () => { diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts index b47bd06dd..569c005b5 100644 --- a/src/core/server/app/middleware/passport/jwt.ts +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -3,7 +3,7 @@ import uuid from "uuid"; import { Db } from "mongodb"; import { Strategy } from "passport-strategy"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; import { retrieveUser, User } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; diff --git a/src/core/server/graph/common/middleware/index.ts b/src/core/server/graph/common/middleware/index.ts index 361bc7073..4d094f59a 100644 --- a/src/core/server/graph/common/middleware/index.ts +++ b/src/core/server/graph/common/middleware/index.ts @@ -5,7 +5,7 @@ import { GraphQLOptions, } from "apollo-server-express"; import { FieldDefinitionNode, GraphQLError, ValidationContext } from "graphql"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; // Sourced from: https://github.com/apollographql/apollo-server/blob/958846887598491fadea57b3f9373d129300f250/packages/apollo-server-core/src/ApolloServer.ts#L46-L57 const NoIntrospection = (context: ValidationContext) => ({ diff --git a/src/core/server/graph/common/subscriptions/pubsub.ts b/src/core/server/graph/common/subscriptions/pubsub.ts index 89ede4771..ae716a556 100644 --- a/src/core/server/graph/common/subscriptions/pubsub.ts +++ b/src/core/server/graph/common/subscriptions/pubsub.ts @@ -1,5 +1,5 @@ import { RedisPubSub } from "graphql-redis-subscriptions"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; import { createRedisClient } from "talk-server/services/redis"; export async function createPubSub(config: Config): Promise { diff --git a/src/core/server/graph/management/middleware.ts b/src/core/server/graph/management/middleware.ts index def73c2e9..8a62eed63 100644 --- a/src/core/server/graph/management/middleware.ts +++ b/src/core/server/graph/management/middleware.ts @@ -1,7 +1,7 @@ import { GraphQLSchema } from "graphql"; import { Db } from "mongodb"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; import { graphqlMiddleware } from "talk-server/graph/common/middleware"; import Context from "./context"; diff --git a/src/core/server/graph/tenant/middleware.ts b/src/core/server/graph/tenant/middleware.ts index f583c6d7a..f27e2430e 100644 --- a/src/core/server/graph/tenant/middleware.ts +++ b/src/core/server/graph/tenant/middleware.ts @@ -1,7 +1,7 @@ import { GraphQLSchema } from "graphql"; import { Db } from "mongodb"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; import { graphqlMiddleware } from "talk-server/graph/common/middleware"; import { Request } from "talk-server/types/express"; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 56e92f7f7..3ef89bde4 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -1,13 +1,13 @@ import express, { Express } from "express"; import http from "http"; +import config, { Config } from "talk-common/config"; import { createJWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import getManagementSchema from "talk-server/graph/management/schema"; import { Schemas } from "talk-server/graph/schemas"; import getTenantSchema from "talk-server/graph/tenant/schema"; import { attachSubscriptionHandlers, createApp, listenAndServe } from "./app"; -import config, { Config } from "./config"; import logger from "./logger"; import { createMongoDB } from "./services/mongodb"; import { createRedisClient } from "./services/redis"; diff --git a/src/core/server/services/mongodb/index.ts b/src/core/server/services/mongodb/index.ts index 03f4d21a6..c5fe7c1df 100644 --- a/src/core/server/services/mongodb/index.ts +++ b/src/core/server/services/mongodb/index.ts @@ -1,5 +1,5 @@ import { Db, MongoClient } from "mongodb"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; /** * create will connect to the MongoDB instance identified in the configuration. diff --git a/src/core/server/services/redis/index.ts b/src/core/server/services/redis/index.ts index 14b977b59..8ec7fbb65 100644 --- a/src/core/server/services/redis/index.ts +++ b/src/core/server/services/redis/index.ts @@ -1,5 +1,5 @@ import RedisClient, { Redis } from "ioredis"; -import { Config } from "talk-server/config"; +import { Config } from "talk-common/config"; /** * create will connect to the Redis instance identified in the configuration. diff --git a/src/types/pym.d.ts b/src/types/pym.d.ts new file mode 100644 index 000000000..4c3285192 --- /dev/null +++ b/src/types/pym.d.ts @@ -0,0 +1,174 @@ +declare module "pym.js" { + export interface ChildSettings { + /** + * Callback invoked after receiving a resize event from the parent, + * sets module:pym.Child#settings.renderCallback + */ + renderCallback?: Function; + /** xdomain to validate messages received */ + xdomain?: string; + /** polling frequency in milliseconds to send height to parent */ + polling?: number; + /** + * parent container id used when navigating the child + * iframe to a new page but we want to keep it responsive. + */ + id?: string; + /** + * if passed it will be override the default parentUrl query string + * parameter name expected on the iframe src + */ + parenturlparam?: string; + } + + /** The Child half of a responsive iframe. */ + export class Child { + /** The id of the parent container */ + id: string; + + /** The timerId in order to be able to stop when polling is enabled */ + timerId: string; + + /** The initial width of the parent page */ + parentWidth: string; + + /** The URL of the parent page from window.location.href. */ + parentUrl: string; + + /** The title of the parent page from document.title. */ + parentTitle: string; + + /** Stores the registered messageHandlers for each messageType */ + messageHandlers: Record void>>; + + /** RegularExpression to validate the received messages */ + messageRegex: RegExp; + + constructor(config: ChildSettings); + + /** Navigate parent to a given url. */ + navigateParentTo(url: string): void; + + /** + * Bind a callback to a given messageType from the child. + * Reserved message names are: "width". + */ + onMessage(messageType: string, callback: (message: string) => void): void; + + /** Unbind child event handlers and timers. */ + remove(): void; + + /** Scroll parent to a given element id. */ + scrollParentTo(hash: string): void; + + /** Scroll parent to a given child element id. */ + scrollParentToChildEl(id: string): void; + + /** Scroll parent to a particular child offset. */ + scrollParentToChildPos(pos: number): void; + + /** Transmit the current iframe height to the parent. */ + sendHeight(): void; + + /** Send a message to the the Parent. */ + sendMessage(messageType: string, message: string): void; + } + + export interface ParentSettings { + /** xdomain to validate messages received */ + xdomain?: string; + /** if passed it will be assigned to the iframe title attribute */ + title?: string; + /** if passed it will be assigned to the iframe name attribute */ + name?: string; + /** if passed it will be assigned to the iframe id attribute */ + id?: string; + /** if passed it will be assigned to the iframe allowfullscreen attribute */ + allowfullscreen?: boolean; + /** + * if passed it will be assigned to the iframe sandbox attribute + * (we do not validate the syntax so be careful!!) + */ + sandbox?: boolean; + /** + * if passed it will be override the default parentUrl query string + * parameter name passed to the iframe src + */ + parenturlparam?: string; + /** + * if passed it will be override the default parentUrl query string + * parameter value passed to the iframe src + */ + parenturlvalue?: string; + /** + * if passed and different than false it will strip the querystring + * params parentUrl and parentTitle passed to the iframe src + */ + optionalparams?: string; + /** if passed it will activate scroll tracking on the parent */ + trackscroll?: boolean; + /** + * if passed it will set the throttle wait in order to fire + * scroll messaging. Defaults to 100 ms. + */ + scrollwait?: number; + } + + /** The Parent half of a response iframe. */ + export class Parent { + /** The container DOM object */ + el: HTMLElement; + + /** The id of the container element */ + id: string; + + /** The contained child iframe */ + iframe: HTMLElement; + + /** Stores the registered messageHandlers for each messageType */ + messageHandlers: Record void>>; + + /** RegularExpression to validate the received messages */ + messageRegex: RegExp; + + /** + * The parent instance settings, updated by the values + * passed in the config object + */ + settings: ParentSettings; + + /** The url that will be set as the iframe's src */ + url: string; + + constructor(id: string, url: string, config: ParentSettings); + + /** + * Bind a callback to a given messageType from the child. + * Reserved message names are: "height", "scrollTo" and "navigateTo". + */ + onMessage(messageType: string, callback: (message: string) => void): void; + + /** Remove this parent from the page and unbind it's event handlers. */ + remove(): void; + + /** Send a message to the the child. */ + sendMessage(messageType: string, message: string): void; + + /** + * Transmit the current viewport and iframe position to the child. + * Sends viewport width, viewport height + * and iframe bounding rect top-left-bottom-right + * all separated by spaces + * + * You shouldn't need to call this directly. + */ + sendViewportAndIFramePosition(): void; + + /** + * Transmit the current iframe width to the child. + * You shouldn't need to call this directly. + */ + sendWidth(): void; + } + export function autoInit(doNotRaiseEvents: boolean): void; +} diff --git a/src/types/react-dev-utils.d.ts b/src/types/react-dev-utils.d.ts new file mode 100644 index 000000000..946e07665 --- /dev/null +++ b/src/types/react-dev-utils.d.ts @@ -0,0 +1,28 @@ +declare module "react-dev-utils/InterpolateHtmlPlugin" { + import { Plugin } from "webpack"; + export default class InterpolateHtmlPlugin extends Plugin { + constructor(env: Record); + } +} + +declare module "react-dev-utils/ModuleScopePlugin" { + import { Plugin } from "webpack"; + export default class ModuleScopePlugin extends Plugin { + constructor(rootPath: string, ignore: ReadonlyArray); + } +} + +declare module "react-dev-utils/WatchMissingNodeModulesPlugin" { + import { Plugin } from "webpack"; + export default class ModuleScopePlugin extends Plugin { + constructor(nodeModulesPath: string); + } +} + +declare module "react-dev-utils/errorOverlayMiddleware"; +declare module "react-dev-utils/ignoredFiles"; +declare module "react-dev-utils/noopServiceWorkerMiddleware"; +declare module "react-dev-utils/WebpackDevServerUtils"; +declare module "react-dev-utils/FileSizeReporter"; +declare module "react-dev-utils/formatWebpackMessages"; +declare module "react-dev-utils/printBuildError"; diff --git a/tsconfig.json b/tsconfig.json index bee79d2d0..3ddce16aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,8 +13,14 @@ "noImplicitAny": true, "strictNullChecks": true, "noErrorTruncation": true, - "lib": ["es6", "esnext.asynciterable"] + "lib": ["dom", "es6", "esnext.asynciterable"] }, - "include": ["./src/**/.*.js", "./scripts/**/*", "./config/**/*", "*.js"], + "include": [ + "./src/**/.*.js", + "./src/types/**/*.d.ts", + "./scripts/**/*", + "./config/**/*", + "*.js" + ], "exclude": ["node_modules"] }