[next] Install (#1957)

* Wip

* Adding Wizard and initial step <3

* Wip

* Adding more steps to the Install Wizard

* Mantaining state across steps

* Adding Steps

* Step Component

* wip

* feat: improved logging

* feat: added tenant install route

* Progress

* Refactor

* Adding rest install mutation

* Wip

* Done

* Addding snapshots

* URL validators

* Adding validation for url, and final step

* Header component and main variants

* fix: resolved router

* fix: corrected spec for prop

* fix: updated test snapshot

* Translations

* Translations

* Adding extra validation

* prefixing events with On - onSaveData

* prefixing events with On - onSaveData

* Adding translations, and colors from the palette

* Centering steps

* Ready

* feat: added production redirects

* fix: fixes during production

* fix: removed dead code

* Placeholder translations

* removing submit from cancel

* Adding a description for the URL field

* typo

* Refactor and renaming

* Using white as var

* Refactor and renaming

* feat: use new palette colors

* fix: extracted styles

* Adding await to avoid race condition with setState :)

* lint

* feat: added urls

* fix: wizard leaking styles

* fix: simplify icon style

* fix: adjust global body css to remove padding to allow seamless integration

* fix: types and small refactors

* fix: correctly filter wizard children

* refactor: simplify step containers

* fix: better typecheck

* test: remove obsolete snapshots

* fix: don't export FormProps
This commit is contained in:
Wyatt Johnson
2018-10-11 22:13:02 +00:00
committed by GitHub
parent 44e6d4b26a
commit 8184c3932e
76 changed files with 2009 additions and 43 deletions
+19
View File
@@ -65,6 +65,23 @@ const config: Config = {
runOnInit: true,
}),
},
compileRelayInstall: {
paths: [
"core/client/install/**/*.ts",
"core/client/install/**/*.tsx",
"core/client/install/**/*.graphql",
"core/server/**/*.graphql",
],
ignore: [
"core/**/*.d.ts",
"core/**/*.graphql.ts",
"**/test/**/*",
"core/**/*.spec.*",
],
executor: new CommandExecutor("npm run compile:relay-install", {
runOnInit: true,
}),
},
compileCSSTypes: {
paths: ["**/*.css"],
executor: new CommandExecutor("npm run compile:css-types", {
@@ -94,6 +111,7 @@ const config: Config = {
"compileCSSTypes",
"compileRelayStream",
"compileRelayAuth",
"compileRelayInstall",
"compileRelayAdmin",
"compileSchema",
],
@@ -103,6 +121,7 @@ const config: Config = {
"compileCSSTypes",
"compileRelayStream",
"compileRelayAuth",
"compileRelayInstall",
],
},
};
+21 -5
View File
@@ -1675,6 +1675,15 @@
"@types/node": "*"
}
},
"@types/bunyan-prettystream": {
"version": "0.1.31",
"resolved": "https://registry.npmjs.org/@types/bunyan-prettystream/-/bunyan-prettystream-0.1.31.tgz",
"integrity": "sha512-NE7fq2ZcX7OSMK+VhTNJkVEHlo+hm0uVXpuLeH1ifGm52Qwuo/kLD2GHo7UcEXMFu3duKver/AFo8C4TME93zw==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/case-sensitive-paths-webpack-plugin": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@types/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.1.2.tgz",
@@ -5714,6 +5723,11 @@
"safe-json-stringify": "~1"
}
},
"bunyan-prettystream": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/bunyan-prettystream/-/bunyan-prettystream-0.1.3.tgz",
"integrity": "sha1-bDtxMmb2rTIAfHtqsemYokU0nZg="
},
"bytes": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz",
@@ -6671,6 +6685,13 @@
"moment": "2.22.2",
"validator": "7.2.0",
"yargs-parser": "10.0.0"
},
"dependencies": {
"validator": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-7.2.0.tgz",
"integrity": "sha512-c8NGTUYeBEcUIGeMppmNVKHE7wwfm3mYbNZxV+c5mlv9fDHI7Ad3p07qfNrn/CvpdkK2k61fOLRO2sTEhgQXmg=="
}
}
},
"cookie": {
@@ -25297,11 +25318,6 @@
"spdx-expression-parse": "^3.0.0"
}
},
"validator": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-7.2.0.tgz",
"integrity": "sha512-c8NGTUYeBEcUIGeMppmNVKHE7wwfm3mYbNZxV+c5mlv9fDHI7Ad3p07qfNrn/CvpdkK2k61fOLRO2sTEhgQXmg=="
},
"value-equal": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/value-equal/-/value-equal-0.4.0.tgz",
+3
View File
@@ -10,6 +10,7 @@
"compile:css-types": "tcm src/core/client/",
"compile:relay-stream": "ts-node ./scripts/compileRelay --src ./src/core/client/stream --schema tenant",
"compile:relay-auth": "ts-node ./scripts/compileRelay --src ./src/core/client/auth --schema tenant",
"compile:relay-install": "ts-node ./scripts/compileRelay --src ./src/core/client/install --schema tenant",
"compile:relay-admin": "ts-node ./scripts/compileRelay --src ./src/core/client/admin --schema tenant",
"compile:schema": "node ./scripts/generateSchemaTypes.js",
"docz": "docz",
@@ -38,6 +39,7 @@
"bcryptjs": "^2.4.3",
"bull": "^3.4.4",
"bunyan": "^1.8.12",
"bunyan-prettystream": "^0.1.3",
"cheerio": "^1.0.0-rc.2",
"consolidate": "0.14.0",
"convict": "^4.3.1",
@@ -96,6 +98,7 @@
"@types/bcryptjs": "^2.4.1",
"@types/bull": "^3.3.16",
"@types/bunyan": "^1.8.4",
"@types/bunyan-prettystream": "^0.1.31",
"@types/case-sensitive-paths-webpack-plugin": "^2.1.2",
"@types/cheerio": "^0.22.8",
"@types/chokidar": "^1.7.5",
+30
View File
@@ -255,6 +255,22 @@ export default function createWebpackConfig({
},
],
},
{
test: paths.appInstallLocalesTemplate,
use: [
// This is the locales loader that loads available locales
// from a particular target.
{
loader: "locales-loader",
options: {
...localesOptions,
// Target specifies the prefix for fluent files to be loaded.
// ${target}-xyz.ftl and ${†arget}.ftl are loaded into the locales.
target: "install",
},
},
],
},
// Loader for our fluent files.
{
test: /\.ftl$/,
@@ -432,6 +448,12 @@ export default function createWebpackConfig({
paths.appAuthIndex,
// Remove deactivated entries.
],
install: [
// We ship polyfills by default
paths.appPolyfill,
...devServerEntries,
paths.appInstallIndex,
],
admin: [
// We ship polyfills by default
paths.appPolyfill,
@@ -457,6 +479,14 @@ export default function createWebpackConfig({
inject: "body",
...htmlWebpackConfig,
}),
// Generates an `install.html` file with the <script> injected.
new HtmlWebpackPlugin({
filename: "install.html",
template: paths.appInstallHTML,
chunks: ["install"],
inject: "body",
...htmlWebpackConfig,
}),
// Generates an `admin.html` file with the <script> injected.
new HtmlWebpackPlugin({
filename: "admin.html",
+4
View File
@@ -29,6 +29,10 @@ export default {
appAuthLocalesTemplate: resolveSrc("core/client/auth/locales.ts"),
appAuthIndex: resolveSrc("core/client/auth/index.tsx"),
appInstallHTML: resolveSrc("core/client/install/index.html"),
appInstallLocalesTemplate: resolveSrc("core/client/install/locales.ts"),
appInstallIndex: resolveSrc("core/client/install/index.tsx"),
appAdminHTML: resolveSrc("core/client/admin/index.html"),
appAdminLocalesTemplate: resolveSrc("core/client/admin/locales.ts"),
appAdminIndex: resolveSrc("core/client/admin/index.tsx"),
+9 -2
View File
@@ -15,7 +15,13 @@ import PymControl, {
defaultPymControlFactory,
PymControlFactory,
} from "./PymControl";
import { ensureEndSlash } from "./utils";
import { ensureNoEndSlash } from "./utils";
// This is importing the url helper from the framework using a relative path
// import because the ts paths are not configured to use the framework for this
// target.
// TODO: (wyattjoh) replace with import from framework when we include it in the config.
import urls from "../framework/helpers/urls";
export interface StreamEmbedConfig {
assetID?: string;
@@ -110,7 +116,8 @@ export class StreamEmbed {
assetURL: this.config.assetURL,
commentID: this.config.commentID,
});
const url = `${ensureEndSlash(this.config.rootURL)}stream.html${
const url = `${ensureNoEndSlash(this.config.rootURL)}${urls.embed.stream}${
query ? `?${query}` : ""
}`;
this.pymControl = this.pymControlFactory({
@@ -4,7 +4,7 @@ exports[`should pass correct values to pymControl 1`] = `
Object {
"id": "container-id",
"title": "StreamEmbed",
"url": "http://localhost/stream.html?assetID=asset-id&assetURL=asset-url&commentID=comment-id",
"url": "http://localhost/embed/stream?assetID=asset-id&assetURL=asset-url&commentID=comment-id",
}
`;
@@ -12,6 +12,6 @@ exports[`should pass default values to pymControl 1`] = `
Object {
"id": "container-id",
"title": "StreamEmbed",
"url": "http://localhost/stream.html",
"url": "http://localhost/embed/stream",
}
`;
@@ -1,5 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Basic integration test should render iframe 1`] = `"<iframe src=\\"http://localhost/stream.html?assetURL=http%3A%2F%2Flocalhost%2F&amp;initialWidth=0&amp;childId=basic-integration-test-id&amp;parentTitle=&amp;parentUrl=http%3A%2F%2Flocalhost%2F\\" width=\\"100%\\" scrolling=\\"no\\" marginheight=\\"0\\" frameborder=\\"0\\" title=\\"Talk Embed Stream\\" id=\\"basic-integration-test-id_iframe\\" name=\\"basic-integration-test-id_iframe\\" style=\\"width: 1px; min-width: 100%;\\"></iframe>"`;
exports[`Basic integration test should render iframe 1`] = `"<iframe src=\\"http://localhost/embed/stream?assetURL=http%3A%2F%2Flocalhost%2F&amp;initialWidth=0&amp;childId=basic-integration-test-id&amp;parentTitle=&amp;parentUrl=http%3A%2F%2Flocalhost%2F\\" width=\\"100%\\" scrolling=\\"no\\" marginheight=\\"0\\" frameborder=\\"0\\" title=\\"Talk Embed Stream\\" id=\\"basic-integration-test-id_iframe\\" name=\\"basic-integration-test-id_iframe\\" style=\\"width: 1px; min-width: 100%;\\"></iframe>"`;
exports[`Basic integration test should use canonical link 1`] = `"<iframe src=\\"http://localhost/stream.html?assetURL=http%3A%2F%2Flocalhost%2Fcanonical&amp;initialWidth=0&amp;childId=basic-integration-test-id&amp;parentTitle=&amp;parentUrl=http%3A%2F%2Flocalhost%2F\\" width=\\"100%\\" scrolling=\\"no\\" marginheight=\\"0\\" frameborder=\\"0\\" title=\\"Talk Embed Stream\\" name=\\"basic-integration-test-id_iframe\\" style=\\"width: 1px; min-width: 100%;\\"></iframe>"`;
exports[`Basic integration test should use canonical link 1`] = `"<iframe src=\\"http://localhost/embed/stream?assetURL=http%3A%2F%2Flocalhost%2Fcanonical&amp;initialWidth=0&amp;childId=basic-integration-test-id&amp;parentTitle=&amp;parentUrl=http%3A%2F%2Flocalhost%2F\\" width=\\"100%\\" scrolling=\\"no\\" marginheight=\\"0\\" frameborder=\\"0\\" title=\\"Talk Embed Stream\\" name=\\"basic-integration-test-id_iframe\\" style=\\"width: 1px; min-width: 100%;\\"></iframe>"`;
+1 -1
View File
@@ -2,7 +2,7 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"lib": ["dom", "es5"],
"types": ["jest"],
"types": ["jest", "node"],
"paths": {}
},
"include": [
@@ -0,0 +1,11 @@
import ensureNoEndSlash from "./ensureNoEndSlash";
it("should remove the slash from the end", () => {
const path = ensureNoEndSlash("/test/");
expect(path).toBe("/test");
});
it("should not change a string if there is no ending slash", () => {
const path = ensureNoEndSlash("/test");
expect(path).toBe("/test");
});
@@ -0,0 +1,3 @@
export default function ensureEndSlash(p: string) {
return p.replace(/\/$/, "");
}
+1
View File
@@ -1,5 +1,6 @@
export { default as buildURL } from "./buildURL";
export { default as ensureEndSlash } from "./ensureEndSlash";
export { default as ensureNoEndSlash } from "./ensureNoEndSlash";
export { default as startsWith } from "./startsWith";
export { default as prefixStorage } from "./prefixStorage";
export { default as parseHashQuery } from "./parseHashQuery";
@@ -1,2 +1,3 @@
export { default as getMe } from "./getMe";
export { default as getURLWithCommentID } from "./getURLWithCommentID";
export { default as urls } from "./urls";
@@ -0,0 +1,15 @@
export default (process.env.NODE_ENV !== "development"
? {
admin: "/admin",
embed: {
stream: "/embed/stream",
auth: "/embed/auth",
},
}
: {
admin: "/admin.html",
embed: {
stream: "/stream.html",
auth: "/auth.html",
},
});
@@ -53,3 +53,9 @@ export const PASSWORDS_DO_NOT_MATCH = () => (
<span>Passwords do not match. Try again.</span>
</Localized>
);
export const INVALID_URL = () => (
<Localized id="framework-validation-invalidURL">
<span>Invalid URL</span>
</Localized>
);
@@ -2,6 +2,7 @@ import { ReactNode } from "react";
import {
INVALID_CHARACTERS,
INVALID_EMAIL,
INVALID_URL,
PASSWORD_TOO_SHORT,
PASSWORDS_DO_NOT_MATCH,
USERNAME_TOO_LONG,
@@ -56,6 +57,17 @@ export const validateUsernameCharacters = createValidator(
INVALID_CHARACTERS()
);
/**
* validateURL is a Validator that checks that the URL only contains valid characters.
*/
export const validateURL = createValidator(
v =>
/^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/.test(
v
),
INVALID_URL()
);
/**
* validateUsernameMinLength is a Validator that checks that the username has a min length of characters
*/
+1
View File
@@ -1,3 +1,4 @@
export { default as signIn, SignInInput } from "./signIn";
export { default as signUp, SignUpInput } from "./signUp";
export { default as signOut } from "./signOut";
export { default as install, InstallInput } from "./install";
+22
View File
@@ -0,0 +1,22 @@
import { RestClient } from "../lib/rest";
export interface InstallInput {
tenant: {
organizationName: string;
organizationContactEmail: string;
organizationURL: string;
domains: string[];
};
user: {
username: string;
password: string;
email: string;
};
}
export default function install(rest: RestClient, input: InstallInput) {
return rest.fetch("/tenant/install", {
method: "POST",
body: input,
});
}
+10
View File
@@ -0,0 +1,10 @@
const path = require("path");
module.exports = {
extends: "../.babelrc.js",
plugins: [
[
"babel-plugin-relay",
{ artifactDirectory: path.resolve(__dirname, "./__generated__") },
],
],
};
@@ -0,0 +1,25 @@
/* Here we add global stylings for body and document */
:global {
body {
margin: 0;
/* Support for all WebKit browsers. */
-webkit-font-smoothing: antialiased;
/* Support for Firefox. */
-moz-osx-font-smoothing: grayscale;
background-color: var(--palette-background-light);
color: var(--palette-text-primary);
}
}
.root {
width: 100%;
box-sizing: border-box;
}
.container {
padding: var(--spacing-unit) var(--spacing-unit) calc(2 * var(--spacing-unit))
var(--spacing-unit);
max-width: 1080px;
margin: 0 auto;
}
@@ -0,0 +1,9 @@
import { shallow } from "enzyme";
import React from "react";
import App from "./App";
it("renders correctly", () => {
const wrapper = shallow(<App />);
expect(wrapper).toMatchSnapshot();
});
@@ -0,0 +1,20 @@
import React, { Component } from "react";
import * as styles from "./App.css";
import WizardContainer from "../containers/WizardContainer";
import MainBar from "./MainBar";
class App extends Component {
public render() {
return (
<div className={styles.root}>
<MainBar />
<div className={styles.container}>
<WizardContainer />
</div>
</div>
);
}
}
export default App;
@@ -0,0 +1,20 @@
.root {
padding: calc(4 * var(--spacing-unit)) 0;
}
.headline {
font-size: 1.2rem;
}
.headlineMain {
font-weight: bold;
font-size: 1.5rem;
}
.subHeadline {
font-size: 1.5rem;
}
.subHeadlineMain {
font-size: 2rem;
}
@@ -0,0 +1,42 @@
import cn from "classnames";
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Flex, Typography } from "talk-ui/components";
import * as styles from "./Header.css";
interface HeaderProps {
main?: boolean;
}
const Header: StatelessComponent<HeaderProps> = ({ main }) => {
return (
<Flex
alignItems="center"
justifyContent="center"
direction="column"
className={styles.root}
>
<Typography
className={cn(styles.headline, {
[styles.headlineMain]: main,
})}
>
The Coral Project
</Typography>
<Localized id="install-title">
<Typography
className={cn(styles.subHeadline, {
[styles.subHeadlineMain]: main,
})}
variant="heading1"
>
Talk Installation Wizard
</Typography>
</Localized>
</Flex>
);
};
export default Header;
@@ -0,0 +1,26 @@
.root {
width: 100%;
border-bottom: 2px solid var(--palette-grey-main);
padding: var(--spacing-unit);
box-sizing: border-box;
background: var(--palette-text-primary);
background: linear-gradient(
to right,
var(--palette-text-primary) 0%,
var(--palette-grey-main) 100%
);
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.2), 0 4px 5px 0 rgba(0, 0, 0, 0.14),
0 1px 10px 0 rgba(0, 0, 0, 0.12);
}
.container {
max-width: 1080px;
margin: 0 auto;
display: flex;
justify-content: flex-start;
box-sizing: border-box;
}
.title {
color: var(--palette-text-light);
}
@@ -0,0 +1,19 @@
import React from "react";
import { Typography } from "talk-ui/components";
import * as styles from "./MainBar.css";
const MainBar = () => {
return (
<div className={styles.root}>
<div className={styles.container}>
<Typography variant="heading1" className={styles.title}>
Talk
</Typography>
</div>
</div>
);
};
export default MainBar;
@@ -0,0 +1,13 @@
.root {
max-width: 600px;
margin: 0 auto;
}
.section {
max-width: 400px;
margin: 0 auto;
}
.stepBar {
padding: var(--spacing-unit) 0 calc(6 * var(--spacing-unit));
}
@@ -0,0 +1,57 @@
import cn from "classnames";
import { Localized } from "fluent-react/compat";
import React, { Component, ReactNode } from "react";
import { Step, StepBar } from "talk-ui/components";
import { WizardProps } from "../components/Wizard";
import Header from "./Header";
import * as styles from "./Wizard.css";
export interface WizardProps {
currentStep: number;
className?: string;
children: ReactNode;
}
class Wizard extends Component<WizardProps> {
public render() {
const { children, currentStep, className } = this.props;
const wizardChildren = React.Children.toArray(children);
const wizardChildrenToRender = wizardChildren.filter(
(_, i) => i === currentStep
);
return (
<div className={cn(className, styles.root)}>
<Header main={currentStep === 0} />
{currentStep !== 0 &&
currentStep !== wizardChildren.length - 1 && (
<StepBar currentStep={currentStep - 1} className={styles.stepBar}>
<Step hidden>Start</Step>
<Step>
<Localized id="install-createYourAccount-stepTitle">
<span>Create Admin Account</span>
</Localized>
</Step>
<Step>
<Localized id="install-addOrganization-stepTitle">
<span>Add Organization Details</span>
</Localized>
</Step>
<Step>
<Localized id="install-permittedDomains-stepTitle">
<span>Add Permitted Domains</span>
</Localized>
</Step>
<Step hidden>Finish</Step>
</StepBar>
)}
<section className={styles.section}>{wizardChildrenToRender}</section>
</div>
);
}
}
export default Wizard;
@@ -0,0 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<div
className="App-root"
>
<MainBar />
<div
className="App-container"
>
<withContext(createMutationContainer(WizardContainer)) />
</div>
</div>
`;
@@ -0,0 +1,124 @@
import React, { Component } from "react";
import Wizard from "../components/Wizard";
import { InstallMutation, withInstallMutation } from "../mutations";
import FinalStep from "../steps/components/FinalStep";
import InitialStep from "../steps/components/InitialStep";
import AddOrganizationContainer from "../steps/containers/AddOrganizationContainer";
import CreateYourAccountContainer from "../steps/containers/CreateYourAccountContainer";
import PermittedDomainsContainer from "../steps/containers/PermittedDomainsContainer";
import { InstallInput } from "talk-framework/rest";
interface FormData {
organizationName: string;
organizationContactEmail: string;
organizationURL: string;
email: string;
username: string;
password: string;
confirmPassword: string;
domains: string[];
}
interface WizardContainerState {
step: number;
data: FormData;
}
function shapeFinalData(data: FormData): InstallInput {
const {
organizationName,
organizationContactEmail,
organizationURL,
domains,
username,
password,
email,
} = data;
return {
tenant: {
organizationName,
organizationContactEmail,
organizationURL,
domains,
},
user: {
username,
password,
email,
},
};
}
interface Props {
install: InstallMutation;
}
class WizardContainer extends Component<Props, WizardContainerState> {
public state = {
step: 0,
data: {
organizationContactEmail: "",
organizationName: "",
organizationURL: "",
email: "",
username: "",
password: "",
confirmPassword: "",
domains: [],
},
};
private handleSaveData = (newData: FormData) => {
this.setState(({ data }) => ({
data: { ...data, ...newData },
}));
};
private handleGoToNextStep = () =>
this.setState(({ step }) => ({
step: step + 1,
}));
private handleGoToPreviousStep = () =>
this.setState(({ step }) => ({
step: step - 1,
}));
private handleInstall = async (newData: Partial<FormData>) => {
return await this.props.install(
shapeFinalData({ ...this.state.data, ...newData })
);
};
public render() {
return (
<Wizard currentStep={this.state.step}>
<InitialStep onGoToNextStep={this.handleGoToNextStep} />
<CreateYourAccountContainer
data={this.state.data}
onSaveData={this.handleSaveData}
onGoToNextStep={this.handleGoToNextStep}
onGoToPreviousStep={this.handleGoToPreviousStep}
/>
<AddOrganizationContainer
data={this.state.data}
onSaveData={this.handleSaveData}
onGoToNextStep={this.handleGoToNextStep}
onGoToPreviousStep={this.handleGoToPreviousStep}
/>
<PermittedDomainsContainer
data={this.state.data}
onGoToNextStep={this.handleGoToNextStep}
onGoToPreviousStep={this.handleGoToPreviousStep}
onInstall={this.handleInstall}
/>
<FinalStep />
</Wizard>
);
}
}
export default withInstallMutation(WizardContainer);
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<title>Talk - Install</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"></div>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
import React from "react";
import { StatelessComponent } from "react";
import ReactDOM from "react-dom";
import { createManaged } from "talk-framework/lib/bootstrap";
import App from "./components/App";
import localesData from "./locales";
async function main() {
const ManagedTalkContextProvider = await createManaged({
localesData,
userLocales: navigator.languages,
});
const Index: StatelessComponent = () => (
<ManagedTalkContextProvider>
<App />
</ManagedTalkContextProvider>
);
ReactDOM.render(<Index />, document.getElementById("app"));
}
main();
+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,21 @@
import { Environment } from "relay-runtime";
import { TalkContext } from "talk-framework/lib/bootstrap";
import { createMutationContainer } from "talk-framework/lib/relay";
import { install, InstallInput } from "talk-framework/rest";
export type InstallMutation = (input: InstallInput) => Promise<void>;
export async function commit(
environment: Environment,
input: InstallInput,
{ rest }: TalkContext
) {
try {
await install(rest, input);
} catch (err) {
throw err;
}
}
export const withInstallMutation = createMutationContainer("install", commit);
@@ -0,0 +1 @@
export { withInstallMutation, InstallMutation } from "./InstallMutation";
@@ -0,0 +1,204 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Field, Form } from "react-final-form";
import { OnSubmit } from "talk-framework/lib/form";
import {
composeValidators,
required,
validateEmail,
validateURL,
} from "talk-framework/lib/validation";
import {
CallOut,
Flex,
FormField,
HorizontalGutter,
InputDescription,
InputLabel,
TextField,
Typography,
ValidationMessage,
} from "talk-ui/components";
import BackButton from "./BackButton";
import NextButton from "./NextButton";
interface FormProps {
organizationName: string;
organizationContactEmail: string;
organizationURL: string;
}
export interface AddOrganizationForm {
onSubmit: OnSubmit<FormProps>;
onGoToPreviousStep: () => void;
data: FormProps;
}
const AddOrganization: StatelessComponent<AddOrganizationForm> = props => {
return (
<Form
onSubmit={props.onSubmit}
initialValues={{
organizationName: props.data.organizationName,
organizationContactEmail: props.data.organizationContactEmail,
organizationURL: props.data.organizationURL,
}}
>
{({ handleSubmit, submitting, submitError }) => (
<form autoComplete="off" onSubmit={handleSubmit}>
<HorizontalGutter size="double">
<Localized id="install-addOrganization-title">
<Typography variant="heading1" align="center">
Add Organization
</Typography>
</Localized>
<Localized id="install-addOrganization-description">
<Typography variant="bodyCopy" align="center">
Please tell us the name of your organization. This will appear
in emails when inviting new team members.
</Typography>
</Localized>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
<Field
name="organizationName"
validate={composeValidators(required)}
>
{({ input, meta }) => (
<FormField>
<Localized id="install-addOrganization-orgName">
<InputLabel>Organization Name</InputLabel>
</Localized>
<Localized
id="install-addOrganization-orgNameTextField"
attrs={{ placeholder: true }}
>
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
placeholder="Organization Name"
color={
meta.touched && (meta.error || meta.submitError)
? "error"
: "regular"
}
disabled={submitting}
fullWidth
/>
</Localized>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</FormField>
)}
</Field>
<Field
name="organizationContactEmail"
validate={composeValidators(required, validateEmail)}
>
{({ input, meta }) => (
<FormField>
<Localized id="install-addOrganization-orgEmail">
<InputLabel>Organization Contact Email</InputLabel>
</Localized>
<Localized
id="install-addOrganization-orgEmailTextField"
attrs={{ placeholder: true }}
>
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
placeholder="Organization Contact Email"
color={
meta.touched && (meta.error || meta.submitError)
? "error"
: "regular"
}
disabled={submitting}
fullWidth
/>
</Localized>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</FormField>
)}
</Field>
<Field
name="organizationURL"
validate={composeValidators(required, validateURL)}
>
{({ input, meta }) => (
<FormField>
<Localized id="install-addOrganization-orgURL">
<InputLabel>Organization URL</InputLabel>
</Localized>
<Localized
id="install-addOrganization-orgURLDescription"
strong={<strong />}
>
<InputDescription>
{/* Related: https://github.com/prettier/prettier/issues/2347 */}
Be sure to include <strong>{"http://"}</strong> or{" "}
<strong>{"https://"}</strong> in your URL
</InputDescription>
</Localized>
<Localized
id="install-addOrganization-orgURLTextField"
attrs={{ placeholder: true }}
>
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
placeholder="Organization URL"
color={
meta.touched && (meta.error || meta.submitError)
? "error"
: "regular"
}
disabled={submitting}
fullWidth
/>
</Localized>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</FormField>
)}
</Field>
<Flex direction="row-reverse" itemGutter>
<NextButton submitting={submitting} />
<BackButton
submitting={submitting}
onGoToPreviousStep={props.onGoToPreviousStep}
/>
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
);
};
export default AddOrganization;
@@ -0,0 +1,30 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Button } from "talk-ui/components";
export interface BackButtonProps {
submitting: boolean;
onGoToPreviousStep: () => void;
}
const BackButton: StatelessComponent<BackButtonProps> = ({
submitting,
onGoToPreviousStep,
}) => {
return (
<Localized id="install-back">
<Button
onClick={onGoToPreviousStep}
variant="filled"
color="regular"
size="large"
disabled={submitting}
>
Back
</Button>
</Localized>
);
};
export default BackButton;
@@ -0,0 +1,235 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Field, Form } from "react-final-form";
import { OnSubmit } from "talk-framework/lib/form";
import {
composeValidators,
required,
validateEmail,
validateEqualPasswords,
validatePassword,
validateUsername,
} from "talk-framework/lib/validation";
import {
CallOut,
Flex,
FormField,
HorizontalGutter,
InputDescription,
InputLabel,
TextField,
Typography,
ValidationMessage,
} from "talk-ui/components";
import NextButton from "./NextButton";
interface FormProps {
email: string;
username: string;
password: string;
confirmPassword: string;
}
export interface CreateYourAccountForm {
onSubmit: OnSubmit<FormProps>;
onGoToPreviousStep: () => void;
data: FormProps;
}
const CreateYourAccount: StatelessComponent<CreateYourAccountForm> = props => {
return (
<Form
onSubmit={props.onSubmit}
initialValues={{
email: props.data.email,
username: props.data.username,
}}
>
{({ handleSubmit, submitting, submitError }) => (
<form autoComplete="off" onSubmit={handleSubmit}>
<HorizontalGutter size="double">
<Localized id="install-createYourAccount-title">
<Typography variant="heading1" align="center">
Create an Administrator Account
</Typography>
</Localized>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
<Field
name="email"
validate={composeValidators(required, validateEmail)}
>
{({ input, meta }) => (
<FormField>
<Localized id="install-createYourAccount-email">
<InputLabel>Email</InputLabel>
</Localized>
<Localized
id="install-createYourAccount-emailTextField"
attrs={{ placeholder: true }}
>
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
placeholder="Email"
color={
meta.touched && (meta.error || meta.submitError)
? "error"
: "regular"
}
disabled={submitting}
fullWidth
/>
</Localized>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</FormField>
)}
</Field>
<Field
name="username"
validate={composeValidators(required, validateUsername)}
>
{({ input, meta }) => (
<FormField>
<Localized id="install-createYourAccount-username">
<InputLabel>Username</InputLabel>
</Localized>
<Localized id="install-createYourAccount-usernameDescription">
<InputDescription>
A unique identifier displayed on your comments. You may
use _ and .
</InputDescription>
</Localized>
<Localized
id="install-createYourAccount-usernameTextField"
attrs={{ placeholder: true }}
>
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
placeholder="Username"
color={
meta.touched && (meta.error || meta.submitError)
? "error"
: "regular"
}
disabled={submitting}
fullWidth
/>
</Localized>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</FormField>
)}
</Field>
<Field
name="password"
validate={composeValidators(required, validatePassword)}
>
{({ input, meta }) => (
<FormField>
<Localized id="install-createYourAccount-password">
<InputLabel>Password</InputLabel>
</Localized>
<Localized id="install-createYourAccount-passwordDescription">
<InputDescription>
Must be at least 8 characters
</InputDescription>
</Localized>
<Localized
id="install-createYourAccount-passwordTextField"
attrs={{ placeholder: true }}
>
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
placeholder="Password"
type="password"
color={
meta.touched && (meta.error || meta.submitError)
? "error"
: "regular"
}
disabled={submitting}
fullWidth
/>
</Localized>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</FormField>
)}
</Field>
<Field
name="confirmPassword"
validate={composeValidators(required, validateEqualPasswords)}
>
{({ input, meta }) => (
<FormField>
<Localized id="install-createYourAccount-confirmPassword">
<InputLabel>Confirm Password</InputLabel>
</Localized>
<Localized
id="install-createYourAccount-confirmPasswordTextField"
attrs={{ placeholder: true }}
>
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
placeholder="Confirm Password"
type="password"
color={
meta.touched && (meta.error || meta.submitError)
? "error"
: "regular"
}
disabled={submitting}
fullWidth
/>
</Localized>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</FormField>
)}
</Field>
<Flex direction="row-reverse">
<NextButton submitting={submitting} />
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
);
};
export default CreateYourAccount;
@@ -0,0 +1,41 @@
import { Localized } from "fluent-react/compat";
import React, { Component } from "react";
import { urls } from "talk-framework/helpers";
import { Button, Flex, Typography } from "talk-ui/components";
class FinalStep extends Component {
public render() {
return (
<Flex direction="column" justifyContent="center" itemGutter="double">
<Localized id="install-finalStep-description">
<Typography variant="bodyCopy">
Thanks for installing Talk! We sent an email to verify your email
address. While you finish setting up the account, you can start
engaging with your readers now.
</Typography>
</Localized>
<Flex direction="row" itemGutter justifyContent="center">
<Localized id="install-finalStep-goToTheDocs">
<Button
anchor
color="regular"
variant="filled"
href="https://docs.coralproject.net"
target="_blank"
>
Go to the Docs
</Button>
</Localized>
<Localized id="install-finalStep-goToAdmin">
<Button anchor color="primary" variant="filled" href={urls.admin}>
Go to Admin
</Button>
</Localized>
</Flex>
</Flex>
);
}
}
export default FinalStep;
@@ -0,0 +1,38 @@
import { Localized } from "fluent-react/compat";
import React, { Component } from "react";
import { Button, Flex, Typography } from "talk-ui/components";
interface InitialStepProps {
onGoToNextStep: () => void;
}
class InitialStep extends Component<InitialStepProps> {
public render() {
return (
<Flex direction="column" justifyContent="center" itemGutter="double">
<Localized id="install-initialStep-copy">
<Typography variant="bodyCopy">
The remainder of the Talk installation will take about ten minutes.
Once you complete the following three steps, you will have a free
installation and provision Mongo and Redis.
</Typography>
</Localized>
<Flex justifyContent="center">
<Localized id="install-initialStep-getStarted">
<Button
onClick={this.props.onGoToNextStep}
color="primary"
variant="filled"
fullWidth={false}
>
Get Started
</Button>
</Localized>
</Flex>
</Flex>
);
}
}
export default InitialStep;
@@ -0,0 +1,27 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Button, ButtonIcon } from "talk-ui/components";
export interface NextButtonProps {
submitting: boolean;
}
const NextButton: StatelessComponent<NextButtonProps> = props => {
return (
<Button
variant="filled"
color="primary"
size="large"
type="submit"
disabled={props.submitting}
>
<Localized id="install-next">
<span>Next</span>
</Localized>
<ButtonIcon>arrow_forward</ButtonIcon>
</Button>
);
};
export default NextButton;
@@ -0,0 +1,126 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Field, Form } from "react-final-form";
import { OnSubmit } from "talk-framework/lib/form";
import {
Button,
CallOut,
Flex,
FormField,
HorizontalGutter,
InputDescription,
InputLabel,
TextField,
Typography,
ValidationMessage,
} from "talk-ui/components";
import BackButton from "./BackButton";
interface FormProps {
domains: string;
}
export interface PermittedDomainsForm {
onSubmit: OnSubmit<FormProps>;
onGoToPreviousStep: () => void;
data: {
domains: string[];
};
}
const PermittedDomains: StatelessComponent<PermittedDomainsForm> = props => {
return (
<Form
onSubmit={props.onSubmit}
initialValues={{
domains: props.data.domains.join(","),
}}
>
{({ handleSubmit, submitting, submitError }) => (
<form autoComplete="off" onSubmit={handleSubmit}>
<HorizontalGutter size="double">
<Localized id="install-permittedDomains-title">
<Typography variant="heading1" align="center">
Permitted Domains
</Typography>
</Localized>
<Localized id="install-permittedDomains-description">
<Typography variant="bodyCopy" align="center">
Enter the domains you would like to permit for Talk, e.g. your
local, staging and production environments (ex. localhost:3000,
staging.domain.com, domain.com).
</Typography>
</Localized>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
<Field name="domains">
{({ input, meta }) => (
<FormField>
<Localized id="install-permittedDomains-permittedDomains">
<InputLabel>Permitted Domains</InputLabel>
</Localized>
<Localized id="install-permittedDomains-permittedDomainsDescription">
<InputDescription>
Insert domains separated by comma
</InputDescription>
</Localized>
<Localized
id="install-permittedDomains-permittedDomainsTextField"
attrs={{ placeholder: true }}
>
<TextField
name={input.name}
onChange={input.onChange}
value={input.value}
placeholder="Domains"
color={
meta.touched && (meta.error || meta.submitError)
? "error"
: "regular"
}
disabled={submitting}
fullWidth
/>
</Localized>
{meta.touched &&
(meta.error || meta.submitError) && (
<ValidationMessage fullWidth>
{meta.error || meta.submitError}
</ValidationMessage>
)}
</FormField>
)}
</Field>
<Flex direction="row-reverse" itemGutter>
<Localized id="install-finishInstall">
<Button
variant="filled"
color="primary"
size="large"
type="submit"
disabled={submitting}
>
Finish Install
</Button>
</Localized>
<BackButton
submitting={submitting}
onGoToPreviousStep={props.onGoToPreviousStep}
/>
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
);
};
export default PermittedDomains;
@@ -0,0 +1,39 @@
import { FORM_ERROR } from "final-form";
import React, { Component } from "react";
import { PropTypesOf } from "talk-ui/types";
import AddOrganization, {
AddOrganizationForm,
} from "../components/AddOrganization";
interface AddOrganizationContainerProps {
onGoToNextStep: () => void;
onGoToPreviousStep: () => void;
data: PropTypesOf<typeof AddOrganization>["data"];
onSaveData: (newData: PropTypesOf<typeof AddOrganization>["data"]) => void;
}
class AddOrganizationContainer extends Component<
AddOrganizationContainerProps
> {
private onSubmit: AddOrganizationForm["onSubmit"] = async (input, form) => {
try {
this.props.onSaveData(input);
return this.props.onGoToNextStep();
} catch (error) {
return { [FORM_ERROR]: error.message };
}
};
public render() {
return (
<AddOrganization
data={this.props.data}
onSubmit={this.onSubmit}
onGoToPreviousStep={this.props.onGoToPreviousStep}
/>
);
}
}
export default AddOrganizationContainer;
@@ -0,0 +1,39 @@
import { FORM_ERROR } from "final-form";
import React, { Component } from "react";
import { PropTypesOf } from "talk-ui/types";
import CreateYourAccount, {
CreateYourAccountForm,
} from "../components/CreateYourAccount";
interface CreateYourAccountContainerProps {
onGoToNextStep: () => void;
onGoToPreviousStep: () => void;
data: PropTypesOf<typeof CreateYourAccount>["data"];
onSaveData: (newData: PropTypesOf<typeof CreateYourAccount>["data"]) => void;
}
class CreateYourAccountContainer extends Component<
CreateYourAccountContainerProps
> {
private onSubmit: CreateYourAccountForm["onSubmit"] = async (input, form) => {
try {
this.props.onSaveData(input);
return this.props.onGoToNextStep();
} catch (error) {
return { [FORM_ERROR]: error.message };
}
};
public render() {
return (
<CreateYourAccount
data={this.props.data}
onSubmit={this.onSubmit}
onGoToPreviousStep={this.props.onGoToPreviousStep}
/>
);
}
}
export default CreateYourAccountContainer;
@@ -0,0 +1,42 @@
import { FORM_ERROR } from "final-form";
import React, { Component } from "react";
import { PropTypesOf } from "talk-ui/types";
import PermittedDomains, {
PermittedDomainsForm,
} from "../components/PermittedDomains";
interface PermittedDomainsContainerProps {
onGoToNextStep: () => void;
onGoToPreviousStep: () => void;
data: PropTypesOf<typeof PermittedDomains>["data"];
onInstall: (
newData: PropTypesOf<typeof PermittedDomains>["data"]
) => Promise<void>;
}
class PermittedDomainsContainer extends Component<
PermittedDomainsContainerProps
> {
private onSubmit: PermittedDomainsForm["onSubmit"] = async (input, form) => {
try {
const domains = input.domains.split(",");
await this.props.onInstall({ domains });
return this.props.onGoToNextStep();
} catch (error) {
return { [FORM_ERROR]: error.message };
}
};
public render() {
return (
<PermittedDomains
data={this.props.data}
onSubmit={this.onSubmit}
onGoToPreviousStep={this.props.onGoToPreviousStep}
/>
);
}
}
export default PermittedDomainsContainer;
+1 -1
View File
@@ -2,7 +2,7 @@
:global {
body {
margin: 0;
padding: 2px 8px;
padding: 0;
/* Support for all WebKit browsers. */
-webkit-font-smoothing: antialiased;
@@ -18,6 +18,7 @@ import {
} from "talk-stream/mutations";
import { Popup } from "talk-ui/components";
import { urls } from "talk-framework/helpers";
import UserBoxAuthenticated from "../components/UserBoxAuthenticated";
interface InnerProps {
@@ -57,7 +58,7 @@ export class UserBoxContainer extends Component<InnerProps> {
return (
<>
<Popup
href={`/auth.html?view=${view}`}
href={`${urls.embed.auth}?view=${view}`}
title="Talk Auth"
features="menubar=0,resizable=0,width=350,height=395,top=200,left=500"
open={open}
@@ -5,7 +5,7 @@ exports[`renders correctly 1`] = `
<Popup
features="menubar=0,resizable=0,width=350,height=395,top=200,left=500"
focus={false}
href="/auth.html?view=SIGN_IN"
href="/embed/auth?view=SIGN_IN"
onBlur={[Function]}
onClose={[Function]}
onFocus={[Function]}
@@ -0,0 +1,8 @@
.root {
composes: subTabCounter from "talk-ui/shared/typography.css";
background-color: var(--palette-grey-dark);
color: var(--palette-text-light);
border-radius: 1px;
padding: 2px 3px 3px 3px;
}
@@ -99,7 +99,7 @@
}
.variantFilled {
color: var(--palette-common-white);
color: var(--palette-text-light);
&.colorRegular {
background-color: var(--palette-grey-main);
}
@@ -243,7 +243,7 @@
&:active,
&.active {
border: 1px solid currentColor;
color: var(--palette-common-white);
color: var(--palette-text-light);
background-color: var(--palette-grey-main);
}
}
@@ -254,7 +254,7 @@
&:active,
&.active {
border: 1px solid currentColor;
color: var(--palette-common-white);
color: var(--palette-text-light);
background-color: var(--palette-primary-main);
}
}
@@ -265,7 +265,7 @@
&:active,
&.active {
border: 1px solid currentColor;
color: var(--palette-common-white);
color: var(--palette-text-light);
background-color: var(--palette-error-main);
}
}
@@ -276,7 +276,7 @@
&:active,
&.active {
border: 1px solid currentColor;
color: var(--palette-common-white);
color: var(--palette-text-light);
background-color: var(--palette-success-main);
}
}
@@ -21,7 +21,7 @@
.colorError {
background-color: var(--palette-error-light);
border-color: var(--palette-error-darkest);
color: var(--palette-common-white);
color: var(--palette-text-light);
}
.fullWidth {
@@ -0,0 +1,23 @@
.root {
display: inline-block;
width: 20px;
height: 20px;
background-color: var(--palette-common-white);
border: 2px solid var(--palette-grey-light);
box-sizing: border-box;
border-radius: 100%;
z-index: 1;
}
.active,
.completed {
background-color: var(--palette-success-main);
border-color: var(--palette-success-main);
}
.icon {
color: var(--palette-text-light);
font-weight: bold;
margin-top: -4px;
margin-left: 1px;
}
@@ -0,0 +1,23 @@
import cn from "classnames";
import React, { StatelessComponent } from "react";
import Icon from "../Icon";
import * as styles from "./Circle.css";
interface CircleProps {
active?: boolean;
completed?: boolean;
}
const Circle: StatelessComponent<CircleProps> = ({ active, completed }) => {
const rootClassName = cn(styles.root, {
[styles.active]: active,
[styles.completed]: completed,
});
return (
<span className={rootClassName}>
{completed && <Icon className={styles.icon}>done</Icon>}
</span>
);
};
export default Circle;
@@ -0,0 +1,12 @@
.root {
display: inline-block;
width: 200px;
height: 0px;
border: 1px solid var(--palette-grey-light);
box-sizing: border-box;
z-index: 1;
}
.completed {
border-color: var(--palette-success-main);
}
@@ -0,0 +1,17 @@
import cn from "classnames";
import React, { StatelessComponent } from "react";
import * as styles from "./Line.css";
interface CircleProps {
active?: boolean;
completed?: boolean;
}
const Line: StatelessComponent<CircleProps> = ({ active, completed }) => {
const rootClassName = cn(styles.root, {
[styles.completed]: completed,
});
return <span className={rootClassName} />;
};
export default Line;
@@ -0,0 +1,11 @@
.root {
position: relative;
}
.text {
position: absolute;
top: 26px;
transform: translateX(-50%);
left: 8%;
white-space: nowrap;
}
@@ -0,0 +1,39 @@
import React, { Component, ReactNode, ReactText } from "react";
import Flex from "../Flex";
import Typography from "../Typography";
import Circle from "./Circle";
import Line from "./Line";
import * as styles from "./Step.css";
interface StepProps {
children: ReactText | ReactNode;
active?: boolean;
completed?: boolean;
last?: boolean;
hidden?: boolean;
}
class Step extends Component<StepProps> {
public render() {
const { children, completed, active, last, hidden } = this.props;
if (hidden) {
return null;
}
return (
<div className={styles.root}>
<Flex
direction="row"
justifyContent="center"
alignItems="center"
itemGutter
>
<Circle completed={completed} active={active} />
{!last && <Line completed={completed} />}
</Flex>
<Typography className={styles.text}>{children}</Typography>
</div>
);
}
}
export default Step;
@@ -0,0 +1,6 @@
.root {
width: 100%;
position: relative;
display: block;
padding: calc(2 * var(--spacing-unit)) 0;
}
@@ -0,0 +1,41 @@
import cn from "classnames";
import React, { Component, ReactNode } from "react";
import Flex from "../Flex";
import * as styles from "./StepBar.css";
interface StepBarProps {
children: ReactNode;
currentStep: number;
className?: string;
}
class StepBar extends Component<StepBarProps> {
public render() {
const { children, currentStep } = this.props;
const steps = React.Children.toArray(children);
const stepsToRender = steps
.filter((child: React.ReactElement<any>) => !child.props.hidden)
.map((child: React.ReactElement<any>, index: number, arr) =>
React.cloneElement(child, {
last: arr.length - 1 === index,
active: currentStep === index,
completed: currentStep > index,
})
);
return (
<div className={cn(styles.root, this.props.className)}>
<Flex
direction="row"
itemGutter
alignItems="center"
justifyContent="center"
>
{stepsToRender}
</Flex>
</div>
);
}
}
export default StepBar;
@@ -0,0 +1,2 @@
export { default as StepBar } from "./StepBar";
export { default as Step } from "./Step";
@@ -0,0 +1,18 @@
---
name: Steps
menu: UI Kit
---
# Steps
### Examples
import { Flex, Step, StepBar } from './index'
<Playground>
<StepBar currentStep={1}>
<Step>Create Admin Account</Step>
<Step>Add Organization Details</Step>
<Step>Add Permitted Domains</Step>
</StepBar>
</Playground>
+1
View File
@@ -22,3 +22,4 @@ export { default as Icon } from "./Icon";
export { default as AriaInfo } from "./AriaInfo";
export { default as Message, MessageIcon } from "./Message";
export { Tab, TabBar, TabContent, TabPane } from "./Tabs";
export { StepBar, Step } from "./Steps";
+5
View File
@@ -48,6 +48,11 @@ const variables = {
text: {
primary: "#3B4A53",
secondary: "#787D80",
light: "#fff",
},
/* Background colors */
background: {
light: "#f5f6fa",
},
/* Common colors */
common: {
+6 -3
View File
@@ -20,10 +20,13 @@ export interface SignupBody {
email: string;
}
const SignupBodySchema = Joi.object().keys({
export const SignupBodySchema = Joi.object().keys({
username: Joi.string().trim(),
password: Joi.string().trim(),
email: Joi.string().trim(),
password: Joi.string(),
email: Joi.string()
.trim()
.lowercase()
.email(),
});
export interface SignupOptions {
@@ -0,0 +1,101 @@
import { RequestHandler } from "express";
import { Redis } from "ioredis";
import Joi from "joi";
import { Db } from "mongodb";
import { Omit } from "talk-common/types";
import { validate } from "talk-server/app/request/body";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { LocalProfile } from "talk-server/models/user";
import { install, InstallTenant } from "talk-server/services/tenant";
import TenantCache from "talk-server/services/tenant/cache";
import { upsert, UpsertUser } from "talk-server/services/users";
import { Request } from "talk-server/types/express";
export interface TenantInstallBody {
tenant: Omit<InstallTenant, "domain">;
user: Required<Pick<UpsertUser, "username" | "email" | "password">>;
}
const TenantInstallBodySchema = Joi.object().keys({
tenant: Joi.object().keys({
organizationName: Joi.string().trim(),
organizationURL: Joi.string()
.trim()
.uri(),
organizationContactEmail: Joi.string()
.trim()
.lowercase()
.email(),
domains: Joi.array().items(
Joi.string()
.trim()
.uri()
),
}),
user: Joi.object().keys({
username: Joi.string().trim(),
password: Joi.string(),
email: Joi.string()
.trim()
.lowercase()
.email(),
}),
});
export interface TenantInstallHandlerOptions {
cache: TenantCache;
redis: Redis;
mongo: Db;
}
export const tenantInstallHandler = ({
mongo,
redis,
cache,
}: TenantInstallHandlerOptions): RequestHandler => async (
req: Request,
res,
next
) => {
try {
// Validate that the payload passed in was correct, it will throw if the
// payload is invalid.
const {
tenant: tenantInput,
user: userInput,
}: TenantInstallBody = validate(TenantInstallBodySchema, req.body);
// Install will throw if it can not create a Tenant, or it has already been
// installed.
const tenant = await install(mongo, redis, cache, {
...tenantInput,
// Infer the Tenant domain via the hostname parameter.
domain: req.hostname,
});
// Pull the user details out of the input for the user.
const { email, username, password } = userInput;
// Configure with profile.
const profile: LocalProfile = {
id: email,
type: "local",
};
// Create the first admin user.
await upsert(mongo, tenant, {
email,
username,
password,
profiles: [profile],
role: GQLUSER_ROLE.ADMIN,
});
// Send back the Tenant.
return res.sendStatus(204);
} catch (err) {
// TODO: (wyattjoh) maybe wrap the error?
return next(err);
}
};
@@ -0,0 +1,27 @@
import { RequestHandler } from "express";
import { isInstalled } from "talk-server/services/tenant";
import TenantCache from "talk-server/services/tenant/cache";
export interface InstalledMiddlewareOptions {
tenantCache: TenantCache;
redirectURL?: string;
redirectIfInstalled?: boolean;
}
export const installedMiddleware = ({
tenantCache,
redirectIfInstalled = false,
redirectURL = "/install",
}: InstalledMiddlewareOptions): RequestHandler => async (req, res, next) => {
const installed = await isInstalled(tenantCache);
// If Talk is installed, and redirectIfInstall is true, then it will redirect.
// If Talk is not installed, and redirectIfInstall is false, then it will also
// redirect.
if (installed === redirectIfInstalled) {
return res.redirect(redirectURL);
}
next();
};
+4 -4
View File
@@ -1,4 +1,4 @@
import { NextFunction, Response } from "express";
import { RequestHandler } from "express";
import TenantCache from "talk-server/services/tenant/cache";
import { Request } from "talk-server/types/express";
@@ -7,10 +7,10 @@ export interface MiddlewareOptions {
cache: TenantCache;
}
export default (options: MiddlewareOptions) => async (
export default (options: MiddlewareOptions): RequestHandler => async (
req: Request,
res: Response,
next: NextFunction
res,
next
) => {
try {
const { cache } = options;
+12
View File
@@ -1,6 +1,7 @@
import express from "express";
import { AppOptions } from "talk-server/app";
import { tenantInstallHandler } from "talk-server/app/handlers/install/tenant";
import tenantMiddleware from "talk-server/app/middleware/tenant";
import { RouterOptions } from "talk-server/app/router/types";
import tenantGraphMiddleware from "talk-server/graph/tenant/middleware";
@@ -13,6 +14,17 @@ export async function createTenantRouter(
) {
const router = express.Router();
// Tenant setup handler.
router.use(
"/install",
express.json(),
tenantInstallHandler({
cache: app.tenantCache,
redis: app.redis,
mongo: app.mongo,
})
);
// Tenant identification middleware.
router.use(tenantMiddleware({ cache: app.tenantCache }));
+31 -3
View File
@@ -2,6 +2,7 @@ import express, { Router } from "express";
import { AppOptions } from "talk-server/app";
import { nocacheMiddleware } from "talk-server/app/middleware/cacheHeaders";
import { installedMiddleware } from "talk-server/app/middleware/installed";
import playground from "talk-server/app/middleware/playground";
import { RouterOptions } from "talk-server/app/router/types";
import logger from "talk-server/logger";
@@ -20,9 +21,36 @@ export async function createRouter(app: AppOptions, options: RouterOptions) {
attachGraphiQL(router, app);
}
// Add the client targets.
router.get("/embed/stream", createClientTargetRouter({ view: "stream" }));
router.get("/admin", createClientTargetRouter({ view: "admin" }));
// Add the embed targets.
router.use("/embed/stream", createClientTargetRouter({ view: "stream" }));
router.use("/embed/auth", createClientTargetRouter({ view: "auth" }));
// Add the standalone targets.
router.use(
"/admin",
installedMiddleware({
tenantCache: app.tenantCache,
}),
createClientTargetRouter({ view: "admin" })
);
router.use(
"/install",
installedMiddleware({
tenantCache: app.tenantCache,
redirectIfInstalled: true,
redirectURL: "/admin",
}),
createClientTargetRouter({ view: "install", cacheDuration: "" })
);
// Handle the root path.
router.get(
"/",
installedMiddleware({ tenantCache: app.tenantCache }),
(req, res, next) => {
res.redirect("/admin");
}
);
return router;
}
+17 -3
View File
@@ -1,11 +1,25 @@
import bunyan, { LogLevelString } from "bunyan";
import bunyan, { LogLevelString, stdSerializers as serializers } from "bunyan";
import PrettyStream from "bunyan-prettystream";
import config from "talk-common/config";
function getStreams() {
// If we aren't in production mode, use the pretty stream printer.
if (config.get("env") !== "production") {
const pretty = new PrettyStream();
pretty.pipe(process.stdout);
return [{ type: "raw", stream: pretty }];
}
// In production, emit JSON.
return [{ stream: process.stdout }];
}
const logger = bunyan.createLogger({
name: "talk",
serializers: bunyan.stdSerializers,
// TODO: (wyattjoh) move this into some managed instance?
serializers,
streams: getStreams(),
level: config.get("logging_level") as LogLevelString,
});
+6
View File
@@ -178,6 +178,12 @@ export async function retrieveAllTenants(db: Db) {
.toArray();
}
export async function countTenants(mongo: Db) {
return collection(mongo)
.find({})
.count();
}
export type UpdateTenantInput = Omit<DeepPartial<Tenant>, "id" | "domain">;
export async function updateTenant(
+8 -4
View File
@@ -1,6 +1,13 @@
import { Db, MongoClient } from "mongodb";
import { Config } from "talk-common/config";
export async function createMongoClient(config: Config): Promise<MongoClient> {
return MongoClient.connect(
config.get("mongodb"),
{ useNewUrlParser: true }
);
}
/**
* create will connect to the MongoDB instance identified in the configuration.
*
@@ -8,10 +15,7 @@ import { Config } from "talk-common/config";
*/
export async function createMongoDB(config: Config): Promise<Db> {
// Connect and create a client for MongoDB.
const client = await MongoClient.connect(
config.get("mongodb"),
{ useNewUrlParser: true }
);
const client = await createMongoClient(config);
// Return the database handle, which defaults to the database name provided
// in the config connection string.
+42
View File
@@ -7,6 +7,7 @@ import { EventEmitter } from "events";
import { Config } from "talk-common/config";
import logger from "talk-server/logger";
import {
countTenants,
retrieveAllTenants,
retrieveManyTenants,
retrieveManyTenantsByDomain,
@@ -38,6 +39,17 @@ export default class TenantCache {
*/
private tenantsByDomain: DataLoader<string, Readonly<Tenant> | null>;
/**
* tenantCountCache stores all the id's of all the Tenant's that have crossed
* it.
*/
private tenantCountCache = new Set<string>();
/**
* primed is true when the cache has already been fully primed.
*/
private primed: boolean = false;
/**
* Create a new client application ID. This prevents duplicated messages
* generated by this application from being handled as external messages
@@ -73,6 +85,11 @@ export default class TenantCache {
{ tenants: tenants.filter(t => t !== null).length },
"loaded tenants"
);
tenants
.filter(t => t !== null)
.forEach((t: Readonly<Tenant>) => this.tenantCountCache.add(t.id));
return tenants;
},
{
@@ -88,6 +105,11 @@ export default class TenantCache {
{ tenants: tenants.filter(t => t !== null).length },
"loaded tenants"
);
tenants
.filter(t => t !== null)
.forEach((t: Readonly<Tenant>) => this.tenantCountCache.add(t.id));
return tenants;
},
{
@@ -106,6 +128,21 @@ export default class TenantCache {
}
}
/**
* count will return the number of Tenant's.
*/
public async count(): Promise<number> {
if (!this.cachingEnabled) {
return countTenants(this.mongo);
}
if (!this.primed) {
await this.primeAll();
}
return this.tenantCountCache.size;
}
/**
* primeAll will load all the tenants into the cache on startup.
*/
@@ -121,14 +158,17 @@ export default class TenantCache {
// Clear out all the items in the cache.
this.tenantsByID.clearAll();
this.tenantsByDomain.clearAll();
this.tenantCountCache.clear();
// Prime the cache with each of these tenants.
tenants.forEach(tenant => {
this.tenantsByID.prime(tenant.id, tenant);
this.tenantsByDomain.prime(tenant.domain, tenant);
this.tenantCountCache.add(tenant.id);
});
logger.debug({ tenants: tenants.length }, "primed all tenants");
this.primed = true;
}
/**
@@ -160,6 +200,7 @@ export default class TenantCache {
// Update the tenant cache.
this.tenantsByID.clear(tenant.id).prime(tenant.id, tenant);
this.tenantsByDomain.clear(tenant.domain).prime(tenant.domain, tenant);
this.tenantCountCache.add(tenant.id);
// Publish the event for the connected listeners.
this.emitter.emit(EMITTER_EVENT_NAME, tenant);
@@ -207,6 +248,7 @@ export default class TenantCache {
// Update the tenant in the local cache.
this.tenantsByID.clear(tenant.id).prime(tenant.id, tenant);
this.tenantsByDomain.clear(tenant.domain).prime(tenant.domain, tenant);
this.tenantCountCache.add(tenant.id);
// Notify the other nodes about the tenant change.
const message: TenantUpdateMessage = {
+52 -5
View File
@@ -3,29 +3,76 @@ import { Db } from "mongodb";
import { GQLSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types";
import {
createTenant,
CreateTenantInput,
Tenant,
updateTenant,
// UpdateTenantInput,
} from "talk-server/models/tenant";
import logger from "talk-server/logger";
import TenantCache from "./cache";
export type UpdateTenant = GQLSettingsInput;
export async function update(
db: Db,
conn: Redis,
mongo: Db,
redis: Redis,
cache: TenantCache,
tenant: Tenant,
input: UpdateTenant
): Promise<Tenant | null> {
const updatedTenant = await updateTenant(db, tenant.id, input);
const updatedTenant = await updateTenant(mongo, tenant.id, input);
if (!updatedTenant) {
return null;
}
// Update the tenant cache.
await cache.update(conn, updatedTenant);
await cache.update(redis, updatedTenant);
return updatedTenant;
}
export type InstallTenant = CreateTenantInput;
export async function install(
mongo: Db,
redis: Redis,
cache: TenantCache,
input: InstallTenant
) {
if (await isInstalled(cache)) {
// TODO: (wyattjoh) return better error
throw new Error(
"tenant already setup, setup multi-tenant mode if you want to install more than one tenant"
);
}
// Create the Tenant.
const tenant = await createTenant(mongo, input);
// Update the tenant cache.
await cache.update(redis, tenant);
logger.info(
{ tenant_id: tenant.id, tenant_domain: tenant.domain },
"a tenant has been installed"
);
return tenant;
}
/**
* canInstall will return a promise that determines if a given install can
* proceed.
*/
export async function canInstall(cache: TenantCache) {
return (await cache.count()) === 0;
}
/**
* isInstalled will return a promise that if true, indicates that a Tenant has
* been installed.
*/
export async function isInstalled(cache: TenantCache) {
return (await cache.count()) > 0;
}
+1
View File
@@ -12,6 +12,7 @@ framework-validation-invalidCharacters = Invalid characters. Try again.
framework-validation-invalidEmail = Please enter a valid email address.
framework-validation-passwordTooShort = Password must contain at least {$minLength} characters.
framework-validation-passwordsDoNotMatch = Passwords do not match. Try again.
framework-validation-invalidURL = Invalid URL
framework-timeago-just-now = Just now
+52
View File
@@ -0,0 +1,52 @@
## Install
install-back = Back
install-next = Next
install-finishInstall = Finish Install
install-title = Talk Installation Wizard
install-initialStep-copy = The remainder of the Talk installation will take about ten minutes. Once you complete the following three steps, you will have a free installation and provision Mongo and Redis.
install-initialStep-getStarted = Get Started
install-addOrganization-stepTitle = Add Organization Details
install-addOrganization-title = Add Organization
install-addOrganization-description = Please tell us the name of your organization. This will appear in emails when inviting new team members.
install-addOrganization-orgName = Organization Name
install-addOrganization-orgNameTextField =
.placeholder = Organization Name
install-addOrganization-orgEmail = Organization Contact Email
install-addOrganization-orgEmailTextField =
.placeholder = Organization Contact Email
install-addOrganization-orgURL = Organization URL
install-addOrganization-orgURLTextField =
.placeholder = Organization URL
install-addOrganization-orgURLDescription = Be sure to include <strong>http://</strong> or <strong>https://</strong> in your URL
install-createYourAccount-stepTitle = Create Admin Account
install-createYourAccount-title = Create an Administrator Account
install-createYourAccount-email = Email
install-createYourAccount-emailTextField =
.placeholder = Email
install-createYourAccount-username = Username
install-createYourAccount-usernameTextField =
.placeholder = Username
install-createYourAccount-usernameDescription = A unique identifier displayed on your comments. You may use “_” and “.”
install-createYourAccount-password = Password
install-createYourAccount-passwordTextField =
.placeholder = Password
install-createYourAccount-passwordDescription = Must be at least 8 characters
install-createYourAccount-confirmPassword = Confirm Password
install-createYourAccount-confirmPasswordTextField =
.placeholder = Confirm Password
install-permittedDomains-stepTitle = Add Permitted Domains
install-permittedDomains-title = Permitted Domains
install-permittedDomains-description = Enter the domains you would like to permit for Talk, e.g. your local, staging and production environments (ex. localhost:3000, staging.domain.com, domain.com).
install-permittedDomains-permittedDomains = Permitted Domains
install-permittedDomains-permittedDomainsTextField =
.placeholder = Domains
install-permittedDomains-permittedDomainsDescription = Insert domains separated by comma
install-finalStep-description = Thanks for installing Talk! We sent an email to verify your email address. While you finish setting up the account, you can start engaging with your readers now.
install-finalStep-goToTheDocs = Go to the Docs
install-finalStep-goToAdmin = Go to Admin