mirror of
https://github.com/wassname/talk.git
synced 2026-07-17 11:33:39 +08:00
Merge branch 'next' into next-auth
This commit is contained in:
@@ -288,7 +288,7 @@ export default function createWebpackConfig({
|
||||
// smaller than specified limit in bytes as data URLs to avoid requests.
|
||||
// A missing `test` is equivalent to a match.
|
||||
{
|
||||
test: [/\.gif$/, /\.jpe?g$/, /\.png$/],
|
||||
test: [/\.gif$/, /\.jpe?g$/, /\.png$/, /\.svg$/],
|
||||
loader: require.resolve("url-loader"),
|
||||
options: {
|
||||
limit: 10000,
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
.root {
|
||||
width: 1080px;
|
||||
:global {
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-unit);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const props: PropTypesOf<typeof App> = {
|
||||
children: "child",
|
||||
};
|
||||
const wrapper = shallow(<App {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
@@ -1,13 +1,16 @@
|
||||
import React, { StatelessComponent } from "react";
|
||||
import { HorizontalGutter } from "talk-ui/components";
|
||||
|
||||
import styles from "./App.css";
|
||||
import AppBar from "./AppBar";
|
||||
import Navigation from "./Navigation";
|
||||
|
||||
const App: StatelessComponent = ({ children }) => (
|
||||
<HorizontalGutter className={styles.root}>
|
||||
<Navigation />
|
||||
{children}
|
||||
</HorizontalGutter>
|
||||
<div>
|
||||
<AppBar>
|
||||
<Navigation />
|
||||
</AppBar>
|
||||
<div className={styles.container}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.root {
|
||||
background-color: #f5f5f5;
|
||||
border-bottom: 1px solid var(--palette-grey-lighter);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 calc(2 * var(--spacing-unit));
|
||||
height: calc(6 * var(--spacing-unit));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin-right: calc(2.5 * var(--spacing-unit));
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
|
||||
import AppBar from "./AppBar";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const props: PropTypesOf<typeof AppBar> = {
|
||||
children: "child",
|
||||
};
|
||||
const wrapper = shallow(<AppBar {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import { Flex } from "talk-ui/components";
|
||||
|
||||
import styles from "./AppBar.css";
|
||||
import Logo from "./Logo";
|
||||
|
||||
const AppBar: StatelessComponent = ({ children }) => (
|
||||
<div className={styles.root}>
|
||||
<Flex className={styles.container}>
|
||||
<Logo className={styles.logo} />
|
||||
{children}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AppBar;
|
||||
@@ -0,0 +1,12 @@
|
||||
.base {
|
||||
}
|
||||
|
||||
.md {
|
||||
height: 35px;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.lg {
|
||||
height: 58px;
|
||||
width: 59px;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
|
||||
import BrandIcon from "./BrandIcon";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const props: PropTypesOf<typeof BrandIcon> = {
|
||||
className: "custom",
|
||||
size: "lg",
|
||||
};
|
||||
const wrapper = shallow(<BrandIcon {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import cn from "classnames";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import styles from "./BrandIcon.css";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
size?: "md" | "lg";
|
||||
}
|
||||
|
||||
const BrandIcon: StatelessComponent<Props> = props => (
|
||||
<svg
|
||||
className={cn(styles.base, props.className, {
|
||||
[styles.md]: props.size === "md",
|
||||
[styles.lg]: props.size === "lg",
|
||||
})}
|
||||
id="Layer_1"
|
||||
data-name="Layer 1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 541.77 557.72"
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="clip-path">
|
||||
<rect width="554" height="570" fill="none" />
|
||||
</clipPath>
|
||||
<clipPath id="clip-path-2" transform="translate(-8.12 -8.14)">
|
||||
<path
|
||||
d="M61.63,350.45c-.67,10.22,1.34,21.13,6,32.21a95.36,95.36,0,0,0,27.22,36.41c26,21.16,52.14,17.65,77.43,14.24,21.22-2.85,43.17-5.8,69,5.43,33.54,14.57,59.71,45.39,66.65,78.46a99.16,99.16,0,0,1,.11,38.66H219.65a16.89,16.89,0,0,0-.71-2.44c-16.54-44.09-38.46-69.52-67.09-77.78-9.37-2.66-18-3-25.68-3.27-9.91-.37-17.75-.66-26-5.6s-14.93-13.28-19.59-24.42a17,17,0,0,0-22.12-9.07,16.69,16.69,0,0,0-9.17,21.88c7.45,17.8,18.6,31.31,33.25,40.21,15.85,9.48,30.54,10,42.35,10.48,6.55.24,12.2.45,17.55,2,15.64,4.51,29.43,20.65,41.07,48H63.07a45,45,0,0,1-44.95-45V322.78a144.51,144.51,0,0,0,43.51,27.67Zm13-31.55a109.22,109.22,0,0,1-56.48-52.22v-66a88.41,88.41,0,0,0,35.59,10.37c18.52,1.13,34.77-4.64,47-16.71,11.63-11.51,14.6-24.24,18.05-39,2.88-12.32,6.46-27.66,16.66-48.7,10.44-21.72,17.13-30,24.69-30.51,11-.79,29.3,13.46,32.09,34.75,2,14.93-3.58,25.14-10.07,37C175,161,166,177.28,172.4,198.15c6.81,22.16,25.44,31.45,40.4,38.91,15.61,7.78,25.42,13.21,30.37,26.2,4.26,11.27,4.87,27.9-.71,33.32-3.74,3.66-16.43.64-29.86-2.55-23-5.47-54.5-12.95-91.49-1.57-9.77,3-32.07,9.72-46.51,26.44ZM357.73,18.14c-15,17.37-28.76,40.23-34.85,69-4.25,19.33-13.09,59.57,13.51,88.26A71.68,71.68,0,0,0,388.11,198a61.12,61.12,0,0,0,22.65-4.23c31.63-12.61,46.84-43.4,49.25-56.36,3.24-17.51,5-35.76,6.33-51.32a11.43,11.43,0,0,1,5.38-8.61l.5-.31a12.23,12.23,0,0,1,13.33,1.63c19.82,16.56,36.73,31.82,51.69,46.63a17.07,17.07,0,0,0,2.64,2.14v93.21a16.92,16.92,0,0,0-3.87,4.69c-9.23,16.43-23.71,36.42-40.11,38.31-6.55.7-10.3-1.49-20.21-7.9S452.37,240.8,431,236c-23.2-5.22-38.56-2.06-50.9.48-10.61,2.18-17.62,3.62-28.9,0-27.43-9-42.31-36.22-51.21-52.47l-.24-.45c-19.81-36.54-18.23-71.85-16.84-103l0-.58a287.16,287.16,0,0,1,9.81-61.77Zm49.14,0h88.06a45,45,0,0,1,44.95,45V80.79c-10.08-9.22-21-18.69-32.79-28.58a45.72,45.72,0,0,0-50.93-5.38,19.1,19.1,0,0,0-2.55,1.52,46.1,46.1,0,0,0-21,34.46,1.49,1.49,0,0,0,0,.21c-1.24,14.7-2.91,31.88-5.86,47.9-.88,3.66-9.12,23.11-28.33,30.76-12.81,5.1-29.11-1.09-37.41-10-14.22-15.33-8.26-42.48-5-57.19C363.43,59.29,386.3,35,404.17,20.82a18,18,0,0,0,2.7-2.68Zm-149.42,0a319.88,319.88,0,0,0-8.67,60.21l0,.62c-1.43,32-3.38,75.91,21,120.82l.28.52c9.81,17.93,30.25,55.25,70.55,68.52,19.95,6.48,34,3.59,46.44,1,11-2.26,20.48-4.21,36.49-.61,15.43,3.48,24.69,9.46,33.63,15.25C467,290.87,478,298,493.8,298a54,54,0,0,0,5.86-.32c14.5-1.67,27.83-8.41,40.22-20.42v45.53a179.81,179.81,0,0,1-47.68.48c-17-2.18-28.9-6.21-40.39-10.1-16.13-5.46-31.37-10.63-54.46-8.79-12.34.86-49.5,3.52-68.67,32.46-21.27,32-4.79,71.47-1.27,79.05,0,.06,0,.11.07.17,11.15,23.37,28,33.16,44.25,42.63,9.57,5.57,19.46,11.33,29.45,20.4C421,497.23,435.05,523,443,555.86H342.61a132,132,0,0,0-1.29-45.63c-9.16-43.62-43.1-84-86.46-102.82-34.51-15-63.68-11.11-87.12-8-24.18,3.25-37.44,4.42-51.35-6.91-15-12.14-24-32.83-19.67-45.1,4.74-13.34,25.44-19.62,34.35-22.32,28.19-8.67,52.33-2.93,73.64,2.14,21.83,5.18,44.4,10.55,61.57-6.21,17.7-17.22,17-48.29,8.81-69.92-10-26.15-30.53-36.41-47.06-44.65-13.09-6.53-20.6-10.6-23-18.37-2-6.43.36-11.62,7-23.75,7.41-13.48,17.55-31.93,14-58-4.77-36.43-36.65-66.5-68.28-64.3-30.19,2.1-44.53,32-53.08,49.73-11.83,24.42-16.07,42.54-19.16,55.78-2.9,12.39-4.37,18.06-8.79,22.43-5.21,5.13-12.26,7.47-21,6.93-13-.8-27.57-8-37.68-18.45V63.15a45,45,0,0,1,45-45Zm282.43,449v43.76a45,45,0,0,1-44.95,45H477.46a16.09,16.09,0,0,0-.36-2.56c-4.89-22.41-12.24-42.37-22-59.73a120.79,120.79,0,0,1,39-21,119.51,119.51,0,0,1,45.77-5.45Zm0-34.82a152.6,152.6,0,0,0-56,7.12,154.85,154.85,0,0,0-48.66,25.94,153.26,153.26,0,0,0-11.29-11.5c-12.68-11.52-24.67-18.5-35.24-24.65-14.45-8.41-24-14-30.62-27.78-.87-1.9-12.66-28.42-1.23-45.62,10-15.1,33.94-16.77,42.95-17.4l.17,0c16.24-1.3,26.66,2.23,41.07,7.11,12.45,4.22,26.57,9,46.95,11.61a212.66,212.66,0,0,0,51.92.08v75.11Z"
|
||||
fill="none"
|
||||
clipPath="url(#clip-path)"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<title>logo mark2018</title>
|
||||
<g clipPath="url(#clip-path-2)">
|
||||
<rect width="541.77" height="557.72" fill="#f7705f" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
BrandIcon.defaultProps = {
|
||||
size: "md",
|
||||
};
|
||||
|
||||
export default BrandIcon;
|
||||
@@ -0,0 +1,15 @@
|
||||
.root {
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-family: var(--font-family-sans-serif);
|
||||
color: var(--palette-brand);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.md {
|
||||
font-size: calc(36rem / var(--rem-base));
|
||||
line-height: calc(38em / 36);
|
||||
}
|
||||
.lg {
|
||||
font-size: calc(48rem / var(--rem-base));
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
|
||||
import BrandName from "./BrandName";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const props: PropTypesOf<typeof BrandName> = {
|
||||
align: "center",
|
||||
className: "custom",
|
||||
size: "lg",
|
||||
};
|
||||
const wrapper = shallow(<BrandName {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import cn from "classnames";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
import { Typography } from "talk-ui/components";
|
||||
|
||||
import styles from "./BrandName.css";
|
||||
|
||||
interface Props {
|
||||
align?: PropTypesOf<typeof Typography>["align"];
|
||||
className?: string;
|
||||
size?: "md" | "lg";
|
||||
}
|
||||
|
||||
const BrandName: StatelessComponent<Props> = props => (
|
||||
<Localized id="general-brandName">
|
||||
<Typography
|
||||
variant="heading1"
|
||||
align={props.align}
|
||||
className={cn(props.className, styles.root, {
|
||||
[styles.md]: props.size === "md",
|
||||
[styles.lg]: props.size === "lg",
|
||||
})}
|
||||
>
|
||||
Talk
|
||||
</Typography>
|
||||
</Localized>
|
||||
);
|
||||
|
||||
BrandName.defaultProps = {
|
||||
size: "md",
|
||||
align: "center",
|
||||
};
|
||||
|
||||
export default BrandName;
|
||||
@@ -0,0 +1,6 @@
|
||||
.root {
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: var(--spacing-unit);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
|
||||
import Logo from "./Logo";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const props: PropTypesOf<typeof Logo> = {
|
||||
className: "custom",
|
||||
};
|
||||
const wrapper = shallow(<Logo {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import cn from "classnames";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import { Flex } from "talk-ui/components";
|
||||
|
||||
import BrandIcon from "./BrandIcon";
|
||||
import BrandName from "./BrandName";
|
||||
import styles from "./Logo.css";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Logo: StatelessComponent<Props> = props => (
|
||||
<Flex alignItems="center" className={cn(styles.root, props.className)}>
|
||||
<BrandIcon className={styles.icon} />
|
||||
<BrandName />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
export default Logo;
|
||||
@@ -1,7 +1,28 @@
|
||||
.root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--palette-text-secondary);
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: calc(18rem / var(--rem-base));
|
||||
line-height: calc(20em / 18);
|
||||
letter-spacing: calc(-0.1em / 18);
|
||||
height: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 calc(1.5 * var(--spacing-unit));
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
border-bottom: 3px solid var(--palette-primary-main);
|
||||
background-color: var(--palette-brand);
|
||||
text-decoration: none;
|
||||
color: var(--palette-text-light);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
|
||||
import Navigation from "./Navigation";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const wrapper = shallow(<Navigation />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
@@ -1,39 +1,31 @@
|
||||
import { Link } from "found";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { StatelessComponent } from "react";
|
||||
import { Button, Flex, Typography } from "talk-ui/components";
|
||||
|
||||
import { Flex } from "talk-ui/components";
|
||||
|
||||
import SignOutButtonContainer from "../containers/SignOutButtonContainer";
|
||||
import styles from "./Navigation.css";
|
||||
import NavigationLink from "./NavigationLink";
|
||||
|
||||
const Navigation: StatelessComponent = () => (
|
||||
<Flex itemGutter="double">
|
||||
<Typography variant="heading1">Talk</Typography>
|
||||
<Link
|
||||
to="/admin/moderate"
|
||||
className={styles.link}
|
||||
activeClassName={styles.active}
|
||||
>
|
||||
<Button>Moderate</Button>
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/community"
|
||||
className={styles.link}
|
||||
activeClassName={styles.active}
|
||||
>
|
||||
<Button>Community</Button>
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/stories"
|
||||
className={styles.link}
|
||||
activeClassName={styles.active}
|
||||
>
|
||||
<Button>Stories</Button>
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/configure"
|
||||
className={styles.link}
|
||||
activeClassName={styles.active}
|
||||
>
|
||||
<Button>Configure</Button>
|
||||
</Link>
|
||||
<Flex className={styles.root} justifyContent="space-between">
|
||||
<Flex alignItems="center">
|
||||
<Localized id="navigation-moderate">
|
||||
<NavigationLink to="/admin/moderate">Moderate</NavigationLink>
|
||||
</Localized>
|
||||
<Localized id="navigation-community">
|
||||
<NavigationLink to="/admin/community">Community</NavigationLink>
|
||||
</Localized>
|
||||
<Localized id="navigation-stories">
|
||||
<NavigationLink to="/admin/stories">Stories</NavigationLink>
|
||||
</Localized>
|
||||
<Localized id="navigation-configure">
|
||||
<NavigationLink to="/admin/configure">Configure</NavigationLink>
|
||||
</Localized>
|
||||
</Flex>
|
||||
<Flex alignItems="center">
|
||||
<SignOutButtonContainer id="navigation-signOutButton" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
.root {
|
||||
color: var(--palette-text-secondary);
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: calc(18rem / var(--rem-base));
|
||||
line-height: calc(20em / 18);
|
||||
letter-spacing: calc(-0.1em / 18);
|
||||
height: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 calc(1.5 * var(--spacing-unit));
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: var(--palette-brand);
|
||||
text-decoration: none;
|
||||
color: var(--palette-text-light);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
|
||||
import NavigationLink from "./NavigationLink";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const props: PropTypesOf<typeof NavigationLink> = {
|
||||
to: "/moderate",
|
||||
children: "link",
|
||||
};
|
||||
const wrapper = shallow(<NavigationLink {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Link, LocationDescriptor } from "found";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import styles from "./NavigationLink.css";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
to: string | LocationDescriptor;
|
||||
}
|
||||
|
||||
const NavigationLink: StatelessComponent<Props> = props => (
|
||||
<Link to={props.to} className={styles.root} activeClassName={styles.active}>
|
||||
{props.children}
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default NavigationLink;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { shallow } from "enzyme";
|
||||
import { noop } from "lodash";
|
||||
import React from "react";
|
||||
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
|
||||
import SignOutButton from "./SignOutButton";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const props: PropTypesOf<typeof SignOutButton> = {
|
||||
id: "id",
|
||||
onClick: noop,
|
||||
};
|
||||
const wrapper = shallow(<SignOutButton {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import { Button } from "talk-ui/components";
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
onClick: React.EventHandler<React.MouseEvent>;
|
||||
}
|
||||
|
||||
const SignOutButton: StatelessComponent<Props> = props => (
|
||||
<Localized id="navigation-signOutButton">
|
||||
<Button id={props.id} onClick={props.onClick}>
|
||||
Sign Out
|
||||
</Button>
|
||||
</Localized>
|
||||
);
|
||||
|
||||
export default SignOutButton;
|
||||
@@ -0,0 +1,14 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<div>
|
||||
<AppBar>
|
||||
<Navigation />
|
||||
</AppBar>
|
||||
<div
|
||||
className="App-container"
|
||||
>
|
||||
child
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,16 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<div
|
||||
className="AppBar-root"
|
||||
>
|
||||
<withPropsOnChange(Flex)
|
||||
className="AppBar-container"
|
||||
>
|
||||
<Logo
|
||||
className="AppBar-logo"
|
||||
/>
|
||||
child
|
||||
</withPropsOnChange(Flex)>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,46 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<svg
|
||||
className="BrandIcon-base custom BrandIcon-lg"
|
||||
data-name="Layer 1"
|
||||
id="Layer_1"
|
||||
viewBox="0 0 541.77 557.72"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<clipPath
|
||||
id="clip-path"
|
||||
>
|
||||
<rect
|
||||
fill="none"
|
||||
height="570"
|
||||
width="554"
|
||||
/>
|
||||
</clipPath>
|
||||
<clipPath
|
||||
id="clip-path-2"
|
||||
transform="translate(-8.12 -8.14)"
|
||||
>
|
||||
<path
|
||||
clipPath="url(#clip-path)"
|
||||
clipRule="evenodd"
|
||||
d="M61.63,350.45c-.67,10.22,1.34,21.13,6,32.21a95.36,95.36,0,0,0,27.22,36.41c26,21.16,52.14,17.65,77.43,14.24,21.22-2.85,43.17-5.8,69,5.43,33.54,14.57,59.71,45.39,66.65,78.46a99.16,99.16,0,0,1,.11,38.66H219.65a16.89,16.89,0,0,0-.71-2.44c-16.54-44.09-38.46-69.52-67.09-77.78-9.37-2.66-18-3-25.68-3.27-9.91-.37-17.75-.66-26-5.6s-14.93-13.28-19.59-24.42a17,17,0,0,0-22.12-9.07,16.69,16.69,0,0,0-9.17,21.88c7.45,17.8,18.6,31.31,33.25,40.21,15.85,9.48,30.54,10,42.35,10.48,6.55.24,12.2.45,17.55,2,15.64,4.51,29.43,20.65,41.07,48H63.07a45,45,0,0,1-44.95-45V322.78a144.51,144.51,0,0,0,43.51,27.67Zm13-31.55a109.22,109.22,0,0,1-56.48-52.22v-66a88.41,88.41,0,0,0,35.59,10.37c18.52,1.13,34.77-4.64,47-16.71,11.63-11.51,14.6-24.24,18.05-39,2.88-12.32,6.46-27.66,16.66-48.7,10.44-21.72,17.13-30,24.69-30.51,11-.79,29.3,13.46,32.09,34.75,2,14.93-3.58,25.14-10.07,37C175,161,166,177.28,172.4,198.15c6.81,22.16,25.44,31.45,40.4,38.91,15.61,7.78,25.42,13.21,30.37,26.2,4.26,11.27,4.87,27.9-.71,33.32-3.74,3.66-16.43.64-29.86-2.55-23-5.47-54.5-12.95-91.49-1.57-9.77,3-32.07,9.72-46.51,26.44ZM357.73,18.14c-15,17.37-28.76,40.23-34.85,69-4.25,19.33-13.09,59.57,13.51,88.26A71.68,71.68,0,0,0,388.11,198a61.12,61.12,0,0,0,22.65-4.23c31.63-12.61,46.84-43.4,49.25-56.36,3.24-17.51,5-35.76,6.33-51.32a11.43,11.43,0,0,1,5.38-8.61l.5-.31a12.23,12.23,0,0,1,13.33,1.63c19.82,16.56,36.73,31.82,51.69,46.63a17.07,17.07,0,0,0,2.64,2.14v93.21a16.92,16.92,0,0,0-3.87,4.69c-9.23,16.43-23.71,36.42-40.11,38.31-6.55.7-10.3-1.49-20.21-7.9S452.37,240.8,431,236c-23.2-5.22-38.56-2.06-50.9.48-10.61,2.18-17.62,3.62-28.9,0-27.43-9-42.31-36.22-51.21-52.47l-.24-.45c-19.81-36.54-18.23-71.85-16.84-103l0-.58a287.16,287.16,0,0,1,9.81-61.77Zm49.14,0h88.06a45,45,0,0,1,44.95,45V80.79c-10.08-9.22-21-18.69-32.79-28.58a45.72,45.72,0,0,0-50.93-5.38,19.1,19.1,0,0,0-2.55,1.52,46.1,46.1,0,0,0-21,34.46,1.49,1.49,0,0,0,0,.21c-1.24,14.7-2.91,31.88-5.86,47.9-.88,3.66-9.12,23.11-28.33,30.76-12.81,5.1-29.11-1.09-37.41-10-14.22-15.33-8.26-42.48-5-57.19C363.43,59.29,386.3,35,404.17,20.82a18,18,0,0,0,2.7-2.68Zm-149.42,0a319.88,319.88,0,0,0-8.67,60.21l0,.62c-1.43,32-3.38,75.91,21,120.82l.28.52c9.81,17.93,30.25,55.25,70.55,68.52,19.95,6.48,34,3.59,46.44,1,11-2.26,20.48-4.21,36.49-.61,15.43,3.48,24.69,9.46,33.63,15.25C467,290.87,478,298,493.8,298a54,54,0,0,0,5.86-.32c14.5-1.67,27.83-8.41,40.22-20.42v45.53a179.81,179.81,0,0,1-47.68.48c-17-2.18-28.9-6.21-40.39-10.1-16.13-5.46-31.37-10.63-54.46-8.79-12.34.86-49.5,3.52-68.67,32.46-21.27,32-4.79,71.47-1.27,79.05,0,.06,0,.11.07.17,11.15,23.37,28,33.16,44.25,42.63,9.57,5.57,19.46,11.33,29.45,20.4C421,497.23,435.05,523,443,555.86H342.61a132,132,0,0,0-1.29-45.63c-9.16-43.62-43.1-84-86.46-102.82-34.51-15-63.68-11.11-87.12-8-24.18,3.25-37.44,4.42-51.35-6.91-15-12.14-24-32.83-19.67-45.1,4.74-13.34,25.44-19.62,34.35-22.32,28.19-8.67,52.33-2.93,73.64,2.14,21.83,5.18,44.4,10.55,61.57-6.21,17.7-17.22,17-48.29,8.81-69.92-10-26.15-30.53-36.41-47.06-44.65-13.09-6.53-20.6-10.6-23-18.37-2-6.43.36-11.62,7-23.75,7.41-13.48,17.55-31.93,14-58-4.77-36.43-36.65-66.5-68.28-64.3-30.19,2.1-44.53,32-53.08,49.73-11.83,24.42-16.07,42.54-19.16,55.78-2.9,12.39-4.37,18.06-8.79,22.43-5.21,5.13-12.26,7.47-21,6.93-13-.8-27.57-8-37.68-18.45V63.15a45,45,0,0,1,45-45Zm282.43,449v43.76a45,45,0,0,1-44.95,45H477.46a16.09,16.09,0,0,0-.36-2.56c-4.89-22.41-12.24-42.37-22-59.73a120.79,120.79,0,0,1,39-21,119.51,119.51,0,0,1,45.77-5.45Zm0-34.82a152.6,152.6,0,0,0-56,7.12,154.85,154.85,0,0,0-48.66,25.94,153.26,153.26,0,0,0-11.29-11.5c-12.68-11.52-24.67-18.5-35.24-24.65-14.45-8.41-24-14-30.62-27.78-.87-1.9-12.66-28.42-1.23-45.62,10-15.1,33.94-16.77,42.95-17.4l.17,0c16.24-1.3,26.66,2.23,41.07,7.11,12.45,4.22,26.57,9,46.95,11.61a212.66,212.66,0,0,0,51.92.08v75.11Z"
|
||||
fill="none"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<title>
|
||||
logo mark2018
|
||||
</title>
|
||||
<g
|
||||
clipPath="url(#clip-path-2)"
|
||||
>
|
||||
<rect
|
||||
fill="#f7705f"
|
||||
height="557.72"
|
||||
width="541.77"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
@@ -0,0 +1,15 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<Localized
|
||||
id="general-brandName"
|
||||
>
|
||||
<withPropsOnChange(Typography)
|
||||
align="center"
|
||||
className="custom BrandName-root BrandName-lg"
|
||||
variant="heading1"
|
||||
>
|
||||
Talk
|
||||
</withPropsOnChange(Typography)>
|
||||
</Localized>
|
||||
`;
|
||||
@@ -0,0 +1,17 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<withPropsOnChange(Flex)
|
||||
alignItems="center"
|
||||
className="Logo-root custom"
|
||||
>
|
||||
<BrandIcon
|
||||
className="Logo-icon"
|
||||
size="md"
|
||||
/>
|
||||
<BrandName
|
||||
align="center"
|
||||
size="md"
|
||||
/>
|
||||
</withPropsOnChange(Flex)>
|
||||
`;
|
||||
@@ -0,0 +1,56 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<withPropsOnChange(Flex)
|
||||
className="Navigation-root"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<withPropsOnChange(Flex)
|
||||
alignItems="center"
|
||||
>
|
||||
<Localized
|
||||
id="navigation-moderate"
|
||||
>
|
||||
<NavigationLink
|
||||
to="/admin/moderate"
|
||||
>
|
||||
Moderate
|
||||
</NavigationLink>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="navigation-community"
|
||||
>
|
||||
<NavigationLink
|
||||
to="/admin/community"
|
||||
>
|
||||
Community
|
||||
</NavigationLink>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="navigation-stories"
|
||||
>
|
||||
<NavigationLink
|
||||
to="/admin/stories"
|
||||
>
|
||||
Stories
|
||||
</NavigationLink>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="navigation-configure"
|
||||
>
|
||||
<NavigationLink
|
||||
to="/admin/configure"
|
||||
>
|
||||
Configure
|
||||
</NavigationLink>
|
||||
</Localized>
|
||||
</withPropsOnChange(Flex)>
|
||||
<withPropsOnChange(Flex)
|
||||
alignItems="center"
|
||||
>
|
||||
<withContext(createMutationContainer(RedirectAppContainer))
|
||||
id="navigation-signOutButton"
|
||||
/>
|
||||
</withPropsOnChange(Flex)>
|
||||
</withPropsOnChange(Flex)>
|
||||
`;
|
||||
@@ -0,0 +1,11 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<Link
|
||||
activeClassName="NavigationLink-active"
|
||||
className="NavigationLink-root"
|
||||
to="/moderate"
|
||||
>
|
||||
link
|
||||
</Link>
|
||||
`;
|
||||
@@ -0,0 +1,14 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<Localized
|
||||
id="navigation-signOutButton"
|
||||
>
|
||||
<withPropsOnChange(Button)
|
||||
id="id"
|
||||
onClick={[Function]}
|
||||
>
|
||||
Sign Out
|
||||
</withPropsOnChange(Button)>
|
||||
</Localized>
|
||||
`;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { BrowserProtocol, queryMiddleware } from "farce";
|
||||
import { createFarceRouter, createRender } from "found";
|
||||
import { Resolver } from "found-relay";
|
||||
|
||||
import React, { StatelessComponent } from "react";
|
||||
import { TalkContextConsumer } from "talk-framework/lib/bootstrap/TalkContext";
|
||||
import routeConfig from "../routeConfig";
|
||||
import NotFound from "../routes/NotFound";
|
||||
|
||||
const Router = createFarceRouter({
|
||||
historyProtocol: new BrowserProtocol(),
|
||||
historyMiddlewares: [queryMiddleware],
|
||||
routeConfig,
|
||||
render: createRender({
|
||||
renderError: ({ error }) => (
|
||||
<div>{error.status === 404 ? <NotFound /> : "Error"}</div>
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
const AppContainer: StatelessComponent = () => (
|
||||
<TalkContextConsumer>
|
||||
{({ relayEnvironment }) => (
|
||||
<Router resolver={new Resolver(relayEnvironment)} />
|
||||
)}
|
||||
</TalkContextConsumer>
|
||||
);
|
||||
|
||||
export default AppContainer;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Match, Router, withRouter } from "found";
|
||||
import React from "react";
|
||||
|
||||
import { RedirectAppContainerLocal as Local } from "talk-admin/__generated__/RedirectAppContainerLocal.graphql";
|
||||
import { graphql, withLocalStateContainer } from "talk-framework/lib/relay";
|
||||
|
||||
interface Props {
|
||||
local: Local;
|
||||
match: Match;
|
||||
router: Router;
|
||||
}
|
||||
|
||||
class RedirectAppContainer extends React.Component<Props> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.redirectIfLoggedIn();
|
||||
}
|
||||
|
||||
public componentWillReceiveProps() {
|
||||
this.redirectIfLoggedIn();
|
||||
}
|
||||
|
||||
private redirectIfLoggedIn() {
|
||||
if (this.props.local.loggedIn) {
|
||||
this.props.router.replace(this.props.local.redirectPath || "/admin");
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withRouter(
|
||||
withLocalStateContainer(
|
||||
graphql`
|
||||
fragment RedirectAppContainerLocal on Local {
|
||||
loggedIn
|
||||
redirectPath
|
||||
}
|
||||
`
|
||||
)(RedirectAppContainer)
|
||||
);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Match, Router, withRouter } from "found";
|
||||
import React from "react";
|
||||
|
||||
import { RedirectLoginContainerLocal as Local } from "talk-admin/__generated__/RedirectLoginContainerLocal.graphql";
|
||||
import {
|
||||
SetRedirectPathMutation,
|
||||
withSetRedirectPathMutation,
|
||||
} from "talk-admin/mutations";
|
||||
import { graphql, withLocalStateContainer } from "talk-framework/lib/relay";
|
||||
|
||||
interface Props {
|
||||
local: Local;
|
||||
match: Match;
|
||||
router: Router;
|
||||
setRedirectPath: SetRedirectPathMutation;
|
||||
}
|
||||
|
||||
class RedirectLoginContainer extends React.Component<Props> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.redirectIfNotLoggedIn();
|
||||
}
|
||||
|
||||
public componentWillReceiveProps() {
|
||||
this.redirectIfNotLoggedIn();
|
||||
}
|
||||
|
||||
private redirectIfNotLoggedIn() {
|
||||
if (!this.props.local.loggedIn) {
|
||||
const location = this.props.match.location;
|
||||
this.props.setRedirectPath({
|
||||
path: location.pathname + location.search + location.hash,
|
||||
});
|
||||
this.props.router.replace("/admin/login");
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withRouter(
|
||||
withSetRedirectPathMutation(
|
||||
withLocalStateContainer(
|
||||
graphql`
|
||||
fragment RedirectLoginContainerLocal on Local {
|
||||
loggedIn
|
||||
}
|
||||
`
|
||||
)(RedirectLoginContainer)
|
||||
)
|
||||
);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from "react";
|
||||
|
||||
import { SignOutMutation, withSignOutMutation } from "talk-framework/mutations";
|
||||
import SignOutButton from "../components/SignOutButton";
|
||||
|
||||
interface Props {
|
||||
signOut: SignOutMutation;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
class RedirectAppContainer extends React.Component<Props> {
|
||||
private handleClick = () => this.props.signOut();
|
||||
public render() {
|
||||
return <SignOutButton id={this.props.id} onClick={this.handleClick} />;
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withSignOutMutation(RedirectAppContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -1,14 +1,10 @@
|
||||
import { BrowserProtocol, queryMiddleware } from "farce";
|
||||
import { createFarceRouter, createRender } from "found";
|
||||
import { Resolver } from "found-relay";
|
||||
|
||||
import React, { StatelessComponent } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { createManaged } from "talk-framework/lib/bootstrap";
|
||||
import { TalkContextConsumer } from "talk-framework/lib/bootstrap/TalkContext";
|
||||
|
||||
import AppContainer from "./containers/AppContainer";
|
||||
import { initLocalState } from "./local";
|
||||
import localesData from "./locales";
|
||||
import routeConfig from "./routeConfig";
|
||||
|
||||
async function main() {
|
||||
const ManagedTalkContextProvider = await createManaged({
|
||||
@@ -17,20 +13,9 @@ async function main() {
|
||||
userLocales: navigator.languages,
|
||||
});
|
||||
|
||||
const Router = createFarceRouter({
|
||||
historyProtocol: new BrowserProtocol(),
|
||||
historyMiddlewares: [queryMiddleware],
|
||||
routeConfig,
|
||||
render: createRender({}),
|
||||
});
|
||||
|
||||
const Index: StatelessComponent = () => (
|
||||
<ManagedTalkContextProvider>
|
||||
<TalkContextConsumer>
|
||||
{({ relayEnvironment }) => (
|
||||
<Router resolver={new Resolver(relayEnvironment)} />
|
||||
)}
|
||||
</TalkContextConsumer>
|
||||
<AppContainer />
|
||||
</ManagedTalkContextProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { commitLocalUpdate, Environment } from "relay-runtime";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import {
|
||||
createAndRetain,
|
||||
LOCAL_ID,
|
||||
LOCAL_TYPE,
|
||||
} from "talk-framework/lib/relay";
|
||||
import { TalkContext } from "talk-framework/lib/bootstrap";
|
||||
import { initLocalBaseState } from "talk-framework/lib/relay";
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
root.setLinkedRecord(localRecord, "local");
|
||||
});
|
||||
export default async function initLocalState(
|
||||
environment: Environment,
|
||||
context: TalkContext
|
||||
) {
|
||||
await initLocalBaseState(environment, context);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Extend graph with local types
|
||||
type Network {
|
||||
isOffline: Boolean!
|
||||
}
|
||||
|
||||
type Local {
|
||||
network: Network!
|
||||
authToken: String
|
||||
authExp: Int
|
||||
authJTI: String
|
||||
loggedIn: Boolean!
|
||||
redirectPath: String
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Environment, RecordSource } from "relay-runtime";
|
||||
|
||||
import { LOCAL_ID } from "talk-framework/lib/relay";
|
||||
import { createRelayEnvironment } from "talk-framework/testHelpers";
|
||||
|
||||
import { commit } from "./SetRedirectPathMutation";
|
||||
|
||||
let environment: Environment;
|
||||
const source: RecordSource = new RecordSource();
|
||||
|
||||
beforeAll(() => {
|
||||
environment = createRelayEnvironment({
|
||||
source,
|
||||
});
|
||||
});
|
||||
|
||||
it("Sets redirectPath", () => {
|
||||
commit(environment, { path: "/path" });
|
||||
expect(source.get(LOCAL_ID)!.redirectPath).toEqual("/path");
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { commitLocalUpdate, Environment } from "relay-runtime";
|
||||
|
||||
import { createMutationContainer, LOCAL_ID } from "talk-framework/lib/relay";
|
||||
|
||||
export interface SetRedirectPathInput {
|
||||
path: string | null;
|
||||
}
|
||||
|
||||
export type SetRedirectPathMutation = (
|
||||
input: SetRedirectPathInput
|
||||
) => Promise<void>;
|
||||
|
||||
export async function commit(
|
||||
environment: Environment,
|
||||
input: SetRedirectPathInput
|
||||
) {
|
||||
return commitLocalUpdate(environment, store => {
|
||||
const record = store.get(LOCAL_ID)!;
|
||||
record.setValue(input.path, "redirectPath");
|
||||
});
|
||||
}
|
||||
|
||||
export const withSetRedirectPathMutation = createMutationContainer(
|
||||
"setRedirectPath",
|
||||
commit
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { TalkContext } from "talk-framework/lib/bootstrap";
|
||||
import { createMutationContainer } from "talk-framework/lib/relay";
|
||||
import { commit as setAuthToken } from "talk-framework/mutations/SetAuthTokenMutation";
|
||||
import { signIn, SignInInput } from "talk-framework/rest";
|
||||
|
||||
export type SignInMutation = (input: SignInInput) => Promise<void>;
|
||||
|
||||
export async function commit(
|
||||
environment: Environment,
|
||||
input: SignInInput,
|
||||
context: TalkContext
|
||||
) {
|
||||
const result = await signIn(context.rest, input);
|
||||
setAuthToken(environment, { authToken: result.token }, context);
|
||||
}
|
||||
|
||||
export const withSignInMutation = createMutationContainer("signIn", commit);
|
||||
@@ -0,0 +1,5 @@
|
||||
export { withSignInMutation, SignInMutation } from "./SignInMutation";
|
||||
export {
|
||||
withSetRedirectPathMutation,
|
||||
SetRedirectPathMutation,
|
||||
} from "./SetRedirectPathMutation";
|
||||
@@ -1,17 +1,28 @@
|
||||
import { makeRouteConfig, Route } from "found";
|
||||
import { makeRouteConfig, Redirect, Route } from "found";
|
||||
import React from "react";
|
||||
|
||||
import App from "./components/App";
|
||||
import Community from "./components/Community";
|
||||
import Configure from "./components/Configure";
|
||||
import Moderate from "./components/Moderate";
|
||||
import Stories from "./components/Stories";
|
||||
import RedirectAppContainer from "./containers/RedirectAppContainer";
|
||||
import RedirectLoginContainer from "./containers/RedirectLoginContainer";
|
||||
import Community from "./routes/community/components/Community";
|
||||
import Configure from "./routes/configure/components/Configure";
|
||||
import Login from "./routes/login/components/Login";
|
||||
import Moderate from "./routes/moderate/components/Moderate";
|
||||
import Stories from "./routes/stories/components/Stories";
|
||||
|
||||
export default makeRouteConfig(
|
||||
<Route path="admin" Component={App}>
|
||||
<Route path="moderate" Component={Moderate} />
|
||||
<Route path="community" Component={Community} />
|
||||
<Route path="stories" Component={Stories} />
|
||||
<Route path="configure" Component={Configure} />
|
||||
<Route path="admin">
|
||||
<Route Component={RedirectLoginContainer}>
|
||||
<Route Component={App}>
|
||||
<Redirect from="/" to="/admin/moderate" />
|
||||
<Route path="moderate" Component={Moderate} />
|
||||
<Route path="community" Component={Community} />
|
||||
<Route path="stories" Component={Stories} />
|
||||
<Route path="configure" Component={Configure} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route Component={RedirectAppContainer}>
|
||||
<Route path="login" Component={Login} />
|
||||
</Route>
|
||||
</Route>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import React, { StatelessComponent } from "react";
|
||||
import { HorizontalGutter, Typography } from "talk-ui/components";
|
||||
|
||||
const NotFound: StatelessComponent = ({ children }) => (
|
||||
<HorizontalGutter>
|
||||
<Typography variant="heading3">Not Found</Typography>
|
||||
</HorizontalGutter>
|
||||
);
|
||||
|
||||
export default NotFound;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
|
||||
import Community from "./Community";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const wrapper = shallow(<Community />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import React, { StatelessComponent } from "react";
|
||||
import { HorizontalGutter, Typography } from "talk-ui/components";
|
||||
|
||||
const Community: StatelessComponent = ({ children }) => (
|
||||
const Community: StatelessComponent = () => (
|
||||
<HorizontalGutter>
|
||||
<Typography variant="heading3">Community</Typography>
|
||||
</HorizontalGutter>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<withPropsOnChange(HorizontalGutter)>
|
||||
<withPropsOnChange(Typography)
|
||||
variant="heading3"
|
||||
>
|
||||
Community
|
||||
</withPropsOnChange(Typography)>
|
||||
</withPropsOnChange(HorizontalGutter)>
|
||||
`;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
|
||||
import Configure from "./Configure";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const wrapper = shallow(<Configure />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<withPropsOnChange(HorizontalGutter)>
|
||||
<withPropsOnChange(Typography)
|
||||
variant="heading3"
|
||||
>
|
||||
Configure
|
||||
</withPropsOnChange(Typography)>
|
||||
</withPropsOnChange(HorizontalGutter)>
|
||||
`;
|
||||
@@ -0,0 +1,17 @@
|
||||
.loginContainer {
|
||||
width: 350px;
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid var(--palette-grey-lighter);
|
||||
margin-top: 70px;
|
||||
padding: 0 calc(3 * var(--spacing-unit)) calc(4 * var(--spacing-unit))
|
||||
calc(3 * var(--spacing-unit));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.brandIcon {
|
||||
display: inline-flex;
|
||||
background: var(--palette-common-white);
|
||||
padding: calc(0.5 * var(--spacing-unit));
|
||||
border: 1px solid var(--palette-grey-lighter);
|
||||
margin-top: -42px;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
|
||||
import Login from "./Login";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const wrapper = shallow(<Login />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import AppBar from "talk-admin/components/AppBar";
|
||||
import BrandName from "talk-admin/components/BrandName";
|
||||
import { Flex, HorizontalGutter, Typography } from "talk-ui/components";
|
||||
|
||||
import BrandIcon from "talk-admin/components/BrandIcon";
|
||||
import SignInFormContainer from "../containers/SignInFormContainer";
|
||||
import styles from "./Login.css";
|
||||
|
||||
const Login: StatelessComponent = () => (
|
||||
<div>
|
||||
<AppBar />
|
||||
<Flex justifyContent="center">
|
||||
<HorizontalGutter className={styles.loginContainer} size="double">
|
||||
<Flex justifyContent="center">
|
||||
<div className={styles.brandIcon}>
|
||||
<BrandIcon size="lg" />
|
||||
</div>
|
||||
</Flex>
|
||||
<div>
|
||||
<Localized id="login-login-signInTo">
|
||||
<Typography align="center" variant="heading3">
|
||||
Sign in to
|
||||
</Typography>
|
||||
</Localized>
|
||||
<BrandName size="lg" align="center" />
|
||||
</div>
|
||||
<Localized id="login-login-enterAccountDetailsBelow">
|
||||
<Typography align="center">
|
||||
Enter your account details below
|
||||
</Typography>
|
||||
</Localized>
|
||||
<SignInFormContainer />
|
||||
</HorizontalGutter>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Login;
|
||||
@@ -0,0 +1,132 @@
|
||||
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,
|
||||
} from "talk-framework/lib/validation";
|
||||
|
||||
import {
|
||||
Button,
|
||||
CallOut,
|
||||
FormField,
|
||||
HorizontalGutter,
|
||||
InputLabel,
|
||||
TextField,
|
||||
ValidationMessage,
|
||||
} from "talk-ui/components";
|
||||
|
||||
interface FormProps {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSubmit: OnSubmit<FormProps>;
|
||||
}
|
||||
|
||||
const SignIn: StatelessComponent<Props> = props => (
|
||||
<Form onSubmit={props.onSubmit}>
|
||||
{({ handleSubmit, submitting, submitError }) => (
|
||||
<form autoComplete="off" onSubmit={handleSubmit}>
|
||||
<HorizontalGutter size="double">
|
||||
{submitError && (
|
||||
<CallOut color="error" fullWidth>
|
||||
{submitError}
|
||||
</CallOut>
|
||||
)}
|
||||
|
||||
<Field
|
||||
name="email"
|
||||
validate={composeValidators(required, validateEmail)}
|
||||
>
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<Localized id="login-signIn-emailAddressLabel">
|
||||
<InputLabel>Email Address</InputLabel>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="login-signIn-emailAddressTextField"
|
||||
attrs={{ placeholder: true }}
|
||||
>
|
||||
<TextField
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
value={input.value}
|
||||
placeholder="Email Address"
|
||||
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)}>
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<Localized id="login-signIn-passwordLabel">
|
||||
<InputLabel>Password</InputLabel>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="login-signIn-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>
|
||||
<Localized id="login-signIn-signIn">
|
||||
<Button
|
||||
variant="filled"
|
||||
color="primary"
|
||||
size="large"
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
fullWidth
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
|
||||
export default SignIn;
|
||||
@@ -0,0 +1,53 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<div>
|
||||
<AppBar />
|
||||
<withPropsOnChange(Flex)
|
||||
justifyContent="center"
|
||||
>
|
||||
<withPropsOnChange(HorizontalGutter)
|
||||
className="Login-loginContainer"
|
||||
size="double"
|
||||
>
|
||||
<withPropsOnChange(Flex)
|
||||
justifyContent="center"
|
||||
>
|
||||
<div
|
||||
className="Login-brandIcon"
|
||||
>
|
||||
<BrandIcon
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
</withPropsOnChange(Flex)>
|
||||
<div>
|
||||
<Localized
|
||||
id="login-login-signInTo"
|
||||
>
|
||||
<withPropsOnChange(Typography)
|
||||
align="center"
|
||||
variant="heading3"
|
||||
>
|
||||
Sign in to
|
||||
</withPropsOnChange(Typography)>
|
||||
</Localized>
|
||||
<BrandName
|
||||
align="center"
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
<Localized
|
||||
id="login-login-enterAccountDetailsBelow"
|
||||
>
|
||||
<withPropsOnChange(Typography)
|
||||
align="center"
|
||||
>
|
||||
Enter your account details below
|
||||
</withPropsOnChange(Typography)>
|
||||
</Localized>
|
||||
<withContext(createMutationContainer(SignInContainer)) />
|
||||
</withPropsOnChange(HorizontalGutter)>
|
||||
</withPropsOnChange(Flex)>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { FORM_ERROR } from "final-form";
|
||||
import React, { Component } from "react";
|
||||
import { SignInMutation, withSignInMutation } from "talk-admin/mutations";
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
|
||||
import SignInForm from "../components/SignInForm";
|
||||
|
||||
interface SignInContainerProps {
|
||||
signIn: SignInMutation;
|
||||
}
|
||||
|
||||
class SignInContainer extends Component<SignInContainerProps> {
|
||||
private onSubmit: PropTypesOf<typeof SignInForm>["onSubmit"] = async (
|
||||
input,
|
||||
form
|
||||
) => {
|
||||
try {
|
||||
await this.props.signIn(input);
|
||||
return form.reset();
|
||||
} catch (error) {
|
||||
return { [FORM_ERROR]: error.message };
|
||||
}
|
||||
};
|
||||
public render() {
|
||||
return <SignInForm onSubmit={this.onSubmit} />;
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withSignInMutation(SignInContainer);
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
|
||||
import Moderate from "./Moderate";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const wrapper = shallow(<Moderate />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<withPropsOnChange(HorizontalGutter)>
|
||||
<withPropsOnChange(Typography)
|
||||
variant="heading3"
|
||||
>
|
||||
Moderate
|
||||
</withPropsOnChange(Typography)>
|
||||
</withPropsOnChange(HorizontalGutter)>
|
||||
`;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { shallow } from "enzyme";
|
||||
import React from "react";
|
||||
|
||||
import Stories from "./Stories";
|
||||
|
||||
it("renders correctly", () => {
|
||||
const wrapper = shallow(<Stories />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders correctly 1`] = `
|
||||
<withPropsOnChange(HorizontalGutter)>
|
||||
<withPropsOnChange(Typography)
|
||||
variant="heading3"
|
||||
>
|
||||
Stories
|
||||
</withPropsOnChange(Typography)>
|
||||
</withPropsOnChange(HorizontalGutter)>
|
||||
`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
import { EventEmitter2 } from "eventemitter2";
|
||||
import { IResolvers } from "graphql-tools";
|
||||
import { noop } from "lodash";
|
||||
import React from "react";
|
||||
import TestRenderer from "react-test-renderer";
|
||||
import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime";
|
||||
|
||||
import AppContainer from "talk-admin/containers/AppContainer";
|
||||
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
|
||||
import { PostMessageService } from "talk-framework/lib/postMessage";
|
||||
import { RestClient } from "talk-framework/lib/rest";
|
||||
import { createPromisifiedStorage } from "talk-framework/lib/storage";
|
||||
import { createUUIDGenerator } from "talk-framework/testHelpers";
|
||||
|
||||
import createEnvironment from "./createEnvironment";
|
||||
import createFluentBundle from "./createFluentBundle";
|
||||
|
||||
interface CreateParams {
|
||||
logNetwork?: boolean;
|
||||
resolvers?: IResolvers<any, any>;
|
||||
initLocalState?: (
|
||||
local: RecordProxy,
|
||||
source: RecordSourceProxy,
|
||||
environment: Environment
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function create(params: CreateParams) {
|
||||
const environment = createEnvironment({
|
||||
// Set this to true, to see graphql responses.
|
||||
logNetwork: params.logNetwork,
|
||||
resolvers: params.resolvers,
|
||||
initLocalState: (localRecord, source, env) => {
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, env);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const context: TalkContext = {
|
||||
relayEnvironment: environment,
|
||||
localeBundles: [createFluentBundle()],
|
||||
localStorage: createPromisifiedStorage(),
|
||||
sessionStorage: createPromisifiedStorage(),
|
||||
rest: new RestClient("http://localhost/api"),
|
||||
postMessage: new PostMessageService(),
|
||||
browserInfo: { ios: false },
|
||||
uuidGenerator: createUUIDGenerator(),
|
||||
eventEmitter: new EventEmitter2({ wildcard: true, maxListeners: 20 }),
|
||||
clearSession: noop,
|
||||
};
|
||||
|
||||
const testRenderer = TestRenderer.create(
|
||||
<TalkContextProvider value={context}>
|
||||
<AppContainer />
|
||||
</TalkContextProvider>
|
||||
);
|
||||
|
||||
return { context, testRenderer };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IResolvers } from "graphql-tools";
|
||||
import { Environment, RecordProxy, RecordSourceProxy } from "relay-runtime";
|
||||
import { createRelayEnvironment } from "talk-framework/testHelpers";
|
||||
|
||||
interface CreateEnvironmentParams {
|
||||
logNetwork?: boolean;
|
||||
resolvers?: IResolvers<any, any>;
|
||||
initLocalState?: (
|
||||
local: RecordProxy,
|
||||
source: RecordSourceProxy,
|
||||
environment: Environment
|
||||
) => void;
|
||||
}
|
||||
|
||||
export default function createEnvironment(params: CreateEnvironmentParams) {
|
||||
return createRelayEnvironment({
|
||||
network: {
|
||||
logNetwork: params.logNetwork,
|
||||
resolvers: params.resolvers || {},
|
||||
projectName: "tenant",
|
||||
},
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import path from "path";
|
||||
|
||||
import { createFluentBundle } from "talk-framework/testHelpers";
|
||||
|
||||
export default function create() {
|
||||
return createFluentBundle(
|
||||
"admin",
|
||||
path.resolve(__dirname, "../../../../locales/en-US")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { ReactTestInstance, ReactTestRenderer } from "react-test-renderer";
|
||||
import sinon from "sinon";
|
||||
|
||||
import { timeout } from "talk-common/utils";
|
||||
import { TalkContext } from "talk-framework/lib/bootstrap";
|
||||
|
||||
import { LOCAL_ID } from "talk-framework/lib/relay";
|
||||
import { replaceHistoryLocation } from "talk-framework/testHelpers";
|
||||
|
||||
import create from "./create";
|
||||
|
||||
const inputPredicate = (name: string) => (n: ReactTestInstance) => {
|
||||
return n.props.name === name && n.props.onChange;
|
||||
};
|
||||
|
||||
let context: TalkContext;
|
||||
let testRenderer: ReactTestRenderer;
|
||||
let form: ReactTestInstance;
|
||||
beforeEach(async () => {
|
||||
// deliberately setting to a different route,
|
||||
// it should be smart enough to reroute to /admin/login.
|
||||
replaceHistoryLocation("http://localhost/admin/moderate");
|
||||
|
||||
({ testRenderer, context } = create({
|
||||
// Set this to true, to see graphql responses.
|
||||
logNetwork: false,
|
||||
initLocalState: localRecord => {
|
||||
localRecord.setValue(false, "loggedIn");
|
||||
},
|
||||
}));
|
||||
await timeout();
|
||||
form = testRenderer.root.findByType("form");
|
||||
});
|
||||
|
||||
it("renders sign in form", async () => {
|
||||
expect(testRenderer.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("shows error when submitting empty form", async () => {
|
||||
form.props.onSubmit();
|
||||
expect(testRenderer.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("checks for invalid email", async () => {
|
||||
form
|
||||
.find(inputPredicate("email"))
|
||||
.props.onChange({ target: { value: "invalid-email" } });
|
||||
form.props.onSubmit();
|
||||
expect(testRenderer.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("accepts valid email", async () => {
|
||||
form
|
||||
.find(inputPredicate("email"))
|
||||
.props.onChange({ target: { value: "hans@test.com" } });
|
||||
form.props.onSubmit();
|
||||
expect(testRenderer.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("accepts correct password", async () => {
|
||||
form
|
||||
.find(inputPredicate("password"))
|
||||
.props.onChange({ target: { value: "testtest" } });
|
||||
form.props.onSubmit();
|
||||
expect(testRenderer.toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("shows server error", async () => {
|
||||
form
|
||||
.find(inputPredicate("email"))
|
||||
.props.onChange({ target: { value: "hans@test.com" } });
|
||||
form
|
||||
.find(inputPredicate("password"))
|
||||
.props.onChange({ target: { value: "testtest" } });
|
||||
|
||||
const error = new Error("Server Error");
|
||||
const restMock = sinon.mock(context.rest);
|
||||
restMock
|
||||
.expects("fetch")
|
||||
.withArgs("/tenant/auth/local", {
|
||||
method: "POST",
|
||||
body: {
|
||||
email: "hans@test.com",
|
||||
password: "testtest",
|
||||
},
|
||||
})
|
||||
.once()
|
||||
.throws(error);
|
||||
|
||||
form.props.onSubmit();
|
||||
expect(testRenderer.toJSON()).toMatchSnapshot();
|
||||
await timeout();
|
||||
expect(testRenderer.toJSON()).toMatchSnapshot();
|
||||
restMock.verify();
|
||||
});
|
||||
|
||||
it("submits form successfully", async () => {
|
||||
form
|
||||
.find(inputPredicate("email"))
|
||||
.props.onChange({ target: { value: "hans@test.com" } });
|
||||
form
|
||||
.find(inputPredicate("password"))
|
||||
.props.onChange({ target: { value: "testtest" } });
|
||||
|
||||
const authToken = `${btoa(
|
||||
JSON.stringify({
|
||||
alg: "HS256",
|
||||
typ: "JWT",
|
||||
})
|
||||
)}.${btoa(
|
||||
JSON.stringify({
|
||||
exp: 1540503165,
|
||||
jti: "31b26591-4e9a-4388-a7ff-e1bdc5d97cce",
|
||||
})
|
||||
)}`;
|
||||
|
||||
const restMock = sinon.mock(context.rest);
|
||||
restMock
|
||||
.expects("fetch")
|
||||
.withArgs("/tenant/auth/local", {
|
||||
method: "POST",
|
||||
body: {
|
||||
email: "hans@test.com",
|
||||
password: "testtest",
|
||||
},
|
||||
})
|
||||
.once()
|
||||
.returns({ token: authToken });
|
||||
|
||||
const historyMock = sinon.mock(window.history);
|
||||
|
||||
form.props.onSubmit();
|
||||
expect(testRenderer.toJSON()).toMatchSnapshot();
|
||||
await timeout();
|
||||
expect(testRenderer.toJSON()).toMatchSnapshot();
|
||||
restMock.verify();
|
||||
await timeout();
|
||||
expect(
|
||||
context.relayEnvironment
|
||||
.getStore()
|
||||
.getSource()
|
||||
.get(LOCAL_ID)!.loggedIn
|
||||
).toBeTruthy();
|
||||
await timeout();
|
||||
historyMock.verify();
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { timeout } from "talk-common/utils";
|
||||
import { replaceHistoryLocation } from "talk-framework/testHelpers";
|
||||
|
||||
import create from "./create";
|
||||
|
||||
it("redirect when already logged in", async () => {
|
||||
replaceHistoryLocation("http://localhost/admin/login");
|
||||
create({
|
||||
// Set this to true, to see graphql responses.
|
||||
logNetwork: false,
|
||||
initLocalState: localRecord => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
},
|
||||
});
|
||||
await timeout();
|
||||
expect(window.location.toString()).toBe("http://localhost/admin/moderate");
|
||||
});
|
||||
|
||||
it("redirect to redirectPath when already logged in", async () => {
|
||||
replaceHistoryLocation("http://localhost/admin/login");
|
||||
create({
|
||||
// Set this to true, to see graphql responses.
|
||||
logNetwork: false,
|
||||
initLocalState: localRecord => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
localRecord.setValue("/admin/configure", "redirectPath");
|
||||
},
|
||||
});
|
||||
await timeout();
|
||||
expect(window.location.toString()).toBe("http://localhost/admin/configure");
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { timeout } from "talk-common/utils";
|
||||
import { TalkContext } from "talk-framework/lib/bootstrap";
|
||||
import { LOCAL_ID } from "talk-framework/lib/relay";
|
||||
import { replaceHistoryLocation } from "talk-framework/testHelpers";
|
||||
|
||||
import create from "./create";
|
||||
|
||||
function createTestRenderer(): {
|
||||
testRenderer: ReactTestRenderer;
|
||||
context: TalkContext;
|
||||
} {
|
||||
replaceHistoryLocation("http://localhost/admin/moderate");
|
||||
|
||||
const { testRenderer, context } = create({
|
||||
// Set this to true, to see graphql responses.
|
||||
logNetwork: false,
|
||||
initLocalState: localRecord => {
|
||||
localRecord.setValue(false, "loggedIn");
|
||||
},
|
||||
});
|
||||
return { testRenderer, context };
|
||||
}
|
||||
|
||||
it("redirect when not logged in", async () => {
|
||||
const { context } = createTestRenderer();
|
||||
await timeout();
|
||||
expect(
|
||||
context.relayEnvironment
|
||||
.getStore()
|
||||
.getSource()
|
||||
.get(LOCAL_ID)!.redirectPath
|
||||
).toBe("/admin/moderate");
|
||||
expect(window.location.toString()).toBe("http://localhost/admin/login");
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import sinon from "sinon";
|
||||
|
||||
import { timeout } from "talk-common/utils";
|
||||
import { LOCAL_ID } from "talk-framework/lib/relay";
|
||||
import { replaceHistoryLocation } from "talk-framework/testHelpers";
|
||||
|
||||
import create from "./create";
|
||||
|
||||
it("logs out", async () => {
|
||||
replaceHistoryLocation("http://localhost/admin/moderate");
|
||||
|
||||
const { testRenderer, context } = create({
|
||||
// Set this to true, to see graphql responses.
|
||||
logNetwork: false,
|
||||
initLocalState: localRecord => {
|
||||
localRecord.setValue(true, "loggedIn");
|
||||
},
|
||||
});
|
||||
|
||||
const restMock = sinon.mock(context.rest);
|
||||
restMock
|
||||
.expects("fetch")
|
||||
.withArgs("/tenant/auth", {
|
||||
method: "DELETE",
|
||||
})
|
||||
.once()
|
||||
.returns({});
|
||||
|
||||
await timeout();
|
||||
testRenderer.root
|
||||
.find(i => i.props.id === "navigation-signOutButton" && i.props.onClick)
|
||||
.props.onClick();
|
||||
|
||||
await timeout();
|
||||
|
||||
expect(
|
||||
context.relayEnvironment
|
||||
.getStore()
|
||||
.getSource()
|
||||
.get(LOCAL_ID)!.loggedIn
|
||||
).toBeFalsy();
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Environment, RecordSource } from "relay-runtime";
|
||||
|
||||
import { LOCAL_ID } from "talk-framework/lib/relay";
|
||||
import { createRelayEnvironment } from "talk-framework/testHelpers";
|
||||
import {
|
||||
createRelayEnvironment,
|
||||
replaceHistoryLocation,
|
||||
} from "talk-framework/testHelpers";
|
||||
|
||||
import initLocalState from "./initLocalState";
|
||||
|
||||
@@ -23,14 +26,10 @@ it("init local state", () => {
|
||||
|
||||
it("set view from query", () => {
|
||||
const view = "SIGN_UP";
|
||||
const previousLocation = location.toString();
|
||||
const previousState = window.history.state;
|
||||
window.history.replaceState(
|
||||
previousState,
|
||||
document.title,
|
||||
const restoreHistoryLocation = replaceHistoryLocation(
|
||||
`http://localhost/?view=${view}`
|
||||
);
|
||||
initLocalState(environment);
|
||||
expect(source.get(LOCAL_ID)!.view).toBe(view);
|
||||
window.history.replaceState(previousState, document.title, previousLocation);
|
||||
restoreHistoryLocation();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface JWT {
|
||||
header: {
|
||||
alg: string;
|
||||
typ: string;
|
||||
};
|
||||
payload: {
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
iss?: string;
|
||||
sub?: string;
|
||||
jti?: string;
|
||||
};
|
||||
expired: boolean;
|
||||
}
|
||||
|
||||
export function parseJWT(token: string, skewTolerance = 300): JWT {
|
||||
const [headerBase64, payloadBase64] = token.split(".");
|
||||
if (!headerBase64 && !payloadBase64) {
|
||||
throw new Error("invalid jwt token");
|
||||
}
|
||||
const header = JSON.parse(atob(headerBase64));
|
||||
const payload = JSON.parse(atob(payloadBase64));
|
||||
return {
|
||||
header,
|
||||
payload,
|
||||
get expired() {
|
||||
return Date.now() - skewTolerance < payload.exp;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -16,3 +16,4 @@ export { graphql } from "react-relay";
|
||||
export {
|
||||
default as commitLocalUpdatePromisified,
|
||||
} from "./commitLocalUpdatePromisified";
|
||||
export { initLocalBaseState, setAuthTokenInLocalState } from "./localState";
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
commitLocalUpdate,
|
||||
Environment,
|
||||
RecordSourceProxy,
|
||||
} from "relay-runtime";
|
||||
|
||||
import { TalkContext } from "talk-framework/lib/bootstrap";
|
||||
import { parseJWT } from "talk-framework/lib/jwt";
|
||||
import { createAndRetain } from "talk-framework/lib/relay";
|
||||
|
||||
/**
|
||||
* 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";
|
||||
|
||||
export const NETWORK_TYPE = "Network";
|
||||
export const NETWORK_ID = "client:root.local.network";
|
||||
|
||||
export function setAuthTokenInLocalState(
|
||||
authToken: string | null,
|
||||
source: RecordSourceProxy
|
||||
) {
|
||||
const localRecord = source.get(LOCAL_ID)!;
|
||||
localRecord.setValue(authToken || "", "authToken");
|
||||
if (authToken) {
|
||||
const { payload, expired } = parseJWT(authToken);
|
||||
localRecord.setValue(payload.exp, "authExp");
|
||||
localRecord.setValue(payload.jti, "authJTI");
|
||||
localRecord.setValue(!expired, "loggedIn");
|
||||
} else {
|
||||
localRecord.setValue(null, "authExp");
|
||||
localRecord.setValue(null, "authJTI");
|
||||
localRecord.setValue(false, "loggedIn");
|
||||
}
|
||||
}
|
||||
|
||||
export async function initLocalBaseState(
|
||||
environment: Environment,
|
||||
{ localStorage }: TalkContext
|
||||
) {
|
||||
const authToken = await localStorage!.getItem("authToken");
|
||||
|
||||
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);
|
||||
root.setLinkedRecord(localRecord, "local");
|
||||
|
||||
// Set auth token
|
||||
setAuthTokenInLocalState(authToken, s);
|
||||
|
||||
// Create network Record
|
||||
const networkRecord = createAndRetain(
|
||||
environment,
|
||||
s,
|
||||
NETWORK_ID,
|
||||
NETWORK_TYPE
|
||||
);
|
||||
networkRecord.setValue(false, "isOffline");
|
||||
localRecord.setLinkedRecord(networkRecord, "network");
|
||||
});
|
||||
}
|
||||
@@ -17,13 +17,24 @@ beforeAll(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const authToken = `${btoa(
|
||||
JSON.stringify({
|
||||
alg: "HS256",
|
||||
typ: "JWT",
|
||||
})
|
||||
)}.${btoa(
|
||||
JSON.stringify({
|
||||
exp: 1540503165,
|
||||
jti: "31b26591-4e9a-4388-a7ff-e1bdc5d97cce",
|
||||
})
|
||||
)}`;
|
||||
|
||||
it("Sets auth token to localStorage", async () => {
|
||||
const clearSessionStub = sinon.stub();
|
||||
const context: Partial<TalkContext> = {
|
||||
localStorage: createPromisifiedStorage(),
|
||||
clearSession: clearSessionStub,
|
||||
};
|
||||
const authToken = "auth token";
|
||||
await commit(environment, { authToken }, context as any);
|
||||
expect(source.get(LOCAL_ID)!.authToken).toEqual(authToken);
|
||||
await expect(context.localStorage!.getItem("authToken")).resolves.toEqual(
|
||||
@@ -38,7 +49,7 @@ it("Removes auth token from localStorage", async () => {
|
||||
localStorage: createPromisifiedStorage(),
|
||||
clearSession: clearSessionStub,
|
||||
};
|
||||
localStorage.setItem("authToken", "tmp");
|
||||
localStorage.setItem("authToken", authToken);
|
||||
await commit(environment, { authToken: null }, context as any);
|
||||
await expect(context.localStorage!.getItem("authToken")).resolves.toBeNull();
|
||||
expect(clearSessionStub.calledOnce).toBe(true);
|
||||
|
||||
@@ -4,8 +4,8 @@ import { TalkContext } from "talk-framework/lib/bootstrap";
|
||||
import {
|
||||
commitLocalUpdatePromisified,
|
||||
createMutationContainer,
|
||||
setAuthTokenInLocalState,
|
||||
} from "talk-framework/lib/relay";
|
||||
import { LOCAL_ID } from "talk-framework/lib/relay/withLocalStateContainer";
|
||||
|
||||
export interface SetAuthTokenInput {
|
||||
authToken: string | null;
|
||||
@@ -19,8 +19,7 @@ export async function commit(
|
||||
{ localStorage, clearSession }: TalkContext
|
||||
) {
|
||||
return await commitLocalUpdatePromisified(environment, async store => {
|
||||
const record = store.get(LOCAL_ID)!;
|
||||
record.setValue(input.authToken, "authToken");
|
||||
setAuthTokenInLocalState(input.authToken, store);
|
||||
if (input.authToken) {
|
||||
await localStorage.setItem("authToken", input.authToken);
|
||||
} else {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { Environment, RecordSource } from "relay-runtime";
|
||||
|
||||
import { createRelayEnvironment } from "talk-framework/testHelpers";
|
||||
|
||||
import { NETWORK_ID, NETWORK_TYPE } from "../local";
|
||||
import { NETWORK_ID, NETWORK_TYPE } from "../lib/relay/localState";
|
||||
import { commit } from "./SetNetworkStatusMutation";
|
||||
|
||||
let environment: Environment;
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { commitLocalUpdate, Environment } from "relay-runtime";
|
||||
|
||||
import { createMutationContainer } from "talk-framework/lib/relay";
|
||||
|
||||
import { NETWORK_ID } from "../local";
|
||||
import { NETWORK_ID } from "../lib/relay/localState";
|
||||
|
||||
export interface SetNetworkStatusInput {
|
||||
isOffline: boolean;
|
||||
@@ -4,3 +4,8 @@ export {
|
||||
SetAuthTokenInput,
|
||||
} from "./SetAuthTokenMutation";
|
||||
export { withSignOutMutation, SignOutMutation } from "./SignOutMutation";
|
||||
export {
|
||||
withSetNetworkStatusMutation,
|
||||
SetNetworkStatusMutation,
|
||||
SetNetworkStatusInput,
|
||||
} from "./SetNetworkStatusMutation";
|
||||
|
||||
@@ -10,3 +10,4 @@ export {
|
||||
} from "./removeFragmentRefs";
|
||||
export { default as createUUIDGenerator } from "./createUUIDGenerator";
|
||||
export * from "./denormalize";
|
||||
export { default as replaceHistoryLocation } from "./replaceHistoryLocation";
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
type RestoreHistoryFunction = () => void;
|
||||
|
||||
export default function replaceHistoryLocation(
|
||||
location: string
|
||||
): RestoreHistoryFunction {
|
||||
const previousState = window.history.state;
|
||||
const previousLocation = location.toString();
|
||||
window.history.replaceState(previousState, document.title, location);
|
||||
return () =>
|
||||
window.history.replaceState(
|
||||
previousState,
|
||||
document.title,
|
||||
previousLocation
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import { shallow } from "enzyme";
|
||||
import noop from "lodash";
|
||||
import React from "react";
|
||||
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
it("renders comments", () => {
|
||||
it("renders correctly", () => {
|
||||
const props: PropTypesOf<typeof App> = {
|
||||
activeTab: "COMMENTS",
|
||||
onTabClick: noop,
|
||||
};
|
||||
const wrapper = shallow(<App {...props} />);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import * as React from "react";
|
||||
import { StatelessComponent } from "react";
|
||||
import {
|
||||
HorizontalGutter,
|
||||
Tab,
|
||||
TabBar,
|
||||
TabContent,
|
||||
TabPane,
|
||||
} from "talk-ui/components";
|
||||
import TabBarQuery from "talk-stream/queries/TabBarQuery";
|
||||
import { HorizontalGutter, TabContent, TabPane } from "talk-ui/components";
|
||||
|
||||
import { PropTypesOf } from "talk-ui/types";
|
||||
import CommentsCountQuery from "../queries/CommentsCountQuery";
|
||||
import IfLoggedInQuery from "../queries/IfLoggedInQuery";
|
||||
import CommentsPaneContainer from "../tabs/comments/containers/CommentsPaneContainer";
|
||||
import ProfileQuery from "../tabs/profile/queries/ProfileQuery";
|
||||
import * as styles from "./App.css";
|
||||
@@ -20,28 +11,12 @@ type TabValue = "COMMENTS" | "PROFILE" | "%future added value";
|
||||
|
||||
export interface AppProps {
|
||||
activeTab: TabValue;
|
||||
onTabClick: (tab: TabValue) => void;
|
||||
}
|
||||
|
||||
const CommentsTab: StatelessComponent<PropTypesOf<typeof Tab>> = props => (
|
||||
<CommentsCountQuery {...props} />
|
||||
);
|
||||
|
||||
const MyProfileTab: StatelessComponent<PropTypesOf<typeof Tab>> = props => (
|
||||
<IfLoggedInQuery>
|
||||
<Localized id="general-app-myProfileTab">
|
||||
<Tab {...props}>My Profile</Tab>
|
||||
</Localized>
|
||||
</IfLoggedInQuery>
|
||||
);
|
||||
|
||||
const App: StatelessComponent<AppProps> = props => {
|
||||
return (
|
||||
<HorizontalGutter className={styles.root}>
|
||||
<TabBar activeTab={props.activeTab} onTabClick={props.onTabClick}>
|
||||
<CommentsTab tabId="COMMENTS" />
|
||||
<MyProfileTab tabId="PROFILE" />
|
||||
</TabBar>
|
||||
<TabBarQuery />
|
||||
<TabContent activeTab={props.activeTab} className={styles.tabContent}>
|
||||
<TabPane tabId="COMMENTS">
|
||||
<CommentsPaneContainer />
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import * as React from "react";
|
||||
import { StatelessComponent } from "react";
|
||||
import { Tab, TabBar } from "talk-ui/components";
|
||||
|
||||
type TabValue = "COMMENTS" | "PROFILE" | "%future added value";
|
||||
|
||||
export interface Props {
|
||||
activeTab: TabValue;
|
||||
onTabClick: (tab: TabValue) => void;
|
||||
commentCount: number;
|
||||
showProfileTab: boolean;
|
||||
}
|
||||
|
||||
const AppTabBar: StatelessComponent<Props> = props => {
|
||||
return (
|
||||
<TabBar activeTab={props.activeTab} onTabClick={props.onTabClick}>
|
||||
<Tab tabId="COMMENTS">
|
||||
<Localized
|
||||
id="general-tabBar-commentsTab"
|
||||
$commentCount={props.commentCount}
|
||||
>
|
||||
<span>{"{$commentCount} Comments"}</span>
|
||||
</Localized>
|
||||
</Tab>
|
||||
{props.showProfileTab && (
|
||||
<Tab tabId="PROFILE">
|
||||
<Localized id="general-tabBar-myProfileTab">
|
||||
<span>My Profile</span>
|
||||
</Localized>
|
||||
</Tab>
|
||||
)}
|
||||
</TabBar>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppTabBar;
|
||||
@@ -1,20 +1,10 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders comments 1`] = `
|
||||
exports[`renders correctly 1`] = `
|
||||
<withPropsOnChange(HorizontalGutter)
|
||||
className="App-root"
|
||||
>
|
||||
<withPropsOnChange(TabBar)
|
||||
activeTab="COMMENTS"
|
||||
onTabClick={[Function]}
|
||||
>
|
||||
<CommentsTab
|
||||
tabId="COMMENTS"
|
||||
/>
|
||||
<MyProfileTab
|
||||
tabId="PROFILE"
|
||||
/>
|
||||
</withPropsOnChange(TabBar)>
|
||||
<withContext(withLocalStateContainer(TabBarQuery)) />
|
||||
<TabContent
|
||||
activeTab="COMMENTS"
|
||||
className="App-tabContent"
|
||||
|
||||
@@ -2,41 +2,29 @@ import React from "react";
|
||||
|
||||
import { graphql, withLocalStateContainer } from "talk-framework/lib/relay";
|
||||
import { AppContainerLocal as Local } from "talk-stream/__generated__/AppContainerLocal.graphql";
|
||||
import {
|
||||
SetActiveTabInput,
|
||||
SetActiveTabMutation,
|
||||
withSetActiveTabMutation,
|
||||
} from "talk-stream/mutations";
|
||||
|
||||
import App from "../components/App";
|
||||
|
||||
interface InnerProps {
|
||||
local: Local;
|
||||
setActiveTab: SetActiveTabMutation;
|
||||
}
|
||||
|
||||
class AppContainer extends React.Component<InnerProps> {
|
||||
private handleSetActiveTab = (tab: SetActiveTabInput["tab"]) => {
|
||||
this.props.setActiveTab({ tab });
|
||||
};
|
||||
|
||||
public render() {
|
||||
const {
|
||||
local: { activeTab },
|
||||
} = this.props;
|
||||
|
||||
return <App activeTab={activeTab} onTabClick={this.handleSetActiveTab} />;
|
||||
return <App activeTab={activeTab} />;
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withSetActiveTabMutation(
|
||||
withLocalStateContainer(
|
||||
graphql`
|
||||
fragment AppContainerLocal on Local {
|
||||
activeTab
|
||||
}
|
||||
`
|
||||
)(AppContainer)
|
||||
);
|
||||
const enhanced = withLocalStateContainer(
|
||||
graphql`
|
||||
fragment AppContainerLocal on Local {
|
||||
activeTab
|
||||
}
|
||||
`
|
||||
)(AppContainer);
|
||||
|
||||
export default enhanced;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as React from "react";
|
||||
import { Component } from "react";
|
||||
|
||||
import {
|
||||
graphql,
|
||||
withFragmentContainer,
|
||||
withLocalStateContainer,
|
||||
} from "talk-framework/lib/relay";
|
||||
|
||||
import { TabBarContainer_story as StoryData } from "talk-stream/__generated__/TabBarContainer_story.graphql";
|
||||
import { TabBarContainerLocal as Local } from "talk-stream/__generated__/TabBarContainerLocal.graphql";
|
||||
import {
|
||||
SetActiveTabInput,
|
||||
SetActiveTabMutation,
|
||||
withSetActiveTabMutation,
|
||||
} from "talk-stream/mutations";
|
||||
|
||||
import TabBar from "../components/TabBar";
|
||||
|
||||
interface InnerProps {
|
||||
story: StoryData | null;
|
||||
local: Local;
|
||||
setActiveTab: SetActiveTabMutation;
|
||||
}
|
||||
|
||||
export class TabBarContainer extends Component<InnerProps> {
|
||||
private handleSetActiveTab = (tab: SetActiveTabInput["tab"]) => {
|
||||
this.props.setActiveTab({ tab });
|
||||
};
|
||||
|
||||
public render() {
|
||||
const {
|
||||
local: { loggedIn, activeTab },
|
||||
story,
|
||||
} = this.props;
|
||||
|
||||
const commentCount = (story && story.commentCounts.totalVisible) || -1;
|
||||
return (
|
||||
<TabBar
|
||||
activeTab={activeTab}
|
||||
commentCount={commentCount}
|
||||
showProfileTab={loggedIn}
|
||||
onTabClick={this.handleSetActiveTab}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withSetActiveTabMutation(
|
||||
withLocalStateContainer(
|
||||
graphql`
|
||||
fragment TabBarContainerLocal on Local {
|
||||
loggedIn
|
||||
activeTab
|
||||
}
|
||||
`
|
||||
)(
|
||||
withFragmentContainer<InnerProps>({
|
||||
story: graphql`
|
||||
fragment TabBarContainer_story on Story {
|
||||
commentCounts {
|
||||
totalVisible
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(TabBarContainer)
|
||||
)
|
||||
);
|
||||
|
||||
export default enhanced;
|
||||
@@ -13,6 +13,9 @@ exports[`init local state 1`] = `
|
||||
\\"__id\\": \\"client:root.local\\",
|
||||
\\"__typename\\": \\"Local\\",
|
||||
\\"authToken\\": \\"\\",
|
||||
\\"authExp\\": null,
|
||||
\\"authJTI\\": null,
|
||||
\\"loggedIn\\": false,
|
||||
\\"network\\": {
|
||||
\\"__ref\\": \\"client:root.local.network\\"
|
||||
},
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
export const AUTH_POPUP_TYPE = "AuthPopup";
|
||||
export const AUTH_POPUP_ID = "client:root.local.authPopup";
|
||||
|
||||
@@ -4,7 +4,10 @@ import { timeout } from "talk-common/utils";
|
||||
import { TalkContext } from "talk-framework/lib/bootstrap";
|
||||
import { LOCAL_ID } from "talk-framework/lib/relay";
|
||||
import { createPromisifiedStorage } from "talk-framework/lib/storage";
|
||||
import { createRelayEnvironment } from "talk-framework/testHelpers";
|
||||
import {
|
||||
createRelayEnvironment,
|
||||
replaceHistoryLocation,
|
||||
} from "talk-framework/testHelpers";
|
||||
|
||||
import initLocalState from "./initLocalState";
|
||||
|
||||
@@ -33,16 +36,12 @@ it("set storyID from query", async () => {
|
||||
localStorage: createPromisifiedStorage(),
|
||||
};
|
||||
const storyID = "story-id";
|
||||
const previousLocation = location.toString();
|
||||
const previousState = window.history.state;
|
||||
window.history.replaceState(
|
||||
previousState,
|
||||
document.title,
|
||||
const restoreHistoryLocation = replaceHistoryLocation(
|
||||
`http://localhost/?storyID=${storyID}`
|
||||
);
|
||||
await initLocalState(environment, context as any);
|
||||
expect(source.get(LOCAL_ID)!.storyID).toBe(storyID);
|
||||
window.history.replaceState(previousState, document.title, previousLocation);
|
||||
restoreHistoryLocation();
|
||||
});
|
||||
|
||||
it("set commentID from query", async () => {
|
||||
@@ -50,23 +49,29 @@ it("set commentID from query", async () => {
|
||||
localStorage: createPromisifiedStorage(),
|
||||
};
|
||||
const commentID = "comment-id";
|
||||
const previousLocation = location.toString();
|
||||
const previousState = window.history.state;
|
||||
window.history.replaceState(
|
||||
previousState,
|
||||
document.title,
|
||||
const restoreHistoryLocation = replaceHistoryLocation(
|
||||
`http://localhost/?commentID=${commentID}`
|
||||
);
|
||||
await initLocalState(environment, context as any);
|
||||
expect(source.get(LOCAL_ID)!.commentID).toBe(commentID);
|
||||
window.history.replaceState(previousState, document.title, previousLocation);
|
||||
restoreHistoryLocation();
|
||||
});
|
||||
|
||||
it("set authToken from localStorage", async () => {
|
||||
const context: Partial<TalkContext> = {
|
||||
localStorage: createPromisifiedStorage(),
|
||||
};
|
||||
const authToken = "auth-token";
|
||||
const authToken = `${btoa(
|
||||
JSON.stringify({
|
||||
alg: "HS256",
|
||||
typ: "JWT",
|
||||
})
|
||||
)}.${btoa(
|
||||
JSON.stringify({
|
||||
exp: 1540503165,
|
||||
jti: "31b26591-4e9a-4388-a7ff-e1bdc5d97cce",
|
||||
})
|
||||
)}`;
|
||||
context.localStorage!.setItem("authToken", authToken);
|
||||
await initLocalState(environment, context as any);
|
||||
expect(source.get(LOCAL_ID)!.authToken).toBe(authToken);
|
||||
|
||||
@@ -2,38 +2,21 @@ import qs from "query-string";
|
||||
import { commitLocalUpdate, Environment } from "relay-runtime";
|
||||
|
||||
import { TalkContext } from "talk-framework/lib/bootstrap";
|
||||
import {
|
||||
createAndRetain,
|
||||
LOCAL_ID,
|
||||
LOCAL_TYPE,
|
||||
} from "talk-framework/lib/relay";
|
||||
import { createAndRetain, initLocalBaseState } from "talk-framework/lib/relay";
|
||||
|
||||
import {
|
||||
AUTH_POPUP_ID,
|
||||
AUTH_POPUP_TYPE,
|
||||
NETWORK_ID,
|
||||
NETWORK_TYPE,
|
||||
} from "./constants";
|
||||
import { AUTH_POPUP_ID, AUTH_POPUP_TYPE } from "./constants";
|
||||
|
||||
/**
|
||||
* Initializes the local state, before we start the App.
|
||||
*/
|
||||
export default async function initLocalState(
|
||||
environment: Environment,
|
||||
{ localStorage }: TalkContext
|
||||
context: TalkContext
|
||||
) {
|
||||
const authToken = await localStorage!.getItem("authToken");
|
||||
|
||||
await initLocalBaseState(environment, context);
|
||||
commitLocalUpdate(environment, s => {
|
||||
// TODO: (cvle) move local, auth token and network initialization to framework.
|
||||
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);
|
||||
root.setLinkedRecord(localRecord, "local");
|
||||
|
||||
// Set auth token
|
||||
localRecord.setValue(authToken || "", "authToken");
|
||||
const localRecord = root.getLinkedRecord("local")!;
|
||||
|
||||
// Parse query params
|
||||
const query = qs.parse(location.search);
|
||||
@@ -50,16 +33,6 @@ export default async function initLocalState(
|
||||
localRecord.setValue(query.commentID, "commentID");
|
||||
}
|
||||
|
||||
// Create network Record
|
||||
const networkRecord = createAndRetain(
|
||||
environment,
|
||||
s,
|
||||
NETWORK_ID,
|
||||
NETWORK_TYPE
|
||||
);
|
||||
networkRecord.setValue(false, "isOffline");
|
||||
localRecord.setLinkedRecord(networkRecord, "network");
|
||||
|
||||
// Create authPopup Record
|
||||
const authPopupRecord = createAndRetain(
|
||||
environment,
|
||||
|
||||
@@ -29,12 +29,15 @@ extend type Comment {
|
||||
|
||||
type Local {
|
||||
network: Network!
|
||||
authToken: String
|
||||
authExp: Int
|
||||
authJTI: String
|
||||
loggedIn: Boolean!
|
||||
activeTab: Tab!
|
||||
authPopup: AuthPopup!
|
||||
storyID: String
|
||||
storyURL: String
|
||||
commentID: String
|
||||
authPopup: AuthPopup!
|
||||
authToken: String
|
||||
activeTab: Tab!
|
||||
}
|
||||
|
||||
extend type Query {
|
||||
|
||||
@@ -8,11 +8,6 @@ export {
|
||||
EditCommentMutation,
|
||||
EditCommentInput,
|
||||
} from "./EditCommentMutation";
|
||||
export {
|
||||
withSetNetworkStatusMutation,
|
||||
SetNetworkStatusMutation,
|
||||
SetNetworkStatusInput,
|
||||
} from "./SetNetworkStatusMutation";
|
||||
export {
|
||||
withSetCommentIDMutation,
|
||||
SetCommentIDMutation,
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import React, { Component } from "react";
|
||||
import {
|
||||
graphql,
|
||||
QueryRenderer,
|
||||
withLocalStateContainer,
|
||||
} from "talk-framework/lib/relay";
|
||||
import { CommentsCountQuery as QueryTypes } from "talk-stream/__generated__/CommentsCountQuery.graphql";
|
||||
import { CommentsCountQueryLocal as Local } from "talk-stream/__generated__/CommentsCountQueryLocal.graphql";
|
||||
import { Spinner } from "talk-ui/components";
|
||||
import { Tab } from "talk-ui/components";
|
||||
import { PropTypesOf } from "talk-ui/types";
|
||||
import CommentCountTab from "../components/CommentCountTab";
|
||||
|
||||
interface InnerProps extends PropTypesOf<typeof Tab> {
|
||||
local: Local;
|
||||
}
|
||||
|
||||
class CommentsCountQuery extends Component<InnerProps> {
|
||||
public render() {
|
||||
const { storyID, storyURL } = this.props.local;
|
||||
const { local: _, ...rest } = this.props;
|
||||
return (
|
||||
<QueryRenderer<QueryTypes>
|
||||
query={graphql`
|
||||
query CommentsCountQuery($storyID: ID, $storyURL: String) {
|
||||
story(id: $storyID, url: $storyURL) {
|
||||
commentCounts {
|
||||
totalVisible
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
variables={{
|
||||
storyID,
|
||||
storyURL,
|
||||
}}
|
||||
render={({ error, props }) => {
|
||||
if (error) {
|
||||
return <div>{error.message}</div>;
|
||||
}
|
||||
|
||||
if (props && props.story) {
|
||||
return (
|
||||
<CommentCountTab
|
||||
commentCount={props.story.commentCounts.totalVisible}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Spinner />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withLocalStateContainer(
|
||||
graphql`
|
||||
fragment CommentsCountQueryLocal on Local {
|
||||
storyID
|
||||
storyURL
|
||||
}
|
||||
`
|
||||
)(CommentsCountQuery);
|
||||
|
||||
export default enhanced;
|
||||
@@ -1,34 +0,0 @@
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { graphql, QueryRenderer } from "talk-framework/lib/relay";
|
||||
import { IfLoggedInQuery as QueryTypes } from "talk-stream/__generated__/IfLoggedInQuery.graphql";
|
||||
|
||||
class IfLoggedInContainer extends Component {
|
||||
public render() {
|
||||
return (
|
||||
<QueryRenderer<QueryTypes>
|
||||
query={graphql`
|
||||
query IfLoggedInQuery {
|
||||
me {
|
||||
id
|
||||
}
|
||||
}
|
||||
`}
|
||||
render={({ error, props }) => {
|
||||
if (error) {
|
||||
return <div>{error.message}</div>;
|
||||
}
|
||||
|
||||
if (props && props.me) {
|
||||
return <>{this.props.children}</>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
variables={{}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default IfLoggedInContainer;
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, { Component } from "react";
|
||||
import {
|
||||
graphql,
|
||||
QueryRenderer,
|
||||
withLocalStateContainer,
|
||||
} from "talk-framework/lib/relay";
|
||||
|
||||
import { TabBarQuery as QueryTypes } from "talk-stream/__generated__/TabBarQuery.graphql";
|
||||
import { TabBarQueryLocal as Local } from "talk-stream/__generated__/TabBarQueryLocal.graphql";
|
||||
|
||||
import TabBarContainer from "../containers/TabBarContainer";
|
||||
|
||||
interface InnerProps {
|
||||
local: Local;
|
||||
}
|
||||
|
||||
class TabBarQuery extends Component<InnerProps> {
|
||||
public render() {
|
||||
const { storyID, storyURL } = this.props.local;
|
||||
return (
|
||||
<QueryRenderer<QueryTypes>
|
||||
query={graphql`
|
||||
query TabBarQuery($storyID: ID, $storyURL: String) {
|
||||
story(id: $storyID, url: $storyURL) {
|
||||
...TabBarContainer_story
|
||||
}
|
||||
}
|
||||
`}
|
||||
variables={{
|
||||
storyID,
|
||||
storyURL,
|
||||
}}
|
||||
render={({ error, props }) => {
|
||||
if (error) {
|
||||
return <div>{error.message}</div>;
|
||||
}
|
||||
|
||||
return <TabBarContainer story={(props && props.story) || null} />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withLocalStateContainer(
|
||||
graphql`
|
||||
fragment TabBarQueryLocal on Local {
|
||||
storyID
|
||||
storyURL
|
||||
}
|
||||
`
|
||||
)(TabBarQuery);
|
||||
|
||||
export default enhanced;
|
||||
@@ -27,7 +27,9 @@ exports[`cancel edit: edit canceled 1`] = `
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
2 Comments
|
||||
<span>
|
||||
2 Comments
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li
|
||||
@@ -49,7 +51,9 @@ exports[`cancel edit: edit canceled 1`] = `
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
My Profile
|
||||
<span>
|
||||
My Profile
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -540,7 +544,9 @@ exports[`edit a comment: edit form 1`] = `
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
2 Comments
|
||||
<span>
|
||||
2 Comments
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li
|
||||
@@ -562,7 +568,9 @@ exports[`edit a comment: edit form 1`] = `
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
My Profile
|
||||
<span>
|
||||
My Profile
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -1076,7 +1084,9 @@ exports[`edit a comment: optimistic response 1`] = `
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
2 Comments
|
||||
<span>
|
||||
2 Comments
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li
|
||||
@@ -1098,7 +1108,9 @@ exports[`edit a comment: optimistic response 1`] = `
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
My Profile
|
||||
<span>
|
||||
My Profile
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -1612,7 +1624,9 @@ exports[`edit a comment: render stream 1`] = `
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
2 Comments
|
||||
<span>
|
||||
2 Comments
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li
|
||||
@@ -1634,7 +1648,9 @@ exports[`edit a comment: render stream 1`] = `
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
My Profile
|
||||
<span>
|
||||
My Profile
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -2125,7 +2141,9 @@ exports[`edit a comment: server response 1`] = `
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
2 Comments
|
||||
<span>
|
||||
2 Comments
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li
|
||||
@@ -2147,7 +2165,9 @@ exports[`edit a comment: server response 1`] = `
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
My Profile
|
||||
<span>
|
||||
My Profile
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -2647,7 +2667,9 @@ exports[`shows expiry message: edit form closed 1`] = `
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
2 Comments
|
||||
<span>
|
||||
2 Comments
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li
|
||||
@@ -2669,7 +2691,9 @@ exports[`shows expiry message: edit form closed 1`] = `
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
My Profile
|
||||
<span>
|
||||
My Profile
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -3160,7 +3184,9 @@ exports[`shows expiry message: edit time expired 1`] = `
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
2 Comments
|
||||
<span>
|
||||
2 Comments
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li
|
||||
@@ -3182,7 +3208,9 @@ exports[`shows expiry message: edit time expired 1`] = `
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
My Profile
|
||||
<span>
|
||||
My Profile
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -27,7 +27,9 @@ exports[`loads more comments 1`] = `
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
2 Comments
|
||||
<span>
|
||||
2 Comments
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -589,7 +591,9 @@ exports[`renders comment stream 1`] = `
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
2 Comments
|
||||
<span>
|
||||
2 Comments
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user