Merge client into next (#1709)

* Merge client
* Add linting script
* Rename serve to start:development
* Move error harmonization and handling to network layer
* Show Comment Stream
* Added initial test
This commit is contained in:
Kiwi
2018-06-27 19:06:30 -03:00
committed by Wyatt Johnson
parent 68794d5919
commit 65c8da0f34
122 changed files with 21057 additions and 42 deletions
+13 -2
View File
@@ -1,5 +1,16 @@
node_modules
dist
.env
*.js
yarn.lock
npm-debug.log*
yarn-error.log
yarn.lock
coverage
.idea/
.docz
*.swp
*.DS_STORE
*.css.d.ts
__generated__
-5
View File
@@ -1,5 +0,0 @@
{
"execMap": {
"ts": "ts-node"
}
}
-1
View File
@@ -1 +0,0 @@
dist
+1 -1
View File
@@ -1,3 +1,3 @@
{
"trailingComma": "es5"
"trailingComma": "es5"
}
+93
View File
@@ -0,0 +1,93 @@
"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 were 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, <img src={process.env.PUBLIC_URL + '/img/logo.png'} />.
// 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;
+41
View File
@@ -0,0 +1,41 @@
const paths = require("./paths");
module.exports = {
rootDir: "../",
roots: ["<rootDir>/src", "<rootDir>/scripts"],
collectCoverageFrom: [
"src/**/*.{js,jsx,mjs,ts,tsx}"
],
coveragePathIgnorePatterns: ["/node_modules/"],
setupFiles: [
"<rootDir>/config/polyfills.js"
],
testMatch: [
"**/__tests__/**/*.{js,jsx,mjs,ts,tsx}",
"**/*.(spec|test).{js,jsx,mjs,ts,tsx}"
],
testEnvironment: "node",
testURL: "http://localhost",
transform: {
"^.+\\.(js|jsx|mjs|ts|tsx)$": "<rootDir>/node_modules/ts-jest",
"^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
"^(?!.*\\.(js|jsx|mjs|css|json|ftl)$)": "<rootDir>/config/jest/fileTransform.js"
},
transformIgnorePatterns: [
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|ts|tsx)$"
],
moduleNameMapper: {
"^react-native$": "react-native-web"
},
moduleFileExtensions: [
"web.js",
"js",
"json",
"web.jsx",
"jsx",
"node",
"mjs",
"ts",
"tsx"
],
}
+14
View File
@@ -0,0 +1,14 @@
'use strict';
// This is a custom Jest transformer turning style imports into empty objects.
// http://facebook.github.io/jest/docs/en/webpack.html
module.exports = {
process() {
return 'module.exports = {};';
},
getCacheKey() {
// The output is always the same.
return 'cssTransform';
},
};
+12
View File
@@ -0,0 +1,12 @@
'use strict';
const path = require('path');
// This is a custom Jest transformer turning file imports into filenames.
// http://facebook.github.io/jest/docs/en/webpack.html
module.exports = {
process(src, filename) {
return `module.exports = ${JSON.stringify(path.basename(filename))};`;
},
};
+8
View File
@@ -0,0 +1,8 @@
{
"exec": "npm-run-all compile:relay-stream",
"ext": "ts,tsx,graphql",
"watch": [
"./src/core/client/stream",
"./src/core/**/*.graphql"
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"exec": "npm run start:development",
"ext": "ts,graphql",
"watch": [
"./src"
],
"ignore": [
"./src/client"
]
}
+65
View File
@@ -0,0 +1,65 @@
"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 <script> hrefs into HTML even in
// single-page apps that may serve index.html for nested URLs like /todos/42.
// We can't use a relative path in HTML because we don't want to load something
// like /todos/42/static/js/bundle.7289d.js. We have to know the root.
function getServedPath(appPackageJson) {
const publicUrl = getPublicUrl(appPackageJson);
const servedUrl =
envPublicUrl || (publicUrl ? url.parse(publicUrl).pathname : "/");
return ensureSlash(servedUrl, true);
}
// config after eject: we're in ./config/
module.exports = {
dotenv: resolveApp(".env"),
appPostCssConfig: resolveApp("config/postcss.config.js"),
appJestConfig: resolveApp("config/jest.config.js"),
appLoaders: resolveApp("loaders"),
appDist: resolveApp("dist"),
appPublic: resolveApp("public"),
appPackageJson: resolveApp("package.json"),
appSrc: resolveApp("src"),
appTsconfig: resolveApp("src/core/client/tsconfig.json"),
appLocales: resolveApp("src/locales"),
appThemeVariables: resolveApp("src/core/client/ui/theme/variables.ts"),
testsSetup: resolveApp("src/setupTests.js"),
appNodeModules: resolveApp("node_modules"),
publicUrl: getPublicUrl(resolveApp("package.json")),
servedPath: getServedPath(resolveApp("package.json")),
appStreamHtml: resolveApp("src/core/client/stream/index.html"),
appStreamLocalesTemplate: resolveApp("src/core/client/stream/locales.ts"),
appStreamIndex: resolveApp("src/core/client/stream/index.tsx"),
};
+1
View File
@@ -0,0 +1 @@
require("@babel/polyfill");
+32
View File
@@ -0,0 +1,32 @@
const precss = require("precss");
const autoprefixer = require("autoprefixer");
const fontMagician = require("postcss-font-magician");
const kebabCase = require("lodash/kebabCase");
const mapKeys = require("lodash/mapKeys");
const flat = require("flat");
const flexbugsFixes = require("postcss-flexbugs-fixes");
const paths = require('./paths');
delete require.cache[paths.appThemeVariables];
const variables = require(paths.appThemeVariables);
const flatKebabVariables = mapKeys(flat(variables, {delimiter: "-"}), (_, k) => kebabCase(k));
module.exports = {
// Necessary for external CSS imports to work
// https://github.com/facebookincubator/create-react-app/issues/2677
ident: 'postcss',
plugins: [
precss({ variables: flatKebabVariables }),
fontMagician(),
flexbugsFixes,
autoprefixer({
browsers: [
'>1%',
'last 4 versions',
'Firefox ESR',
'not ie < 9', // React doesn't support IE8 anyway
],
flexbox: 'no-2009',
}),
],
};
+328
View File
@@ -0,0 +1,328 @@
"use strict";
const autoprefixer = require("autoprefixer");
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const CaseSensitivePathsPlugin = require("case-sensitive-paths-webpack-plugin");
const InterpolateHtmlPlugin = require("react-dev-utils/InterpolateHtmlPlugin");
const WatchMissingNodeModulesPlugin = require("react-dev-utils/WatchMissingNodeModulesPlugin");
const ModuleScopePlugin = require("react-dev-utils/ModuleScopePlugin");
const getClientEnvironment = require("./env");
const paths = require("./paths");
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
// Webpack uses `publicPath` to determine where the app is being served from.
// In development, we always serve from the root. This makes config easier.
const publicPath = "/";
// `publicUrl` is just like `publicPath`, but we will provide it to our app
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
// Omit trailing slash as %PUBLIC_PATH%/xyz looks better than %PUBLIC_PATH%xyz.
const publicUrl = "";
// Get environment variables to inject into our app.
const env = getClientEnvironment(publicUrl);
// This is the development configuration.
// It is focused on developer experience and fast rebuilds.
// The production configuration is different and lives in a separate file.
module.exports = {
// Set webpack mode.
mode: process.env.NODE_ENV === "production" ? "production" : "development",
// You may want 'eval' instead if you prefer to see the compiled output in DevTools.
// See the discussion in https://github.com/facebookincubator/create-react-app/issues/343.
devtool: "cheap-module-source-map",
// These are the "entry points" to our application.
// This means they will be the "root" imports that are included in JS bundle.
// The first two entry points enable "hot" CSS and auto-refreshes for JS.
entry: [
// We ship polyfills by default:
require.resolve("./polyfills"),
// Include an alternative client for WebpackDevServer. A client's job is to
// connect to WebpackDevServer by a socket and get notified about changes.
// When you save a file, the client will either apply hot updates (in case
// of CSS changes), or refresh the page (in case of JS changes). When you
// make a syntax error, this client will display a syntax error overlay.
// Note: instead of the default WebpackDevServer client, we use a custom one
// to bring better experience for Create React App users. You can replace
// the line below with these two lines if you prefer the stock client:
// require.resolve('webpack-dev-server/client') + '?/',
// require.resolve('webpack/hot/dev-server'),
require.resolve("react-dev-utils/webpackHotDevClient"),
// Finally, this is your app's code:
paths.appStreamIndex,
// We include the app code last so that if there is a runtime error during
// initialization, it doesn't blow up the WebpackDevServer client, and
// changing JS code would still trigger a refresh.
],
output: {
// Add /* filename */ comments to generated require()s in the output.
pathinfo: true,
// This does not produce a real file. It's just the virtual path that is
// served by WebpackDevServer in development. This is the JS bundle
// containing code from all our entry points, and the Webpack runtime.
filename: "static/js/bundle.js",
// There are also additional JS chunk files if you use code splitting.
chunkFilename: "static/js/[name].chunk.js",
// This is the URL that app is served from. We use "/" in development.
publicPath: publicPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
devtoolModuleFilenameTemplate: info =>
path.resolve(info.absoluteResourcePath).replace(/\\/g, "/"),
},
resolve: {
// This allows you to set a fallback for where Webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebookincubator/create-react-app/issues/253
modules: ["node_modules", paths.appNodeModules].concat(
// It is guaranteed to exist because we tweak it in `env.js`
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
),
// These are the reasonable defaults supported by the Node ecosystem.
// We also include JSX as a common component filename extension to support
// some tools, although we do not recommend using it, see:
// https://github.com/facebookincubator/create-react-app/issues/290
// `web` extension prefixes have been added for better support
// for React Native Web.
extensions: [
".web.js",
".mjs",
".js",
".json",
".web.jsx",
".jsx",
".ts",
".tsx",
],
alias: {
// Support React Native Web
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
"react-native": "react-native-web",
},
plugins: [
// Prevents users from importing files from outside of src/ (or node_modules/).
// This often causes confusion because we only process files within src/ with babel.
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
// please link the files into your node_modules/ and let module-resolution kick in.
// Make sure your source files are compiled, as they will not be processed in any way.
new ModuleScopePlugin(paths.appSrc, [paths.appPackageJson]),
// Support `tsconfig.json` `path` setting.
new TsconfigPathsPlugin({
configFile: paths.appTsconfig,
extensions: [".js", ".jsx", ".mjs", ".ts", ".tsx"],
}),
],
},
resolveLoader: {
// This allows you to set a fallback for where Webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebookincubator/create-react-app/issues/253
modules: ["node_modules", paths.appNodeModules, paths.appLoaders].concat(
// It is guaranteed to exist because we tweak it in `env.js`
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
),
},
module: {
strictExportPresence: true,
rules: [
// Disable require.ensure as it's not a standard language feature.
{ parser: { requireEnsure: false } },
// First, run the linter.
// It's important to do this before Babel processes the JS.
{
test: /\.(js|jsx|mjs|ts|tsx)$/,
enforce: "pre",
use: [
{
options: {
tsConfigFile: paths.appTsconfig,
},
loader: require.resolve("tslint-loader"),
},
],
include: paths.appSrc,
},
{
// "oneOf" will traverse all following loaders until one will
// match the requirements. When no loader matches it will fall
// back to the "file" loader at the end of the loader list.
oneOf: [
{
test: paths.appStreamLocalesTemplate,
use: [
// This is the locales loader that loads available locales
// from a particular target.
{
loader: "locales-loader",
options: {
pathToLocales: paths.appLocales,
// Default locale if non could be negotiated.
defaultLocale: "en-US",
// Fallback locale if a translation was not found.
// If not set, will use the text that is already
// in the code base.
fallbackLocale: "en-US",
// Common fluent files are always included in the locale bundles.
commonFiles: ["framework.ftl", "common.ftl"],
// Locales that come with the main bundle. Others are loaded on demand.
bundled: ["en-US"],
// Target specifies the prefix for fluent files to be loaded.
// ${target}-xyz.ftl and ${†arget}.ftl are loaded into the locales.
target: "stream",
// All available locales can be loadable on demand.
// To restrict available locales set:
// availableLocales: ["en-US"]
},
},
],
},
// Loader for our fluent files.
{
test: /\.ftl$/,
use: ["raw-loader"],
},
// "url" loader works like "file" loader except that it embeds assets
// smaller than specified limit in bytes as data URLs to avoid requests.
// A missing `test` is equivalent to a match.
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
loader: require.resolve("url-loader"),
options: {
limit: 10000,
name: "static/media/[name].[hash:8].[ext]",
},
},
// Process JS with Babel.
{
test: /\.(js|jsx|mjs|ts|tsx)$/,
include: paths.appSrc,
use: [
{
loader: require.resolve("babel-loader"),
options: {
// This is a feature of `babel-loader` for webpack (not Babel itself).
// It enables caching results in ./node_modules/.cache/babel-loader/
// directory for faster rebuilds.
cacheDirectory: true,
},
},
{
loader: "ts-loader",
options: {
compilerOptions: {
target: "es2015",
module: "esnext",
jsx: "preserve",
noEmit: false,
},
},
},
],
},
// "postcss" loader applies autoprefixer to our CSS.
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader turns CSS into JS modules that inject <style> tags.
// In production, we use a plugin to extract that CSS to a file, but
// in development "style" loader enables hot editing of CSS.
{
test: /\.css$/,
use: [
require.resolve("style-loader"),
{
loader: require.resolve("css-loader"),
options: {
modules: true,
importLoaders: 1,
},
},
{
loader: require.resolve("postcss-loader"),
options: {
config: {
path: paths.appPostCssConfig,
},
},
},
],
},
// "file" loader makes sure those assets get served by WebpackDevServer.
// When you `import` an asset, you get its (virtual) filename.
// In production, they would get copied to the `build` folder.
// This loader doesn't use a "test" so it will catch all modules
// that fall through the other loaders.
{
// Exclude `js` files to keep "css" loader working as it injects
// its runtime that would otherwise processed through "file" loader.
// Also exclude `html` and `json` extensions so they get processed
// by webpacks internal loaders.
exclude: [/\.(js|jsx|mjs|ts|tsx)$/, /\.html$/, /\.json$/],
loader: require.resolve("file-loader"),
options: {
name: "static/media/[name].[hash:8].[ext]",
},
},
],
},
// ** STOP ** Are you adding a new loader?
// Make sure to add the new loader(s) before the "file" loader.
],
},
plugins: [
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin({
inject: true,
template: paths.appStreamHtml,
}),
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
// In development, this will be an empty string.
new InterpolateHtmlPlugin(env.raw),
// Add module names to factory functions so they appear in browser profiler.
new webpack.NamedModulesPlugin(),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'development') { ... }. See `./env.js`.
new webpack.DefinePlugin(env.stringified),
// This is necessary to emit hot updates (currently CSS only):
new webpack.HotModuleReplacementPlugin(),
// Watcher doesn't work well if you mistype casing in a path so we use
// a plugin that prints an error when you attempt to do this.
// See https://github.com/facebookincubator/create-react-app/issues/240
new CaseSensitivePathsPlugin(),
// If you require a missing module and then `npm install` it, you still have
// to restart the development server for Webpack to discover it. This plugin
// makes the discovery automatic so you don't have to restart.
// See https://github.com/facebookincubator/create-react-app/issues/186
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
// Moment.js is an extremely popular library that bundles large locale files
// by default due to how Webpack interprets its code. This is a practical
// solution that requires the user to opt into importing specific locales.
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
// You can remove this if you don't use Moment.js:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
],
// Some libraries import Node modules but don't use them in the browser.
// Tell Webpack to provide empty mocks for them so importing them works.
node: {
dgram: "empty",
fs: "empty",
net: "empty",
tls: "empty",
child_process: "empty",
},
// Turn off performance hints during development because we don't do any
// splitting or minification in interest of speed. These warnings become
// cumbersome.
performance: {
hints: false,
},
};
+388
View File
@@ -0,0 +1,388 @@
"use strict";
const autoprefixer = require("autoprefixer");
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const ManifestPlugin = require("webpack-manifest-plugin");
const InterpolateHtmlPlugin = require("react-dev-utils/InterpolateHtmlPlugin");
const ModuleScopePlugin = require("react-dev-utils/ModuleScopePlugin");
const getClientEnvironment = require("./env");
const paths = require("./paths");
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
const UglifyJsPlugin = require("uglifyjs-webpack-plugin");
// Webpack uses `publicPath` to determine where the app is being served from.
// It requires a trailing slash, or the file assets will get an incorrect path.
const publicPath = paths.servedPath;
// Some apps do not use client-side routing with pushState.
// For these, "homepage" can be set to "." to enable relative asset paths.
const shouldUseRelativeAssetPaths = publicPath === "./";
// Source maps are resource heavy and can cause out of memory issue for large source files.
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== "false";
// `publicUrl` is just like `publicPath`, but we will provide it to our app
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
// Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
const publicUrl = publicPath.slice(0, -1);
// Get environment variables to inject into our app.
const env = getClientEnvironment(publicUrl);
// Assert this just to be safe.
// Development builds of React are slow and not intended for production.
if (env.stringified["process.env"].NODE_ENV !== '"production"') {
throw new Error("Production builds must have NODE_ENV=production.");
}
// Note: defined here because it will be used more than once.
// We use [md5:contenthash:hex:20] instead of [contenthash:8]
// because of this bug https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/763.
// TODO: Repalce with mini-css-extract-plugin once it supports HMR.
// https://github.com/webpack-contrib/mini-css-extract-plugin
const cssFilename = "static/css/[name].[md5:contenthash:hex:20].css";
// ExtractTextPlugin expects the build output to be flat.
// (See https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/27)
// However, our output is structured with css, js and media folders.
// To have this structure working with relative paths, we have to use custom options.
const extractTextPluginOptions = shouldUseRelativeAssetPaths
? // Making sure that the publicPath goes back to to build folder.
{ publicPath: Array(cssFilename.split("/").length).join("../") }
: {};
// This is the production configuration.
// It compiles slowly and is focused on producing a fast and minimal bundle.
// The development configuration is different and lives in a separate file.
module.exports = {
// Don't attempt to continue if there are any errors.
bail: true,
// Set webpack mode.
mode: "production",
// We generate sourcemaps in production. This is slow but gives good results.
// You can exclude the *.map files from the build during deployment.
devtool: shouldUseSourceMap ? "source-map" : false,
// In production, we only want to load the polyfills and the app code.
entry: [require.resolve("./polyfills"), paths.appStreamIndex],
output: {
// The dist folder.
path: paths.appDist,
// Generated JS file names (with nested folders).
// There will be one main bundle, and one file per asynchronous chunk.
// We don't currently advertise code splitting but Webpack supports it.
filename: "static/js/[name].[chunkhash:8].js",
chunkFilename: "static/js/[name].[chunkhash:8].chunk.js",
// We inferred the "public path" (such as / or /my-project) from homepage.
publicPath: publicPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
devtoolModuleFilenameTemplate: info =>
path
.relative(paths.appSrc, info.absoluteResourcePath)
.replace(/\\/g, "/"),
},
resolve: {
// This allows you to set a fallback for where Webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebookincubator/create-react-app/issues/253
modules: ["node_modules", paths.appNodeModules].concat(
// It is guaranteed to exist because we tweak it in `env.js`
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
),
// These are the reasonable defaults supported by the Node ecosystem.
// We also include JSX as a common component filename extension to support
// some tools, although we do not recommend using it, see:
// https://github.com/facebookincubator/create-react-app/issues/290
// `web` extension prefixes have been added for better support
// for React Native Web.
extensions: [
".web.js",
".mjs",
".js",
".json",
".web.jsx",
".jsx",
".ts",
".tsx",
],
alias: {
// Support React Native Web
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
"react-native": "react-native-web",
},
plugins: [
// Prevents users from importing files from outside of src/ (or node_modules/).
// This often causes confusion because we only process files within src/ with babel.
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
// please link the files into your node_modules/ and let module-resolution kick in.
// Make sure your source files are compiled, as they will not be processed in any way.
new ModuleScopePlugin(paths.appSrc, [paths.appPackageJson]),
// Support `tsconfig.json` `path` setting.
new TsconfigPathsPlugin({
configFile: paths.appTsconfig,
extensions: [".js", ".jsx", ".mjs", ".ts", ".tsx"],
}),
],
},
resolveLoader: {
// This allows you to set a fallback for where Webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebookincubator/create-react-app/issues/253
modules: ["node_modules", paths.appNodeModules, paths.appLoaders].concat(
// It is guaranteed to exist because we tweak it in `env.js`
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
),
},
module: {
strictExportPresence: true,
rules: [
// Disable require.ensure as it's not a standard language feature.
{ parser: { requireEnsure: false } },
// First, run the linter.
// It's important to do this before Babel processes the JS.
{
test: /\.(js|jsx|mjs|ts|tsx)$/,
enforce: "pre",
use: [
{
options: {
tsConfigFile: paths.appTsconfig,
},
loader: require.resolve("tslint-loader"),
},
],
include: paths.appSrc,
},
{
// "oneOf" will traverse all following loaders until one will
// match the requirements. When no loader matches it will fall
// back to the "file" loader at the end of the loader list.
oneOf: [
{
test: paths.appStreamLocalesTemplate,
use: [
// This is the locales loader that loads available locales
// from a particular target.
{
loader: "locales-loader",
options: {
pathToLocales: paths.appLocales,
// Default locale if non could be negotiated.
defaultLocale: "en-US",
// Fallback locale if a translation was not found.
// If not set, will use the text that is already
// in the code base.
fallbackLocale: "en-US",
// Common fluent files are always included in the locale bundles.
commonFiles: ["framework.ftl", "common.ftl"],
// Locales that come with the main bundle. Others are loaded on demand.
bundled: ["en-US"],
// Target specifies the prefix for fluent files to be loaded.
// ${target}-xyz.ftl and ${†arget}.ftl are loaded into the locales.
target: "stream",
// All available locales can be loadable on demand.
// To restrict available locales set:
// availableLocales: ["en-US"]
},
},
],
},
// Loader for our fluent files.
{
test: /\.ftl$/,
use: ["raw-loader"],
},
// "url" loader works like "file" loader except that it embeds assets
// smaller than specified limit in bytes as data URLs to avoid requests.
// A missing `test` is equivalent to a match.
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
loader: require.resolve("url-loader"),
options: {
limit: 10000,
name: "static/media/[name].[hash:8].[ext]",
},
},
// Process JS with Babel.
{
test: /\.(js|jsx|mjs|ts|tsx)$/,
include: paths.appSrc,
use: [
{
loader: require.resolve("babel-loader"),
options: {
compact: true,
},
},
{
loader: "ts-loader",
options: {
compilerOptions: {
target: "es2015",
module: "esnext",
jsx: "preserve",
noEmit: false,
},
},
},
],
},
// The notation here is somewhat confusing.
// "postcss" loader applies autoprefixer to our CSS.
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader normally turns CSS into JS modules injecting <style>,
// but unlike in development configuration, we do something different.
// `ExtractTextPlugin` first applies the "postcss" and "css" loaders
// (second argument), then grabs the result CSS and puts it into a
// separate file in our build process. This way we actually ship
// a single CSS file in production instead of JS code injecting <style>
// tags. If you use code splitting, however, any async bundles will still
// use the "style" loader inside the async code so CSS from them won't be
// in the main CSS file.
{
test: /\.css$/,
loader: ExtractTextPlugin.extract(
Object.assign(
{
fallback: {
loader: require.resolve("style-loader"),
options: {
hmr: false,
},
},
use: [
{
loader: require.resolve("css-loader"),
options: {
modules: true,
importLoaders: 1,
minimize: true,
sourceMap: shouldUseSourceMap,
},
},
{
loader: require.resolve("postcss-loader"),
options: {
config: {
path: paths.appPostCssConfig,
},
},
},
],
},
extractTextPluginOptions
)
),
// Note: this won't work without `new ExtractTextPlugin()` in `plugins`.
},
// "file" loader makes sure those assets get served by WebpackDevServer.
// When you `import` an asset, you get its (virtual) filename.
// In production, they would get copied to the `build` folder.
// This loader doesn't use a "test" so it will catch all modules
// that fall through the other loaders.
{
// Exclude `js` files to keep "css" loader working as it injects
// its runtime that would otherwise processed through "file" loader.
// Also exclude `html` and `json` extensions so they get processed
// by webpacks internal loaders.
exclude: [/\.(js|jsx|mjs|ts|tsx)$/, /\.html$/, /\.json$/],
loader: require.resolve("file-loader"),
options: {
name: "static/media/[name].[hash:8].[ext]",
},
},
],
},
// ** STOP ** Are you adding a new loader?
// Make sure to add the new loader(s) before the "file" loader.
],
},
plugins: [
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin({
inject: true,
template: paths.appStreamHtml,
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
}),
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
// In development, this will be an empty string.
new InterpolateHtmlPlugin(env.raw),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
// It is absolutely essential that NODE_ENV was set to production here.
// Otherwise React will be compiled in the very slow development mode.
new webpack.DefinePlugin(env.stringified),
// Minify the code.
new UglifyJsPlugin({
uglifyOptions: {
compress: {
warnings: false,
// Disabled because of an issue with Uglify breaking seemingly valid code:
// https://github.com/facebookincubator/create-react-app/issues/2376
// Pending further investigation:
// https://github.com/mishoo/UglifyJS2/issues/2011
comparisons: false,
},
mangle: {
safari10: true,
},
output: {
comments: false,
// Turned on because emoji and regex is not minified properly using default
// https://github.com/facebookincubator/create-react-app/issues/2488
ascii_only: true,
},
sourceMap: shouldUseSourceMap,
},
}),
// Note: this won't work without ExtractTextPlugin.extract(..) in `loaders`.
new ExtractTextPlugin({
filename: cssFilename,
}),
// Generate a manifest file which contains a mapping of all asset filenames
// to their corresponding output file so that tools can pick it up without
// having to parse `index.html`.
new ManifestPlugin({
fileName: "asset-manifest.json",
}),
// Moment.js is an extremely popular library that bundles large locale files
// by default due to how Webpack interprets its code. This is a practical
// solution that requires the user to opt into importing specific locales.
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
// You can remove this if you don't use Moment.js:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
],
// Some libraries import Node modules but don't use them in the browser.
// Tell Webpack to provide empty mocks for them so importing them works.
node: {
dgram: "empty",
fs: "empty",
net: "empty",
tls: "empty",
child_process: "empty",
},
};
+101
View File
@@ -0,0 +1,101 @@
"use strict";
const errorOverlayMiddleware = require("react-dev-utils/errorOverlayMiddleware");
const noopServiceWorkerMiddleware = require("react-dev-utils/noopServiceWorkerMiddleware");
const ignoredFiles = require("react-dev-utils/ignoredFiles");
const config = require("./webpack.config.dev");
const paths = require("./paths");
const protocol = process.env.HTTPS === "true" ? "https" : "http";
const host = process.env.HOST || "0.0.0.0";
const serverPort = process.env.PORT || 3000;
module.exports = function(proxy, allowedHost) {
return {
// WebpackDevServer 2.4.3 introduced a security fix that prevents remote
// websites from potentially accessing local content through DNS rebinding:
// https://github.com/webpack/webpack-dev-server/issues/887
// https://medium.com/webpack/webpack-dev-server-middleware-security-issues-1489d950874a
// However, it made several existing use cases such as development in cloud
// environment or subdomains in development significantly more complicated:
// https://github.com/facebookincubator/create-react-app/issues/2271
// https://github.com/facebookincubator/create-react-app/issues/2233
// While we're investigating better solutions, for now we will take a
// compromise. Since our WDS configuration only serves files in the `public`
// folder we won't consider accessing them a vulnerability. However, if you
// use the `proxy` feature, it gets more dangerous because it can expose
// remote code execution vulnerabilities in backends like Django and Rails.
// So we will disable the host check normally, but enable it if you have
// specified the `proxy` setting. Finally, we let you override it if you
// really know what you're doing with a special environment variable.
disableHostCheck:
!proxy || process.env.DANGEROUSLY_DISABLE_HOST_CHECK === "true",
// Enable gzip compression of generated files.
compress: true,
// Silence WebpackDevServer's own logs since they're generally not useful.
// It will still show compile warnings and errors with this setting.
clientLogLevel: "none",
// By default WebpackDevServer serves physical files from current directory
// in addition to all the virtual build products that it serves from memory.
// This is confusing because those files wont automatically be available in
// production build folder unless we copy them. However, copying the whole
// project directory is dangerous because we may expose sensitive files.
// Instead, we establish a convention that only files in `public` directory
// get served. Our build script will copy `public` into the `build` folder.
// In `index.html`, you can get URL of `public` folder with %PUBLIC_URL%:
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
// In JavaScript code, you can access it with `process.env.PUBLIC_URL`.
// Note that we only recommend to use `public` folder as an escape hatch
// for files like `favicon.ico`, `manifest.json`, and libraries that are
// for some reason broken when imported through Webpack. If you just want to
// use an image, put it in `src` and `import` it from JavaScript instead.
contentBase: paths.appPublic,
// By default files from `contentBase` will not trigger a page reload.
watchContentBase: true,
// Enable hot reloading server. It will provide /sockjs-node/ endpoint
// for the WebpackDevServer client so it can learn when the files were
// updated. The WebpackDevServer client is included as an entry point
// in the Webpack development configuration. Note that only changes
// to CSS are currently hot reloaded. JS changes will refresh the browser.
hot: true,
// It is important to tell WebpackDevServer to use the same "root" path
// as we specified in the config. In development, we always serve from /.
publicPath: config.output.publicPath,
// WebpackDevServer is noisy by default so we emit custom message instead
// by listening to the compiler events with `compiler.plugin` calls above.
quiet: true,
// Reportedly, this avoids CPU overload on some systems.
// https://github.com/facebookincubator/create-react-app/issues/293
// src/node_modules is not ignored to support absolute imports
// https://github.com/facebookincubator/create-react-app/issues/1065
watchOptions: {
ignored: ignoredFiles(paths.appSrc),
},
// Enable HTTPS if the HTTPS environment variable is set to 'true'
https: protocol === "https",
host: host,
overlay: false,
historyApiFallback: {
// Paths with dots should still use the history fallback.
// See https://github.com/facebookincubator/create-react-app/issues/387.
disableDotRule: true,
},
public: allowedHost,
// Proxy to the graphql server.
proxy: proxy || {
"/api": {
target: `http://localhost:${serverPort}`,
},
},
before(app) {
// This lets us open files from the runtime error overlay.
app.use(errorOverlayMiddleware());
// This service worker file is effectively a 'no-op' that will reset any
// previous service worker registered for the same host:port combination.
// We do this in development to avoid hitting the production cache if
// it used the same host and port.
// https://github.com/facebookincubator/create-react-app/issues/2272#issuecomment-302832432
app.use(noopServiceWorkerMiddleware());
},
};
};
+44
View File
@@ -0,0 +1,44 @@
const path = require("path");
const fs = require("fs");
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
const extensions = [".ts", ".tsx", ".js"];
const paths = require("./config/paths");
// Ensure environment variables are read.
require("./config/env");
// const stringify = require('json-stringify-safe');
export default {
title: "Talk 5.0",
source: "./src",
typescript: true,
host: process.env.HOST || "0.0.0.0",
port: parseInt(process.env.DOCZ_PORT, 10) || 3000,
modifyBundlerConfig: config => {
config.module.rules.push({
test: /\.css$/,
use: [
require.resolve("style-loader"),
{
loader: require.resolve("css-loader"),
options: {
modules: true,
importLoaders: 1,
},
},
{
loader: require.resolve("postcss-loader"),
options: {
config: {
path: paths.appPostCssConfig,
},
},
},
],
});
config.resolve.plugins = [new TsconfigPathsPlugin({ extensions, configFile: paths.appTsconfig })];
// fs.writeFileSync(path.resolve(__dirname, "tmp"), stringify(config, null, 2));
return config;
},
};
+149
View File
@@ -0,0 +1,149 @@
const loaderUtils = require('loader-utils');
const fs = require('fs');
const path = require('path');
const camelCase = require('lodash/camelCase');
const upperFirst = require('lodash/upperFirst');
const memoize = require('lodash/memoize');
const pascalCase = (x) => upperFirst(camelCase(x));
/**
* Default values for every param that can be passed in the loader query.
*/
const DEFAULT_QUERY_VALUES = {
// Path to locales.
pathToLocales: null,
// Default locale if non could be negotiated.
defaultLocale: 'en-US',
// Fallback locale if a translation was not found.
// If not set, will use the text that is already
// in the code base.
fallbackLocale: '',
// If set, restrict to this list of available locales.
availableLocales: null,
// Common fluent files are always included in the locale bundles.
commonFiles: [],
// Locales that come with the main bundle. Others are loaded on demand.
bundled: [],
// Target specifies the prefix for fluent files to be loaded. ${target}-xyz.ftl and ${†arget}.ftl are
// loaded into the locales.
target: '',
};
function getFiles(target, pathToLocale, context) {
const {pathToLocales, commonFiles} = context;
const common = [];
const suffixes = [];
const files = fs.readdirSync(pathToLocale);
files.forEach(f => {
if (commonFiles.includes(f)) {
common.push(f);
return;
}
if (f.startsWith(target)) {
suffixes.push(f.substr(target.length));
return;
}
});
return {common, suffixes};
}
function generateTarget(target, context) {
const {defaultLocale, fallbackLocale, pathToLocales, locales, commonFiles, bundled} = context;
const getLocalePath = locale => path.join(pathToLocales, locale);
const getLocaleFiles = memoize(locale => getFiles(target, getLocalePath(locale), context));
const loadables = locales.filter(local => !bundled.includes(local));
return `
var ret = {
defaultLocale: ${JSON.stringify(defaultLocale)},
fallbackLocale: ${JSON.stringify(fallbackLocale)},
availableLocales: ${JSON.stringify(locales)},
bundled: {},
loadables: {},
};
// Bundled locales are directly available in the main bundle.
${bundled.map(locale => `
{
var suffixes = ${JSON.stringify(getLocaleFiles(locale).suffixes)};
var contents = [];
${getLocaleFiles(locale).common.map(file => `
contents.push(require('${getLocalePath(locale)}/${file}'));
`).join("\n")}
contents = contents.concat(suffixes.map(function(suffix) { return require(\`${getLocalePath(locale)}/${target}\${suffix}\`); }));
ret.bundled[${JSON.stringify(locale)}] = contents.join("\\n");
}
`).join("\n")}
// Loadables are in a separate bundle, that can be easily loaded.
${loadables.map(locale => `
ret.loadables[${JSON.stringify(locale)}] = function() {
var suffixes = ${JSON.stringify(getLocaleFiles(locale).suffixes)};
var promises = [];
${getLocaleFiles(locale).common.map(file => `
promises.push(
import(
/* webpackChunkName: ${JSON.stringify(`${target}-locale-${locale}`)}, webpackMode: "lazy" */
'${getLocalePath(locale)}/${file}'
)
);
`).join("\n")}
promises = promises.concat(suffixes.map(function(suffix) {
return import(
/* webpackChunkName: ${JSON.stringify(`${target}-locale-${locale}`)}, webpackMode: "lazy-once" */
\`${getLocalePath(locale)}/${target}\${suffix}\`
)
}));
return Promise.all(promises).then(function(modules) {
return modules.map(function(m){return m.default}).join("\\n");
});
};
`).join("\n")}
module.exports = ret;
`;
}
module.exports = function(source) {
const options = Object.assign({}, DEFAULT_QUERY_VALUES, loaderUtils.getOptions(this));
const {pathToLocales, defaultLocale, fallbackLocale, availableLocales, target, bundled, commonFiles} = options;
let locales = fs.readdirSync(pathToLocales);
if (availableLocales) {
availableLocales.forEach(locale => {
if (!locales.includes(locale)) {
throw new Error(`locale ${fallbackLocale} not available`);
}
});
locales = availableLocales;
}
if (fallbackLocale && !locales.includes(fallbackLocale)) {
throw new Error(`fallbackLocale ${fallbackLocale} not in available locales`);
}
if (!pathToLocales) {
throw new Error(`pathToLocales is required`);
}
if (!defaultLocale) {
throw new Error(`defaultLocale is required`);
}
if (!locales.includes(defaultLocale)) {
throw new Error(`defaultLocale ${defaultLocale} not in available locales`);
}
const context = {pathToLocales, defaultLocale, fallbackLocale, commonFiles, locales, bundled};
this.cacheable();
return generateTarget(target, context);
};
+16786 -5
View File
File diff suppressed because it is too large Load Diff
+86 -6
View File
@@ -4,9 +4,23 @@
"description": "A better commenting experience from Mozilla, The Washington Post, and The New York Times.",
"scripts": {
"start": "node dist/index.js",
"build": "tsc",
"watch": "nodemon --config .nodemonrc.json src/index.ts",
"lint": "prettier --write \"src/**/*.ts\""
"test": "node scripts/test.js --env=jsdom",
"build": "npm-run-all --parallel compile:* && npm-run-all --parallel build:*",
"build:client": "node ./scripts/build.js",
"build:server": "tsc",
"watch": "NODE_ENV=development npm-run-all compile:* --parallel watch:*",
"watch:client": "node ./scripts/start.js",
"watch:css-types": "tcm src/core/client/ --watch",
"watch:relay-stream": "nodemon --config ./config/nodemon/relay-stream.json",
"watch:server": "nodemon --config ./config/nodemon/server.json",
"compile:css-types": "tcm src/core/client/",
"compile:relay-stream": "relay-compiler --src ./src/core/client/stream --schema ./src/core/server/graph/tenant/schema/schema.graphql --language typescript --artifactDirectory ./src/core/client/stream/__generated__ --no-watchman",
"start:development": "NODE_ENV=development ts-node -r tsconfig-paths/register src/index.ts",
"lint-fix": "tslint --fix --project ./tsconfig.json && tslint --fix --project ./src/core/client/tsconfig.json",
"lint": "npm-run-all --parallel lint:*",
"lint:server": "tslint --project ./tsconfig.json",
"lint:client": "tslint --project ./src/core/client/tsconfig.json",
"docz:watch": "docz dev"
},
"author": "",
"license": "Apache-2.0",
@@ -16,9 +30,11 @@
"convict": "^4.3.0",
"dataloader": "^1.4.0",
"dotenv": "^6.0.0",
"dotenv-expand": "^4.2.0",
"dotize": "^0.2.0",
"express": "^4.16.3",
"express-static-gzip": "^0.3.2",
"fs-extra": "^6.0.1",
"graphql": "^0.13.2",
"graphql-config": "^2.0.1",
"graphql-redis-subscriptions": "^1.5.0",
@@ -34,27 +50,91 @@
"uuid": "^3.2.1"
},
"devDependencies": {
"@babel/core": "7.0.0-beta.49",
"@babel/plugin-syntax-dynamic-import": "7.0.0-beta.49",
"@babel/polyfill": "7.0.0-beta.49",
"@babel/preset-env": "7.0.0-beta.49",
"@babel/preset-react": "7.0.0-beta.49",
"@types/bunyan": "^1.8.4",
"@types/classnames": "^2.2.4",
"@types/convict": "^4.2.0",
"@types/dotenv": "^4.0.3",
"@types/express": "^4.16.0",
"@types/graphql": "^0.13.1",
"@types/ioredis": "^3.2.8",
"@types/jest": "^23.1.1",
"@types/joi": "^13.0.8",
"@types/lodash": "^4.14.109",
"@types/luxon": "^0.5.3",
"@types/mongodb": "^3.0.19",
"@types/node": "^10.3.1",
"@types/passport": "^0.4.5",
"@types/query-string": "^6.1.0",
"@types/react-dom": "^16.0.6",
"@types/react-relay": "^1.3.6",
"@types/recompose": "^0.26.1",
"@types/relay-runtime": "github:coralproject/patched#types/relay-runtime",
"@types/uuid": "^3.4.3",
"@types/ws": "^5.1.2",
"autoprefixer": "^8.6.0",
"babel-loader": "^8.0.0-beta",
"babel-plugin-relay": "github:coralproject/patched#babel-plugin-relay",
"babel-preset-react-optimize": "^1.0.1",
"case-sensitive-paths-webpack-plugin": "^2.1.2",
"chalk": "^2.4.1",
"classnames": "^2.2.5",
"copy-webpack-plugin": "^4.5.1",
"css-loader": "^0.28.11",
"docz": "^0.2.6",
"docz-theme-default": "^0.2.10",
"extract-text-webpack-plugin": "^4.0.0-beta.0",
"final-form": "^4.8.1",
"flat": "^4.0.0",
"fluent": "^0.6.4",
"fluent-intl-polyfill": "^0.1.0",
"fluent-langneg": "^0.1.0",
"fluent-react": "^0.7.0",
"graphql-playground-middleware-express": "^1.7.0",
"html-webpack-plugin": "^3.2.0",
"jest": "^23.2.0",
"loader-utils": "^1.1.0",
"nodemon": "^1.17.5",
"npm-run-all": "^4.1.3",
"postcss-flexbugs-fixes": "^3.3.1",
"postcss-font-magician": "^2.2.1",
"postcss-loader": "^2.1.5",
"precss": "^3.1.2",
"prettier": "^1.13.4",
"ts-node": "^6.1.1",
"query-string": "^6.1.0",
"raw-loader": "^0.5.1",
"react": "^16.4.0",
"react-dev-utils": "6.0.0-next.3e165448",
"react-dom": "^16.4.0",
"react-final-form": "^3.6.0",
"react-relay": "github:coralproject/patched#react-relay",
"recompose": "^0.27.1",
"relay-compiler": "github:coralproject/patched#relay-compiler",
"relay-compiler-language-typescript": "github:coralproject/patched#relay-compiler-language-typescript",
"relay-runtime": "github:coralproject/patched#relay-runtime",
"relay-test-utils": "github:coralproject/patched#relay-test-utils",
"style-loader": "^0.21.0",
"ts-jest": "^22.4.6",
"ts-loader": "^4.3.1",
"ts-node": "^6.1.0",
"tsconfig-paths": "^3.4.0",
"tsconfig-paths-webpack-plugin": "^3.1.4",
"tslint": "^5.10.0",
"tslint-config-prettier": "^1.13.0",
"tslint-loader": "^3.6.0",
"tslint-plugin-prettier": "^1.3.0",
"typescript": "^2.9.1"
"tslint-react": "^3.6.0",
"typed-css-modules": "^0.3.4",
"typescript": "^2.9.1",
"uglifyjs-webpack-plugin": "^1.2.5",
"webpack": "4.12.0",
"webpack-cli": "^3.0.2",
"webpack-dev-server": "^3.1.4",
"webpack-hot-client": "^4.0.3",
"webpack-manifest-plugin": "^2.0.3"
}
}
}
+145
View File
@@ -0,0 +1,145 @@
"use strict";
// tslint:disable: no-console
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = "production";
process.env.NODE_ENV = "production";
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on("unhandledRejection", err => {
throw err;
});
// Ensure environment variables are read.
require("../config/env");
const path = require("path");
const chalk = require("chalk");
const fs = require("fs-extra");
const webpack = require("webpack");
const config = require("../config/webpack.config.prod");
const paths = require("../config/paths");
const checkRequiredFiles = require("react-dev-utils/checkRequiredFiles");
const formatWebpackMessages = require("react-dev-utils/formatWebpackMessages");
const printHostingInstructions = require("react-dev-utils/printHostingInstructions");
const FileSizeReporter = require("react-dev-utils/FileSizeReporter");
const printBuildError = require("react-dev-utils/printBuildError");
const measureFileSizesBeforeBuild =
FileSizeReporter.measureFileSizesBeforeBuild;
const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild;
// These sizes are pretty large. We'll warn for bundles exceeding them.
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;
// Treat warnings as errors when we are in CI
// TODO: This is currently turned off until we have
// an optimized build.
const treatWarningsAsErrors = false && process.env.CI &&
(typeof process.env.CI !== "string" ||
process.env.CI.toLowerCase() !== "false");
// Warn and crash if required files are missing
if (!checkRequiredFiles([paths.appStreamHtml, paths.appStreamIndex])) {
process.exit(1);
}
// First, read the current file sizes in build directory.
// This lets us display how much they changed later.
measureFileSizesBeforeBuild(paths.appDist)
.then(previousFileSizes => {
// Remove all content but keep the directory so that
// if you're in it, you don't end up in Trash
fs.emptyDirSync(paths.appDist);
// Merge with the public folder
if (fs.pathExistsSync(paths.appPublic)) {
copyPublicFolder();
}
// Start the webpack build
return build(previousFileSizes);
})
.then(
({ stats, previousFileSizes, warnings }) => {
if (warnings.length) {
console.log(chalk.yellow("Compiled with warnings.\n"));
console.log(warnings.join("\n\n"));
console.log(
"\nSearch for the " +
chalk.underline(chalk.yellow("keywords")) +
" to learn more about each warning."
);
console.log(
"To ignore, add " +
chalk.cyan("// eslint-disable-next-line") +
" to the line before.\n"
);
} else {
console.log(chalk.green("Compiled successfully.\n"));
}
console.log("File sizes after gzip:\n");
printFileSizesAfterBuild(
stats,
previousFileSizes,
paths.appDist,
WARN_AFTER_BUNDLE_GZIP_SIZE,
WARN_AFTER_CHUNK_GZIP_SIZE
);
console.log();
},
err => {
console.log(chalk.red("Failed to compile.\n"));
printBuildError(err);
process.exit(1);
}
);
// Create the production build and print the deployment instructions.
function build(previousFileSizes) {
console.log("Creating an optimized production build...");
let compiler = webpack(config);
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
if (err) {
return reject(err);
}
const messages = formatWebpackMessages(stats.toJson({}, true));
if (messages.errors.length) {
// Only keep the first error. Others are often indicative
// of the same problem, but confuse the reader with noise.
if (messages.errors.length > 1) {
messages.errors.length = 1;
}
return reject(new Error(messages.errors.join("\n\n")));
}
if (
treatWarningsAsErrors &&
messages.warnings.length
) {
console.log(
chalk.yellow(
"\nTreating warnings as errors because process.env.CI = true.\n" +
"Most CI servers set it automatically.\n"
)
);
return reject(new Error(messages.warnings.join("\n\n")));
}
return resolve({
stats,
previousFileSizes,
warnings: messages.warnings,
});
});
});
}
function copyPublicFolder() {
fs.copySync(paths.appPublic, paths.appDist, {
dereference: true,
});
}
+107
View File
@@ -0,0 +1,107 @@
"use strict";
// tslint:disable: no-console
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = "development";
process.env.NODE_ENV = "development";
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on("unhandledRejection", err => {
throw err;
});
// Ensure environment variables are read.
require("../config/env");
const fs = require("fs");
const chalk = require("chalk");
const webpack = require("webpack");
const WebpackDevServer = require("webpack-dev-server");
const clearConsole = require("react-dev-utils/clearConsole");
const checkRequiredFiles = require("react-dev-utils/checkRequiredFiles");
const {
choosePort,
createCompiler,
prepareProxy,
prepareUrls,
} = require("react-dev-utils/WebpackDevServerUtils");
const openBrowser = require("react-dev-utils/openBrowser");
const paths = require("../config/paths");
const config = require("../config/webpack.config.dev");
const createDevServerConfig = require("../config/webpackDevServer.config");
const isInteractive = process.stdout.isTTY;
// Warn and crash if required files are missing
if (!checkRequiredFiles([paths.appStreamHtml, paths.appStreamIndex])) {
process.exit(1);
}
const PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 8080;
const HOST = process.env.HOST || "0.0.0.0";
if (process.env.HOST) {
console.log(
chalk.cyan(
`Attempting to bind to HOST environment variable: ${chalk.yellow(
chalk.bold(process.env.HOST)
)}`
)
);
console.log(
`If this was unintentional, check that you haven't mistakenly set it in your shell.`
);
console.log(`Learn more here: ${chalk.yellow("http://bit.ly/2mwWSwH")}`);
console.log();
}
// We attempt to use the default port but if it is busy, we offer the user to
// run on a different port. `choosePort()` Promise resolves to the next free port.
choosePort(HOST, PORT)
.then(port => {
if (port == null) {
// We have not found a port.
return;
}
const protocol = process.env.HTTPS === "true" ? "https" : "http";
const appName = require(paths.appPackageJson).name;
const urls = prepareUrls(protocol, HOST, port);
// Create a webpack compiler that is configured with custom messages.
const compiler = createCompiler(webpack, config, appName, urls);
// Load proxy config
const proxySetting = require(paths.appPackageJson).proxy;
const proxyConfig = prepareProxy(proxySetting, paths.appPublic);
// Serve webpack assets generated by the compiler over a web sever.
const serverConfig = createDevServerConfig(
proxyConfig,
urls.lanUrlForConfig
);
const devServer = new WebpackDevServer(compiler, serverConfig);
// Launch WebpackDevServer.
devServer.listen(port, HOST, err => {
if (err) {
return console.log(err);
}
if (isInteractive) {
clearConsole();
}
console.log(chalk.cyan("Starting the development server...\n"));
openBrowser(urls.localUrlForBrowser);
});
["SIGINT", "SIGTERM"].forEach(function(sig) {
process.on(sig, function() {
devServer.close();
process.exit();
});
});
})
.catch(err => {
if (err && err.message) {
console.log(err.message);
}
process.exit(1);
});
+29
View File
@@ -0,0 +1,29 @@
"use strict";
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = "test";
process.env.NODE_ENV = "test";
process.env.PUBLIC_URL = "";
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on("unhandledRejection", err => {
throw err;
});
// Ensure environment variables are read.
require("../config/env");
const paths = require("../config/paths");
const jest = require("jest");
let argv = process.argv.slice(2);
// Watch unless on CI or in coverage mode
if (!process.env.CI && argv.indexOf("--coverage") < 0) {
argv.push("--watch");
argv.push("--config", paths.appJestConfig);
}
jest.run(argv);
+14
View File
@@ -0,0 +1,14 @@
module.exports = {
presets: [
["@babel/env", { targets: "last 2 versions, ie 11", modules: false }],
"@babel/react"
],
plugins: [
"@babel/syntax-dynamic-import",
],
env: {
"production": {
"plugins": [],
},
},
}
+8
View File
@@ -0,0 +1,8 @@
# Framework
All our client targets (e.g. stream, admin, ...) are based functionality provided by this framework.
## What should be inside `framework`
- Code that are specific to a certain target (e.g. stream, admin, ...) must not live here.
- Code that are shared by different targets should be put in `framework`
+3
View File
@@ -0,0 +1,3 @@
# Lib
This folder contains functionality of integral parts of our technology stack.
@@ -0,0 +1,33 @@
import { LocalizationProvider } from "fluent-react/compat";
import { MessageContext } from "fluent/compat";
import React, { StatelessComponent } from "react";
import { Environment } from "relay-runtime";
export interface TalkContext {
// relayEnvironment for our relay framework.
relayEnvironment: Environment;
// localMessages for our i18n framework.
localeMessages: MessageContext[];
}
const { Provider, Consumer } = React.createContext<TalkContext>({} as any);
/**
* Allows consuming the provided context using the React Context API.
*/
export const TalkContextConsumer = Consumer;
/**
* In addition to just providing the context, TalkContextProvider also
* renders the `LocalizationProvider` with the appropite data.
*/
export const TalkContextProvider: StatelessComponent<{
value: TalkContext;
}> = ({ value, children }) => (
<Provider value={value}>
<LocalizationProvider messages={value.localeMessages}>
{children}
</LocalizationProvider>
</Provider>
);
@@ -0,0 +1,55 @@
import { noop } from "lodash";
import { Environment, Network, RecordSource, Store } from "relay-runtime";
import { generateMessages, LocalesData, negotiateLanguages } from "../i18n";
import { fetchQuery } from "../network";
import { TalkContext } from "./TalkContext";
interface CreateContextArguments {
// Locales that the user accepts, usually `navigator.languages`.
userLocales: ReadonlyArray<string>;
// 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<void>);
}
/**
* `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,
}: CreateContextArguments): Promise<TalkContext> {
// Initialize Relay.
const relayEnvironment = new Environment({
network: Network.create(fetchQuery),
store: new Store(new RecordSource()),
});
// 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 localeMessages = await generateMessages(locales, localesData);
// Assemble context.
const context = {
relayEnvironment,
localeMessages,
};
// Run custom initializations.
await init(context);
return context;
}
@@ -0,0 +1,3 @@
export * from "./TalkContext";
export { default as createContext } from "./createContext";
export { default as withContext } from "./withContext";
@@ -0,0 +1,23 @@
import * as React from "react";
import { hoistStatics, InferableComponentEnhancer } from "recompose";
import { TalkContext, TalkContextConsumer } from "./TalkContext";
/**
* withContext is a HOC wrapper around `TalkContextConsumer`.
* `propsCallback` must be provided which accepts the `TalkContext`
* and returns the props the should be injected.
*/
function withContext<T>(
propsCallback: (context: TalkContext) => T
): InferableComponentEnhancer<T> {
return hoistStatics<T>(
<U extends T>(WrappedComponent: React.ComponentType<U>) => (props: any) => (
<TalkContextConsumer>
{context => <WrappedComponent {...props} {...propsCallback(context)} />}
</TalkContextConsumer>
)
);
}
export default withContext;
@@ -0,0 +1,79 @@
import { mapValues, once } from "lodash";
import { ReactNode } from "react";
import { VALIDATION_REQUIRED, VALIDATION_TOO_SHORT } from "../messages";
/**
* ValidationError represents all possible string values
* that is responded by the server.
*/
type ValidationError = "TOO_SHORT";
/**
* InvalidArgsMap as responded by the server.
*/
interface InvalidArgsMap {
[key: string]: ValidationError;
}
/**
* The localized version of `InvalidArgsMap`.
*/
interface InvalidArgsMapLocalilzed {
[key: string]: ReactNode;
}
/**
* Shape of the `BadUserInput` extension.
*/
interface BadUserInputExtension {
code: "BAD_USER_INPUT";
exception: {
invalidArgs: InvalidArgsMap;
};
}
/**
* Map server `ValidationError` to a translation message.
*/
const validationMap = {
TOO_SHORT: VALIDATION_TOO_SHORT,
REQUIRED: VALIDATION_REQUIRED,
};
/**
* BadUserInputError wraps the `BAD_USER_INPUT` error returned from the
* server.
*/
export default class BadUserInputError extends Error {
// Keep origin of original server response.
public readonly origin: BadUserInputExtension;
constructor(error: BadUserInputExtension) {
super("BadUserInputError");
// Maintains proper stack trace for where our error was thrown.
if (Error.captureStackTrace) {
Error.captureStackTrace(this, BadUserInputError);
}
this.origin = error;
}
get invalidArgs(): InvalidArgsMap {
return this.origin.exception.invalidArgs;
}
get invalidArgsLocalized(): InvalidArgsMapLocalilzed {
return this.computeInvalidArgsLocalized();
}
// Perform localization and memoize result.
private computeInvalidArgsLocalized = once(() => {
return mapValues(this.invalidArgs, v => {
if (v in validationMap) {
return validationMap[v]();
}
return v;
});
});
}
@@ -0,0 +1,25 @@
export interface GraphQLErrorItem {
message: string;
locations: Array<{
line: number;
column: number;
}>;
}
/**
* Graphql wraps graphql errors at the network layer.
*/
export default class GraphQLError extends Error {
// Original error.
public readonly origin: GraphQLErrorItem[];
constructor(origin: GraphQLErrorItem[]) {
super(origin.map(o => o.message).join(" "));
// Maintains proper stack trace for where our error was thrown.
if (Error.captureStackTrace) {
Error.captureStackTrace(this, GraphQLError);
}
this.origin = origin;
}
}
@@ -0,0 +1,5 @@
export { default as NetworkError } from "./networkError";
export { default as UnknownServerError } from "./unknownServerError";
export { default as BadUserInputError } from "./badUserInputError";
export { default as GraphQLError } from "./graphqlError";
export * from "./graphqlError";
@@ -0,0 +1,18 @@
/**
* NetworkError wraps errors at the network layer.
*/
export default class NetworkError extends Error {
// Original error.
public readonly origin: Error;
constructor(origin: Error) {
// Pass remaining arguments (including vendor specific ones) to parent constructor.
super(origin.message);
// Maintains proper stack trace for where our error was thrown.
if (Error.captureStackTrace) {
Error.captureStackTrace(this, NetworkError);
}
this.origin = origin;
}
}
@@ -0,0 +1,26 @@
/**
* Shape of the `UnknownError` extension.
*/
interface UnknownErrorExtension {
code: string;
}
/**
* UnknownServerError wraps any error returned from the
* server that we don't know of.
*/
export default class UnknownServerError extends Error {
// Keep origin of original server response.
public origin: UnknownErrorExtension;
constructor(msg: string, error: UnknownErrorExtension) {
super(msg);
// Maintains proper stack trace for where our error was thrown.
if (Error.captureStackTrace) {
Error.captureStackTrace(this, UnknownServerError);
}
this.origin = error;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { FormApi } from "final-form";
import { ReactNode } from "react";
type ErrorsObject<T> = { [K in keyof T]?: ReactNode };
/**
* A version of FormProps["onSubmit"] with support for Generic Types.
*/
export type OnSubmit<T> = (
values: T,
form: FormApi
) => ErrorsObject<T> | Promise<ErrorsObject<T> | void> | void;
+103
View File
@@ -0,0 +1,103 @@
import "fluent-intl-polyfill/compat";
import { negotiateLanguages as negotiate } from "fluent-langneg/compat";
import { MessageContext } from "fluent/compat";
export interface BundledLocales {
[locale: string]: string;
}
export interface LoadableLocales {
[locale: string]: (() => Promise<string>);
}
/**
* This type describes the shape of the generated code from our `locales-loader`.
* Please check `./src/loaders` and the webpack config for more information.
*/
export interface LocalesData {
readonly defaultLocale: string;
readonly fallbackLocale: string;
readonly availableLocales: ReadonlyArray<string>;
readonly bundled: BundledLocales;
readonly loadables: LoadableLocales;
}
/**
* negotiateLanguages accepts `userLocales` which usually comes from
* `navigator.languages` and the locales `data` as generated by
* the `locales-loader` and returns an array of matching languages.
*/
export function negotiateLanguages(
userLocales: ReadonlyArray<string>,
data: LocalesData
) {
// Choose locale that is best for the user.
const languages = negotiate(userLocales, data.availableLocales, {
defaultLocale: data.defaultLocale,
strategy: "lookup",
});
if (data.fallbackLocale && languages[0] !== data.fallbackLocale) {
// Use default locale as fallback in case we have
// missing keys.
languages.push(data.fallbackLocale);
}
return languages;
}
// Don't warn in production.
let decorateWarnMissing = (cx: MessageContext) => cx;
// Warn about missing locales if we are not in production.
if (process.env.NODE_ENV !== "production") {
decorateWarnMissing = (() => {
const warnings: string[] = [];
return (cx: MessageContext) => {
const original = cx.hasMessage;
cx.hasMessage = (id: string) => {
const result = original.apply(cx, [id]);
if (!result) {
const warn = `${cx.locales} translation for key "${id}" not found`;
if (!warnings.includes(warn)) {
// tslint:disable:next-line: no-console
console.warn(warn);
warnings.push(warn);
}
}
return result;
};
return cx;
};
})();
}
/**
* Given a locales array and the `data` from the `locales-loader`,
* generateMessages returns an Array of MessageContext as a Promise.
* This array is meant to be consumed by `react-fluent`.
*
* Use it in conjunction with `negotiateLanguages`.
*/
export async function generateMessages(
locales: ReadonlyArray<string>,
data: LocalesData
): Promise<MessageContext[]> {
const promises = [];
for (const locale of locales) {
const cx = new MessageContext(locale);
if (locale in data.bundled) {
cx.addMessages(data.bundled[locale]);
promises.push(decorateWarnMissing(cx));
} else if (locale in data.loadables) {
const content = await data.loadables[locale]();
cx.addMessages(content);
promises.push(decorateWarnMissing(cx));
} else {
throw Error(`Locale ${locale} not available`);
}
}
return await Promise.all(promises);
}
@@ -0,0 +1,19 @@
import { Localized } from "fluent-react/compat";
import React from "react";
/**
* This file contains localization messages that are shared by
* different parts of the framework.
*/
export const VALIDATION_REQUIRED = () => (
<Localized id="framework-validation-required">
<span>This field is required.</span>
</Localized>
);
export const VALIDATION_TOO_SHORT = () => (
<Localized id="framework-validation-too-short">
<span>This field is too short.</span>
</Localized>
);
@@ -0,0 +1,61 @@
import { FetchFunction } from "relay-runtime";
import {
BadUserInputError,
GraphQLError,
NetworkError,
UnknownServerError,
} from "../errors";
// Normalize errors.
function getError(errors: Error[]): Error {
if (errors.length > 1) {
// Multiple errors are GraphQL errors.
// TODO: (cvle) Is this assumption correct?
return new GraphQLError(errors as any);
}
const err = errors[0] as Error;
if ((err as any).extensions) {
if ((err as any).code === "BAD_USER_INPUT") {
return new BadUserInputError((err as any).extensions);
}
return new UnknownServerError(err.message, (err as any).extensions);
}
// No extensions == GraphQL error.
// TODO: (cvle) harmonize with server.
return new GraphQLError(errors as any);
}
/**
* fetchQuery is a simple implementation of the `FetchFunction`
* required by Relay. It'll return a `NetworkError` on failure.
*/
const fetchQuery: FetchFunction = async (operation, variables) => {
try {
const response = await fetch("/api/tenant/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
query: operation.text,
variables,
}),
});
if (response.status >= 500) {
throw new Error(`${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data.errors) {
throw getError(data.errors);
}
return data;
} catch (err) {
if (err instanceof TypeError) {
throw new NetworkError(err);
}
throw err;
}
};
export default fetchQuery;
@@ -0,0 +1 @@
export { default as fetchQuery } from "./fetchQuery";
@@ -0,0 +1,40 @@
import React, { Component } from "react";
import { QueryRenderer } from "react-relay";
import { CacheConfig, GraphQLTaggedNode, RerunParam } from "relay-runtime";
import { TalkContextConsumer } from "../bootstrap/TalkContext";
// Taken from relay types and added Generic support for Variables and Response
export interface QueryRendererProps<V, R> {
cacheConfig?: CacheConfig;
query?: GraphQLTaggedNode | null;
render(readyState: ReadyState<R>): React.ReactElement<any> | undefined | null;
variables: V;
rerunParamExperimental?: RerunParam;
}
// Taken from relay types and added Generic support for Variables and Response
export interface ReadyState<R> {
error: Error | undefined | null;
props: R | undefined | null;
retry?(): void;
}
/**
* TalkQueryRenderer is a wrappper around Relay's `QueryRenderer`.
* It supplies the `environment` from the context and has better
* generics type support.
*/
class TalkQueryRenderer<V, R> extends Component<QueryRendererProps<V, R>> {
public render() {
return (
<TalkContextConsumer>
{({ relayEnvironment }) => (
<QueryRenderer environment={relayEnvironment} {...this.props} />
)}
</TalkContextConsumer>
);
}
}
export default TalkQueryRenderer;
@@ -0,0 +1,67 @@
import { commitMutation } from "react-relay";
import { Environment, MutationConfig } from "relay-runtime";
import { Omit } from "talk-framework/types";
/**
* Like `MutationConfig` but omits `onCompleted` and `onError`
* because we are going to use a Promise API.
*/
export type MutationPromiseConfig<T, U> = Omit<
MutationConfig<T, U>,
"onCompleted" | "onError"
>;
// Extract the payload from the response,
function getPayload(response: { [key: string]: any }): any {
const keys = Object.keys(response);
if (keys.length !== 1) {
return response;
}
return response[keys[0]];
}
/**
* Normalizes response and error from `commitMutationPromise`.
* Meaning `response` will directly contain the payload
* and errors are wrapped inside of application specific
* error instances.
*/
export async function commitMutationPromiseNormalized<R, V>(
environment: Environment,
config: MutationPromiseConfig<R, V>
): Promise<R> {
try {
const response = await commitMutationPromise(environment, config);
return getPayload(response);
} catch (e) {
throw e;
}
}
/**
* Like `commitMutation` of the Relay API but returns a Promise.
*/
export function commitMutationPromise<R, V>(
environment: Environment,
config: MutationPromiseConfig<R, V>
): Promise<R> {
return new Promise((resolve, reject) => {
commitMutation(environment, {
...config,
onCompleted: (response, errors) => {
if (errors) {
// This should not happen, as the network layer
// will throw on errors which should result to
// `onError` rather than `onCompleted``.
reject(errors);
return;
}
resolve(getPayload(response));
},
onError: error => {
reject(error);
},
});
});
}
@@ -0,0 +1,23 @@
import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime";
/**
* Creates a Record and retain it forever.
* This means that the garbage collector will
* not remove the record on the next run.
*
* See https://github.com/facebook/relay/issues/1656#issuecomment-380519761
*/
export default function createAndRetain(
environment: Environment,
source: RecordSourceProxy,
id: string,
type: string
): RecordProxy {
const result = source.create(id, type);
environment.retain({
dataID: id,
node: { selections: [] },
variables: {},
});
return result;
}
@@ -0,0 +1,39 @@
import * as React from "react";
import { compose, hoistStatics, InferableComponentEnhancer } from "recompose";
import { Environment } from "relay-runtime";
import { withContext } from "../bootstrap";
/**
* createMutationContainer creates a HOC that
* injects a property with the name specified in `propName`
* and the signature (input: I) => Promise<R>. Calling
* this will call the specified `commit` callback with
* the Relay `environment` provided by the context.
*/
function createMutationContainer<T extends string, I, R>(
propName: T,
commit: (environment: Environment, input: I) => Promise<R>
): InferableComponentEnhancer<{ [P in T]: (input: I) => Promise<R> }> {
return compose(
withContext(({ relayEnvironment }) => ({ relayEnvironment })),
hoistStatics((WrappedComponent: React.ComponentType<any>) => {
class CreateMutationContainer extends React.Component<any> {
private commit = (input: I) => {
return commit(this.props.relayEnvironment, input);
};
public render() {
const { relayEnvironment: _, ...rest } = this.props;
const inject = {
[propName]: this.commit,
};
return <WrappedComponent {...rest} {...inject} />;
}
}
return CreateMutationContainer as React.ComponentType<any>;
})
);
}
export default createMutationContainer;
@@ -0,0 +1,14 @@
export { default as withFragmentContainer } from "./withFragmentContainer";
export { default as withPaginationContainer } from "./withPaginationContainer";
export { default as withRefetchContainer } from "./withRefetchContainer";
export { default as withLocalStateContainer } from "./withLocalStateContainer";
export * from "./withLocalStateContainer";
export { default as QueryRenderer } from "./QueryRenderer";
export * from "./QueryRenderer";
export { default as createMutationContainer } from "./createMutationContainer";
export { default as createAndRetain } from "./createAndRetain";
export {
commitMutationPromise,
commitMutationPromiseNormalized,
} from "./commitMutationPromise";
export { graphql } from "react-relay";
@@ -0,0 +1,12 @@
import { createFragmentContainer, GraphQLTaggedNode } from "react-relay";
import { InferableComponentEnhancerWithProps } from "recompose";
/**
* withFragmentContainer is a curried version of `createFragmentContainers`
* from Relay.
*/
export default <T>(
fragmentSpec: GraphQLTaggedNode
): InferableComponentEnhancerWithProps<T, { [P in keyof T]: any }> => (
component: React.ComponentType<any>
) => createFragmentContainer(component, fragmentSpec) as any;
@@ -0,0 +1,71 @@
import * as React from "react";
import { compose, hoistStatics, InferableComponentEnhancer } from "recompose";
import { CSelector, CSnapshot, Environment } from "relay-runtime";
import { withContext } from "../bootstrap";
interface Props {
relayEnvironment: Environment;
}
/**
* The Root Record of Client-Side Schema Extension must be of this type.
*/
export const LOCAL_TYPE = "Local";
/**
* The Root Record of Client-Side Schema Extension must have this id.
*/
export const LOCAL_ID = "client:root.local";
/**
* withLocalStateContainer allows for subscribing to local state
* that has been added using Client-Side Schema Extensions.
* The `fragmentSpec` must be a `Fragment` on the `LOCAL_TYPE` which
* must have the `LOCAL_ID`.
*/
function withLocalStateContainer<T>(
fragmentSpec: any
): InferableComponentEnhancer<{ local: T }> {
return compose(
withContext(({ relayEnvironment }) => ({ relayEnvironment })),
hoistStatics((WrappedComponent: React.ComponentType<any>) => {
class LocalStateContainer extends React.Component<Props, any> {
constructor(props: Props) {
super(props);
const fragment = fragmentSpec.data().default;
if (fragment.kind !== "Fragment") {
throw new Error("Expected fragment");
}
if (fragment.type !== LOCAL_TYPE) {
throw new Error(
`Type must be "Local" in "Fragment ${fragment.name}"`
);
}
const selector: CSelector<any> = {
dataID: LOCAL_ID,
node: { selections: fragment.selections },
variables: {},
};
const snapshot = props.relayEnvironment.lookup(selector);
props.relayEnvironment.subscribe(snapshot, this.updateSnapshot);
this.state = {
data: snapshot.data,
};
}
private updateSnapshot = (snapshot: CSnapshot<any>) => {
this.setState({ data: snapshot.data });
};
public render() {
const { relayEnvironment: _, ...rest } = this.props;
return <WrappedComponent {...rest} local={this.state.data} />;
}
}
return LocalStateContainer as React.ComponentType<any>;
})
);
}
export default withLocalStateContainer;
@@ -0,0 +1,20 @@
import {
ConnectionConfig,
createPaginationContainer,
GraphQLTaggedNode,
RelayPaginationProp,
} from "react-relay";
import { InferableComponentEnhancerWithProps } from "recompose";
/**
* withPaginationContainer is a curried version of `createPaginationContainers`
* from Relay.
*/
export default <T, InnerProps>(
fragmentSpec: GraphQLTaggedNode,
connectionConfig: ConnectionConfig<InnerProps>
): InferableComponentEnhancerWithProps<
T & { relay: RelayPaginationProp },
{ [P in keyof T]: any }
> => (component: React.ComponentType<any>) =>
createPaginationContainer(component, fragmentSpec, connectionConfig) as any;
@@ -0,0 +1,19 @@
import {
createRefetchContainer,
GraphQLTaggedNode,
RelayRefetchProp,
} from "react-relay";
import { InferableComponentEnhancerWithProps } from "recompose";
/**
* withRefetchContainer is a curried version of `createRefetchContainers`
* from Relay.
*/
export default <T>(
fragmentSpec: GraphQLTaggedNode,
refetchQuery: GraphQLTaggedNode
): InferableComponentEnhancerWithProps<
T & { relay: RelayRefetchProp },
{ [P in keyof T]: any }
> => (component: React.ComponentType<any>) =>
createRefetchContainer(component, fragmentSpec, refetchQuery) as any;
@@ -0,0 +1,12 @@
import { createValidator } from "./validation";
describe("createValidator", () => {
it("should report error when condition is unmet", () => {
const truthy = createValidator(v => !!v, "must be truthy");
expect(truthy(false, {})).toBe("must be truthy");
});
it("should NOT report error when condition is met", () => {
const truthy = createValidator(v => !!v, "must be truthy");
expect(truthy(true, {})).toBe(undefined);
});
});
@@ -0,0 +1,33 @@
import { ReactNode } from "react";
import { VALIDATION_REQUIRED } from "./messages";
type Validator<T, V> = (v: T, values: V) => ReactNode;
/**
* createValidator returns a Validator that returns given `error` when `condition` is falsey.
*/
export function createValidator<T = any, V = any>(
condition: (v: T, values: V) => boolean,
error: ReactNode
): Validator<T, V> {
return (v, values) => (condition(v, values) ? undefined : error);
}
/**
* composeValidators returns a Validator that chains the given validators
* and runs them in sequence until one validator fails and returns an error.
*/
export function composeValidators<T = any, V = any>(
...validators: Array<Validator<T, V>>
) {
return (v: T, values: V) =>
validators.reduce(
(error, validator) => error || validator(v, values),
undefined
);
}
/**
* required is a Validator that checks that the value is truthy.
*/
export const required = createValidator(v => !!v, VALIDATION_REQUIRED());
+2
View File
@@ -0,0 +1,2 @@
// TODO: (@cvle) Extract useful common types into its own package.
export { Diff, Omit, Overwrite, PropTypesOf } from "talk-ui/types";
+8
View File
@@ -0,0 +1,8 @@
const path = require('path');
module.exports = {
extends: "../.babelrc.js",
plugins: [
["babel-plugin-relay", { artifactDirectory: path.resolve(__dirname, "./__generated__") }]
],
}
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react";
import { StatelessComponent } from "react";
import { Center } from "talk-ui/components";
import AssetListContainer from "../containers/AssetListContainer";
import PostCommentFormContainer from "../containers/PostCommentFormContainer";
import StreamContainer from "../containers/StreamContainer";
import Logo from "./Logo";
export interface AppProps {
assets?: any | null;
asset?: {
id: string;
isClosed: boolean;
comments: any | null;
} | null;
}
const App: StatelessComponent<AppProps> = props => {
if (props.assets) {
return <AssetListContainer assets={props.assets} />;
}
if (props.asset) {
return (
<Center>
<Logo gutterBottom />
<StreamContainer comments={props.asset.comments} />
<PostCommentFormContainer assetID={props.asset.id} />
</Center>
);
}
return <div>Asset not found </div>;
};
export default App;
@@ -0,0 +1,16 @@
import * as React from "react";
import { StatelessComponent } from "react";
export interface AssetListProps {
assets: ReadonlyArray<{ id: string; title: string | null }>;
}
const AssetList: StatelessComponent<AssetListProps> = props => {
return (
<div>
{props.assets.map(asset => <div key={asset.id}>{asset.title}</div>)}
</div>
);
};
export default AssetList;
@@ -0,0 +1,11 @@
.root {
width: 400px;
}
.gutterBottom {
margin-bottom: calc(2px * $spacing-unit);
}
.author {
font-weight: $font-weight-medium;
}
@@ -0,0 +1,32 @@
import cn from "classnames";
import React from "react";
import { StatelessComponent } from "react";
import { Typography } from "talk-ui/components";
import * as styles from "./Comment.css";
export interface CommentProps {
className?: string;
author: {
username: string;
} | null;
body: string | null;
gutterBottom?: boolean;
}
const Comment: StatelessComponent<CommentProps> = props => {
const rootClassName = cn(styles.root, props.className, {
[styles.gutterBottom]: props.gutterBottom,
});
return (
<div className={rootClassName}>
<Typography className={styles.author} gutterBottom>
{props.author && props.author.username}
</Typography>
<Typography>{props.body}</Typography>
</div>
);
};
export default Comment;
@@ -0,0 +1,22 @@
import { Localized } from "fluent-react/compat";
import * as React from "react";
import { StatelessComponent } from "react";
import { Typography } from "talk-ui/components";
export interface LogoProps {
className?: string;
gutterBottom?: boolean;
}
const Logo: StatelessComponent<LogoProps> = props => {
return (
<Localized id="stream-logo">
<Typography variant="heading1" gutterBottom={props.gutterBottom}>
Talk NEO
</Typography>
</Localized>
);
};
export default Logo;
@@ -0,0 +1,12 @@
.textarea {
composes: body1 from "talk-ui/shared/typography.css";
display: block;
height: 100px;
width: 400px;
margin-bottom: calc(2px * $spacing-unit);
}
.postButton {
float: right;
}
@@ -0,0 +1,52 @@
import { Localized } from "fluent-react/compat";
import * as React from "react";
import { StatelessComponent } from "react";
import { Field, Form } from "react-final-form";
import { OnSubmit } from "talk-framework/lib/form";
import { required } from "talk-framework/lib/validation";
import { Button, Typography } from "talk-ui/components";
import * as styles from "./PostCommentForm.css";
interface FormProps {
body: string;
}
export interface PostCommentFormProps {
onSubmit: OnSubmit<FormProps>;
}
const PostCommentForm: StatelessComponent<PostCommentFormProps> = props => (
<Form onSubmit={props.onSubmit}>
{({ handleSubmit, submitting }) => (
<form autoComplete="off" onSubmit={handleSubmit}>
<Field name="body" validate={required}>
{({ input, meta }) => (
<div>
<textarea
className={styles.textarea}
name={input.name}
onChange={input.onChange}
value={input.value}
/>
{meta.touched &&
(meta.error || meta.submitError) && (
<Typography align="right" color="error" gutterBottom>
{meta.error || meta.submitError}
</Typography>
)}
</div>
)}
</Field>
<Localized id="postCommentForm-submit">
<Button className={styles.postButton} disabled={submitting} primary>
Post
</Button>
</Localized>
</form>
)}
</Form>
);
export default PostCommentForm;
@@ -0,0 +1,20 @@
import * as React from "react";
import { StatelessComponent } from "react";
import CommentContainer from "../containers/CommentContainer";
export interface StreamProps {
comments: ReadonlyArray<{ id: string }>;
}
const Stream: StatelessComponent<StreamProps> = props => {
return (
<div>
{props.comments.map(comment => (
<CommentContainer key={comment.id} data={comment} gutterBottom />
))}
</div>
);
};
export default Stream;
@@ -0,0 +1,40 @@
import React, { StatelessComponent } from "react";
import { graphql } from "react-relay";
import { withFragmentContainer } from "talk-framework/lib/relay";
import { PropTypesOf } from "talk-framework/types";
import { AppContainer as Data } from "talk-stream/__generated__/AppContainer.graphql";
import App from "../components/App";
interface InnerProps {
data: Data;
}
const AppContainer: StatelessComponent<InnerProps> = props => {
return <App {...props.data} />;
};
const enhanced = withFragmentContainer<{ data: Data }>(
graphql`
fragment AppContainer on Query
@argumentDefinitions(
assetID: { type: "ID!" }
showAssetList: { type: "Boolean!" }
) {
assets @include(if: $showAssetList) {
...AssetListContainer_assets
}
asset(id: $assetID) @skip(if: $showAssetList) {
id
isClosed
comments {
...StreamContainer_comments
}
}
}
`
)(AppContainer);
export type AppContainerProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -0,0 +1,33 @@
import React, { StatelessComponent } from "react";
import { graphql } from "react-relay";
import { withFragmentContainer } from "talk-framework/lib/relay";
import { PropTypesOf } from "talk-framework/types";
import { AssetListContainer_assets as Data } from "talk-stream/__generated__/AssetListContainer_assets.graphql";
import AssetList from "../components/AssetList";
interface InnerProps {
assets: Data;
}
const AssetListContainer: StatelessComponent<InnerProps> = props => {
const assets = props.assets.edges.map(edge => edge.node);
return <AssetList assets={assets} />;
};
const enhanced = withFragmentContainer<{ assets: Data }>(
graphql`
fragment AssetListContainer_assets on AssetsConnection {
edges {
node {
id
title
}
}
}
`
)(AssetListContainer);
export type AssetListContainerProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -0,0 +1,29 @@
import React, { StatelessComponent } from "react";
import { graphql } from "react-relay";
import withFragmentContainer from "talk-framework/lib/relay/withFragmentContainer";
import { Omit, PropTypesOf } from "talk-framework/types";
import { CommentContainer as Data } from "talk-stream/__generated__/CommentContainer.graphql";
import Comment, { CommentProps } from "../components/Comment";
type InnerProps = { data: Data } & Omit<CommentProps, keyof Data>;
const CommentContainer: StatelessComponent<InnerProps> = props => {
const { data, ...rest } = props;
return <Comment {...rest} {...props.data} />;
};
const enhanced = withFragmentContainer<{ data: Data }>(
graphql`
fragment CommentContainer on Comment {
author {
username
}
body
}
`
)(CommentContainer);
export type CommentContainerProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -0,0 +1,38 @@
import React, { Component } from "react";
import { BadUserInputError } from "talk-framework/lib/errors";
import { PropTypesOf } from "talk-framework/types";
import PostCommentForm, {
PostCommentFormProps,
} from "../components/PostCommentForm";
import { CreateCommentMutation, withCreateCommentMutation } from "../mutations";
interface InnerProps {
createComment: CreateCommentMutation;
assetID: string;
}
class PostCommentFormContainer extends Component<InnerProps> {
private onSubmit: PostCommentFormProps["onSubmit"] = async (input, form) => {
try {
await this.props.createComment({
assetID: this.props.assetID,
...input,
});
form.reset();
} catch (error) {
if (error instanceof BadUserInputError) {
return error.invalidArgsLocalized;
}
}
return undefined;
};
public render() {
return <PostCommentForm onSubmit={this.onSubmit} />;
}
}
const enhanced = withCreateCommentMutation(PostCommentFormContainer);
export type PostCommentFormContainerProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -0,0 +1,33 @@
import React, { StatelessComponent } from "react";
import { graphql } from "react-relay";
import { withFragmentContainer } from "talk-framework/lib/relay";
import { PropTypesOf } from "talk-framework/types";
import { StreamContainer_comments as Data } from "talk-stream/__generated__/StreamContainer_comments.graphql";
import Stream from "../components/Stream";
interface InnerProps {
comments: Data;
}
const StreamContainer: StatelessComponent<InnerProps> = props => {
const comments = props.comments.edges.map(edge => edge.node);
return <Stream comments={comments} />;
};
const enhanced = withFragmentContainer<{ comments: Data }>(
graphql`
fragment StreamContainer_comments on CommentsConnection {
edges {
node {
id
...CommentContainer
}
}
}
`
)(StreamContainer);
export type StreamContainerProps = PropTypesOf<typeof enhanced>;
export default enhanced;
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html prefix="og: http://ogp.me/ns#">
<head>
<title>Relay Experiments</title>
<meta charset="utf-8">
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, user-scalable=no">
</head>
<body>
<div id="app" aria-role="application" onclick="void(0)"></div>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
import React from "react";
import { StatelessComponent } from "react";
import ReactDOM from "react-dom";
import {
createContext,
TalkContext,
TalkContextProvider,
} from "talk-framework/lib/bootstrap";
import { initLocalState } from "./local";
import localesData from "./locales";
import AppQuery from "./queries/AppQuery";
// This is called when the context is first initialized.
async function init({ relayEnvironment }: TalkContext) {
await initLocalState(relayEnvironment);
}
async function main() {
// Bootstrap our context.
const context = await createContext({
init,
localesData,
userLocales: navigator.languages,
});
const Index: StatelessComponent = () => (
<TalkContextProvider value={context}>
<AppQuery />
</TalkContextProvider>
);
ReactDOM.render(<Index />, document.getElementById("app"));
}
main();
@@ -0,0 +1,6 @@
/**
* This files contains the various types and ids of the local schema.
*/
export const NETWORK_TYPE = "Network";
export const NETWORK_ID = "client:root.local.network";
+2
View File
@@ -0,0 +1,2 @@
export { default as initLocalState } from "./initLocalState";
export * from "./constants";
@@ -0,0 +1,39 @@
import qs from "query-string";
import { commitLocalUpdate, Environment } from "relay-runtime";
import {
createAndRetain,
LOCAL_ID,
LOCAL_TYPE,
} from "talk-framework/lib/relay";
import { NETWORK_ID, NETWORK_TYPE } from "./constants";
/**
* Initializes the local state, before we start the App.
*/
export default async function initLocalState(environment: Environment) {
commitLocalUpdate(environment, s => {
const root = s.getRoot();
// Create the Local Record which is the Root for the client states.
const localRecord = createAndRetain(environment, s, LOCAL_ID, LOCAL_TYPE);
// Parse query params
const query = qs.parse(location.search);
if (query.assetID) {
localRecord.setValue(query.assetID, "assetID");
}
// Create network Record
const networkRecord = createAndRetain(
environment,
s,
NETWORK_ID,
NETWORK_TYPE
);
networkRecord.setValue(false, "isOffline");
localRecord.setLinkedRecord(networkRecord, "network");
root.setLinkedRecord(localRecord, "local");
});
}
@@ -0,0 +1,13 @@
# Extend graph with local types
type Network {
isOffline: Boolean!
}
type Local {
network: Network!
assetID: String
}
extend type Query {
local: Local!
}
+9
View File
@@ -0,0 +1,9 @@
/**
* The actual content of this file is being generated by our `locales-loader`.
* Please check `./src/loaders` and the webpack config for more information.
*
* This file only represents the types that gets exported.
*/
import { LocalesData } from "talk-framework/lib/i18n";
export default {} as LocalesData;
@@ -0,0 +1,71 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutationContainer,
} from "talk-framework/lib/relay";
import { Omit } from "talk-framework/types";
import {
CreateCommentMutationResponse,
CreateCommentMutationVariables,
} from "talk-stream/__generated__/CreateCommentMutation.graphql";
export type CreateCommentInput = Omit<
CreateCommentMutationVariables["input"],
"clientMutationId"
>;
const mutation = graphql`
mutation CreateCommentMutation($input: CreateCommentInput!) {
createComment(input: $input) {
comment {
id
author {
username
}
body
}
clientMutationId
}
}
`;
let clientMutationId = 0;
function commit(environment: Environment, input: CreateCommentInput) {
return commitMutationPromiseNormalized<
CreateCommentMutationResponse["createComment"],
CreateCommentMutationVariables
>(environment, {
mutation,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
updater: store => {
const payload = store.getRootField("createComment");
if (payload) {
const newRecord = payload.getLinkedRecord("comment")!;
const root = store.getRoot();
const records = root.getLinkedRecords("comments");
if (!records) {
throw new Error("Unexpected cache state");
}
root.setLinkedRecords([...records, newRecord], "comments");
}
},
});
}
export const withCreateCommentMutation = createMutationContainer(
"createComment",
commit
);
export type CreateCommentMutation = (
input: CreateCommentInput
) => Promise<CreateCommentMutationResponse["createComment"]>;
@@ -0,0 +1,23 @@
import { commitLocalUpdate, Environment } from "relay-runtime";
import { createMutationContainer } from "talk-framework/lib/relay";
import { NETWORK_ID } from "../local";
export interface SetNetworkStatusInput {
isOffline: boolean;
}
export type SetNetworkStatusMutation = (input: SetNetworkStatusInput) => void;
async function commit(environment: Environment, input: SetNetworkStatusInput) {
return commitLocalUpdate(environment, store => {
const record = store.get(NETWORK_ID)!;
record.setValue(input.isOffline, "isOffline");
});
}
export const withSetNetworkStatusMutation = createMutationContainer(
"setNetworkStatus",
commit
);
@@ -0,0 +1,2 @@
export * from "./CreateCommentMutation";
export * from "./SetNetworkStatusMutation";
@@ -0,0 +1,60 @@
import * as React from "react";
import { StatelessComponent } from "react";
import {
graphql,
QueryRenderer,
ReadyState,
withLocalStateContainer,
} from "talk-framework/lib/relay";
import {
AppQueryResponse,
AppQueryVariables,
} from "talk-stream/__generated__/AppQuery.graphql";
import { AppQueryLocal as Local } from "talk-stream/__generated__/AppQueryLocal.graphql";
import AppContainer from "../containers/AppContainer";
const render = ({ error, props }: ReadyState<AppQueryResponse>) => {
if (error) {
return <div>{error.message}</div>;
}
if (props) {
return <AppContainer data={props} />;
}
return <div>Loading</div>;
};
interface InnerProps {
local: Local;
}
const AppQuery: StatelessComponent<InnerProps> = props => {
return (
<QueryRenderer<AppQueryVariables, AppQueryResponse>
query={graphql`
query AppQuery($showAssetList: Boolean!, $assetID: ID!) {
...AppContainer
@arguments(showAssetList: $showAssetList, assetID: $assetID)
}
`}
variables={{
// We cast `null` to any due to restrictions of the current graphql syntax.
assetID: props.local.assetID || (null as any),
// TODO: This is set to false, as server does not support querying assets yet.
showAssetList: !props.local.assetID && false,
}}
render={render}
/>
);
};
const enhanced = withLocalStateContainer<Local>(
graphql`
fragment AppQueryLocal on Local {
assetID
}
`
)(AppQuery);
export default enhanced;
+41
View File
@@ -0,0 +1,41 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"target": "es2015",
"module": "esnext",
"jsx": "preserve",
"noEmit": true,
"strictNullChecks": true,
"lib": ["dom", "es7", "scripthost", "es2015", "esnext.asynciterable"],
"baseUrl": "./",
"paths": {
"talk-admin/*": [
"./admin/*"
],
"talk-stream/*": [
"./stream/*"
],
"talk-framework/*": [
"./framework/*"
],
"talk-ui/*": [
"./ui/*"
],
"talk-common/*": [
"../common/*"
],
"talk-locales/*": [
"../../locales/*"
]
}
},
"include": [
"./**/*.ts",
"./**/*.tsx",
"./**/*.d.ts",
"../../types/**/*.d.ts"
],
"exclude": [
"node_modules"
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": [
"../../../tslint.json",
"tslint-react"
],
"rules": {
"jsx-no-multiline-js": false,
"jsx-boolean-value": [true, "never"]
},
"jsRules": {
"jsx-no-multiline-js": false,
"jsx-boolean-value": [true, "never"]
}
}
+8
View File
@@ -0,0 +1,8 @@
# UI Kit
The UI Kit provides the building blocks to create a Rich Graphical User Interface.
## What should be inside the UI Kit
- The UI Kit focuses on providing presentational building blocks that is not specific to any domain or product.
- The UI Kit should be completely independent from the rest of the codebase.
@@ -0,0 +1,10 @@
.root {
composes: buttonReset from "talk-ui/shared/buttonReset.css";
}
.keyboardFocus {
outline-width: 3px;
outline-color: Highlight;
outline-color: -webkit-focus-ring-color;
outline-style: auto;
}
@@ -0,0 +1,28 @@
---
name: BaseButton
menu: UI Kit
---
import { Playground, PropsTable } from 'docz'
import BaseButton from './BaseButton'
# BaseButton
`BaseButton` strips away browser specific styling and unifies the look of the `button` and the `a` tag.
It detects a focus that came from the keyboard rather than mouse or touch and styles the button using
the `className` provided in the `classes.keyboardFocus` property.
When used as a `button` tag the default `type` is set to `button` instead of the standard `submit` in order
to avoid unintended form submissions.
## Basic usage
<Playground>
<BaseButton>Push Me</BaseButton>
</Playground>
Instead of an `button` tag, we can render an `a` tag instead:
<Playground>
<BaseButton anchor>Push Me</BaseButton>
</Playground>
@@ -0,0 +1,61 @@
import cn from "classnames";
import React from "react";
import { ButtonHTMLAttributes, StatelessComponent } from "react";
import { withKeyboardFocus, withStyles } from "talk-ui/hocs";
import { PropTypesOf } from "talk-ui/types";
import * as styles from "./BaseButton.css";
interface InnerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** If set renders an anchor tag instead */
anchor?: boolean;
/**
* This prop can be used to add custom classnames.
* It is handled by the `withStyles `HOC.
*/
classes: typeof styles;
/** This is passed by the `withKeyboardFocus` HOC */
keyboardFocus: boolean;
}
/**
* A button whose styling is stripped off to a minimum and supports
* keyboard focus. It is the base for our other buttons.
*/
const BaseButton: StatelessComponent<InnerProps> = ({
anchor,
className,
classes,
keyboardFocus,
type: typeProp,
...rest
}) => {
let Element = "button";
if (anchor) {
Element = "a";
}
let type = typeProp;
if (anchor && type) {
// tslint:disable:next-line: no-console
console.warn(
"BaseButton used as anchor does not support the `type` property"
);
} else if (type === undefined) {
// Default to button
type = "button";
}
const rootClassName = cn(classes.root, className, {
[classes.keyboardFocus]: keyboardFocus,
});
return <Element {...rest} className={rootClassName} />;
};
const enhanced = withStyles(styles)(withKeyboardFocus(BaseButton));
export type BaseButtonProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -0,0 +1,2 @@
export * from "./BaseButton";
export { default } from "./BaseButton";
@@ -0,0 +1,78 @@
.root {
composes: button from "talk-ui/shared/typography.css";
padding: 5px 20px;
border-radius: $round-corners;
background-color: transparent;
/* TODO: hover styles for the default button */
}
.fullWidth {
display: block;
width: 100%;
box-sizing: border-box;
}
.primary {
background-color: $palette-primary-main;
color: #fff;
&:hover {
background-color: $palette-primary-light;
}
&:active {
background-color: $palette-primary-lighter;
}
&.invert {
background-color: transparent;
border: 1px solid $palette-primary-main;
color: $palette-primary-main;
&:hover {
border-color: $palette-primary-light;
color: $palette-primary-light;
}
&:active {
border-color: $palette-primary-lighter;
color: $palette-primary-lighter;
}
}
}
.secondary {
background-color: $palette-secondary-main;
color: #fff;
&:hover {
background-color: $palette-secondary-light;
}
&:active {
background-color: $palette-secondary-lighter;
}
&.invert {
background-color: transparent;
border: 1px solid $palette-secondary-main;
color: $palette-secondary-main;
&:hover {
border-color: $palette-secondary-light;
color: $palette-secondary-light;
}
&:active {
border-color: $palette-secondary-lighter;
color: $palette-secondary-lighter;
}
}
}
/**
* This seems to be the best way to target modern touch device browsers.
*/
@media (-moz-touch-enabled: 1), (pointer:coarse) {
/* TODO: Remove hover styles */
}
@@ -0,0 +1,20 @@
---
name: Button
menu: UI Kit
---
import { Playground } from 'docz'
import Button from './Button'
# Button
## Basic usage
<Playground>
<Button style={{marginRight: "10px"}}>Push Me</Button>
<Button style={{marginRight: "10px"}} anchor>I'm an Anchor Tag</Button>
<Button style={{marginRight: "10px"}} primary>Primary</Button>
<Button style={{marginRight: "10px"}} secondary>Secondary</Button>
<Button style={{marginTop: "10px"}} primary fullWidth>Full Width</Button>
<Button style={{marginTop: "10px"}} primary invert fullWidth>Full Width Invert</Button>
</Playground>
@@ -0,0 +1,64 @@
import cn from "classnames";
import { pick } from "lodash";
import React, { ButtonHTMLAttributes } from "react";
import { withStyles } from "talk-ui/hocs";
import { PropTypesOf } from "talk-ui/types";
import BaseButton, { BaseButtonProps } from "../BaseButton";
import * as styles from "./Button.css";
// This should extend from BaseButton instead but we can't because of this bug
// TODO: add bug link.
interface InnerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/**
* This prop can be used to add custom classnames.
* It is handled by the `withStyles `HOC.
*/
classes: typeof styles & Partial<BaseButtonProps["classes"]>;
/** If set renders a full width button */
fullWidth?: boolean;
/** If set renders a button with inverted borders */
invert?: boolean;
/** If set renders a button with primary colors */
primary?: boolean;
/** If set renders a button with secondary colors */
secondary?: boolean;
}
class Button extends React.Component<InnerProps> {
public render() {
const {
classes,
className,
fullWidth,
invert,
primary,
secondary,
...rest
} = this.props;
const rootClassName = cn(classes.root, className, {
[classes.invert]: invert,
[classes.fullWidth]: fullWidth,
[classes.primary]: primary,
[classes.secondary]: secondary,
});
return (
<BaseButton
className={rootClassName}
classes={pick(classes, "keyboardFocus")}
{...rest}
/>
);
}
}
const enhanced = withStyles(styles)(Button);
export type ButtonProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -0,0 +1,2 @@
export * from "./Button";
export { default } from "./Button";
@@ -0,0 +1,6 @@
.root {
display: flex;
align-items: center;
flex-direction: column;
}
@@ -0,0 +1,30 @@
import cn from "classnames";
import * as React from "react";
import { ReactNode, StatelessComponent } from "react";
import { withStyles } from "talk-ui/hocs";
import { PropTypesOf } from "talk-ui/types";
import * as styles from "./Center.css";
interface InnerProps {
/**
* This prop can be used to add custom classnames.
* It is handled by the `withStyles `HOC.
*/
classes: Partial<typeof styles>;
className?: string;
children: ReactNode;
}
const Center: StatelessComponent<InnerProps> = props => {
return (
<div className={cn(props.className, props.classes.root)}>
{props.children}
</div>
);
};
const enhanced = withStyles(styles)(Center);
export type CenterProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -0,0 +1,2 @@
export * from "./Center";
export { default } from "./Center";
@@ -0,0 +1,94 @@
.root {
margin: 0;
padding: 0;
}
.heading1 {
composes: heading1 from "talk-ui/shared/typography.css";
}
.heading2 {
composes: heading2 from "talk-ui/shared/typography.css";
}
.heading3 {
composes: heading3 from "talk-ui/shared/typography.css";
}
.heading4 {
composes: heading4 from "talk-ui/shared/typography.css";
}
.subtitle1 {
composes: subtitle1 from "talk-ui/shared/typography.css";
}
.subtitle2 {
composes: subtitle2 from "talk-ui/shared/typography.css";
}
.body1 {
composes: body1 from "talk-ui/shared/typography.css";
}
.body2 {
composes: body2 from "talk-ui/shared/typography.css";
}
.button {
composes: button from "talk-ui/shared/typography.css";
}
.overline {
composes: overline from "talk-ui/shared/typography.css";
}
.alignLeft {
text-align: left;
}
.alignCenter {
text-align: center;
}
.alignRight {
text-align: right;
}
.alignJustify {
text-align: justify;
}
.noWrap {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gutterBottom {
margin-bottom: 0.35em;
}
.paragraph {
margin-bottom: calc(2px * $spacing-unit);
}
.colorInherit {
color: inherit;
}
.colorPrimary {
color: $palette-primary-main;
}
.colorSecondary {
color: $palette-secondary-main;
}
.colorTextSecondary {
color: $palette-text-secondary;
}
.colorError {
color: $palette-error-main;
}
@@ -0,0 +1,140 @@
import cn from "classnames";
import React from "react";
import { HTMLAttributes, ReactNode, StatelessComponent } from "react";
import { withStyles } from "talk-ui/hocs";
import { PropTypesOf } from "talk-ui/types";
import * as styles from "./Typography.css";
type Variant =
| "heading1"
| "heading2"
| "heading3"
| "heading4"
| "subtitle1"
| "subtitle2"
| "body1"
| "body2"
| "button";
// Based on Typography Component of Material UI.
// https://github.com/mui-org/material-ui/blob/303199d39b42a321d28347d8440d69166f872f27/packages/material-ui/src/Typography/Typography.js
interface InnerProps extends HTMLAttributes<any> {
/**
* Set the text-align on the component.
*/
align?: "inherit" | "left" | "center" | "right" | "justify";
/**
* The content of the component.
*/
children: ReactNode;
/**
* Override or extend the styles applied to the component.
*/
classes: typeof styles;
/**
* Convenient prop to override the root styling.
*/
className?: string;
/**
* The color of the component. It supports those theme colors that make sense for this component.
*/
color?:
| "inherit"
| "primary"
| "textSecondary"
| "secondary"
| "error"
| "default";
/**
* The component used for the root node.
* Either a string to use a DOM element or a component.
* By default, it maps the variant to a good default headline component.
*/
component?: React.ComponentType<any> | string;
/**
* If `true`, the text will have a bottom margin.
*/
gutterBottom?: boolean;
/**
* We are empirically mapping the variant property to a range of different DOM element types.
* For instance, h1 to h6. If you wish to change that mapping, you can provide your own.
* Alternatively, you can use the `component` property.
*/
headlineMapping?: { [P in Variant]?: React.ComponentType<any> | string };
/**
* If `true`, the text will not wrap, but instead will truncate with an ellipsis.
*/
noWrap?: boolean;
/**
* If `true`, the text will have a bottom margin.
*/
paragraph?: boolean;
/**
* Applies the theme typography styles.
*/
variant?: Variant;
}
const Typography: StatelessComponent<InnerProps> = props => {
const {
align,
classes,
className,
color,
component,
gutterBottom,
headlineMapping,
noWrap,
paragraph,
variant,
...rest
} = props;
const rootClassName = cn(
classes.root,
classes[variant!],
{
[classes.colorPrimary]: color === "primary",
[classes.colorSecondary]: color === "secondary",
[classes.noWrap]: noWrap,
[classes.gutterBottom]: gutterBottom,
[classes.paragraph]: paragraph,
[classes.alignLeft]: align === "left",
[classes.alignCenter]: align === "center",
[classes.alignRight]: align === "right",
[classes.alignJustify]: align === "justify",
},
className
);
const Component =
component || (paragraph ? "p" : headlineMapping![variant!]) || "span";
return <Component className={rootClassName} {...rest} />;
};
Typography.defaultProps = {
align: "inherit",
color: "default",
gutterBottom: false,
headlineMapping: {
heading1: "h1",
heading2: "h1",
heading3: "h1",
heading4: "h1",
subtitle1: "h2",
subtitle2: "h3",
body1: "p",
body2: "aside",
},
noWrap: false,
paragraph: false,
variant: "body1",
};
const enhanced = withStyles(styles)(Typography);
export type CenterProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -0,0 +1,2 @@
export * from "./Typography";
export { default } from "./Typography";
+4
View File
@@ -0,0 +1,4 @@
export { default as BaseButton } from "./BaseButton";
export { default as Button } from "./Button";
export { default as Center } from "./Center";
export { default as Typography } from "./Typography";
+2
View File
@@ -0,0 +1,2 @@
export { default as withStyles } from "./withStyles";
export { default as withKeyboardFocus } from "./withKeyboardFocus";
@@ -0,0 +1,64 @@
import * as React from "react";
import { FocusEvent, MouseEvent } from "react";
import { hoistStatics } from "recompose";
interface InjectedProps {
onFocus?: React.EventHandler<FocusEvent<any>>;
onBlur?: React.EventHandler<FocusEvent<any>>;
onMouseDown?: React.EventHandler<MouseEvent<any>>;
keyboardFocus?: boolean;
}
/**
* withKeyboardFocus provides a property `keyboardFocus: boolean`
* to indicate a focus on the element, that wasn't triggered by mouse
* or touch.
*/
export default hoistStatics<InjectedProps>(
<T extends InjectedProps>(WrappedComponent: React.ComponentType<T>) => {
class WithKeyboardFocus extends React.Component<any> {
public state = {
keyboardFocus: false,
lastMouseDownTime: 0,
};
private handleFocus: React.EventHandler<FocusEvent<any>> = event => {
if (this.props.onFocus) {
this.props.onFocus(event);
}
const now = new Date().getTime();
if (now - this.state.lastMouseDownTime > 750) {
this.setState({ keyboardFocus: true });
}
};
private handleBlur: React.EventHandler<FocusEvent<any>> = event => {
if (this.props.onBlur) {
this.props.onBlur(event);
}
this.setState({ keyboardFocus: false });
};
private handleMouseDown: React.EventHandler<MouseEvent<any>> = event => {
if (this.props.onMouseDown) {
this.props.onMouseDown(event);
}
this.setState({ lastMouseDownTime: new Date().getTime() });
};
public render() {
return (
<WrappedComponent
{...this.props}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
onMouseDown={this.handleMouseDown}
keyboardFocus={this.state.keyboardFocus}
/>
);
}
}
return WithKeyboardFocus as React.ComponentType<any>;
}
);
+30
View File
@@ -0,0 +1,30 @@
import {
DefaultingInferableComponentEnhancer,
withPropsOnChange,
} from "recompose";
/**
* withStyles provides a property `classes: object` that
* includes the classNames from `styles` and extensions from the
* property `classes`.
*/
function withStyles<T>(
styles: T
): DefaultingInferableComponentEnhancer<{ classes?: Partial<T> }> {
const classes = { ...(styles as any) };
return withPropsOnChange<any, any>(["classes"], props => {
if (props.classes) {
Object.keys(props.classes).forEach(k => {
if (classes[k]) {
classes[k] += ` ${props.classes[k]}`;
} else if (process.env.NODE_ENV !== "production") {
// tslint:disable:next-line: no-console
console.warn("Extending non existant className", k);
}
});
}
return { classes };
});
}
export default withStyles;
+34
View File
@@ -0,0 +1,34 @@
.buttonReset {
/* reset button */
user-select: none;
font-family: inherit;
outline: none;
border: none;
touch-action: manipulation;
padding: 0;
margin: 0;
overflow: hidden;
/* Unify anchor and button. */
cursor: pointer;
display: inline-block;
box-sizing: border-box;
text-align: center;
text-decoration: none;
align-items: flex-start;
vertical-align: middle;
white-space: nowrap;
background: transparent;
font-size: inherit;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0) !important;
&::-moz-focus-inner {
border: 0;
}
&:-moz-focusring {
color: transparent;
textshadow: 0 0 0 #000;
}
}
+59
View File
@@ -0,0 +1,59 @@
.heading1 {
font-size: calc(24rem / $rem-base);
font-weight: $font-weight-medium;
font-family: "Manuale";
line-height: calc(32em / 24);
letter-spacing: calc(0.2em / 16);
color: $palette-text-primary;
}
.heading2 {
font-size: calc(20rem / $rem-base);
font-weight: $font-weight-medium;
font-family: "Manuale";
line-height: calc(24em / 20);
color: $palette-text-primary;
}
.heading3 {
font-size: calc(18rem / $rem-base);
font-weight: $font-weight-medium;
font-family: "Manuale";
line-height: calc(20em / 18);
letter-spacing: calc(0.2em / 16);
color: $palette-text-primary;
}
.heading4 {
font-size: calc(16rem / $rem-base);
font-weight: $font-weight-medium;
font-family: "Manuale";
line-height: calc(18em / 16);
letter-spacing: calc(0.2em / 16);
color: $palette-text-primary;
}
.subtitle1 {}
.subtitle2 {}
.body2 {}
.body1 {
font-size: calc(16rem / $rem-base);
font-weight: $font-weight-regular;
font-family: "Source Sans Pro";
line-height: calc(18em / 16);
letter-spacing: calc(0.2em / 16);
color: $palette-text-primary;
}
.button {
color: $palette-text-secondary;
font-family: "Source Sans Pro";
font-weight: $font-weight-medium;
font-size: 16px;
letter-spacing: calc(0.57em / 16);
}
.overline {}

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