Merge pull request #1741 from coralproject/permalink

[next] Permalink
This commit is contained in:
Kiwi
2018-08-06 22:40:50 +02:00
committed by GitHub
94 changed files with 2165 additions and 555 deletions
+391 -190
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -103,6 +103,7 @@
"@types/passport-oauth2": "^1.4.5",
"@types/passport-strategy": "^0.2.33",
"@types/query-string": "^6.1.0",
"@types/react-copy-to-clipboard": "^4.2.5",
"@types/react-dom": "^16.0.6",
"@types/react-relay": "github:coralproject/patched#types/react-relay",
"@types/react-responsive": "^3.0.1",
@@ -168,9 +169,11 @@
"query-string": "^6.1.0",
"raw-loader": "^0.5.1",
"react": "^16.4.0",
"react-copy-to-clipboard": "^5.0.1",
"react-dev-utils": "6.0.0-next.3e165448",
"react-dom": "^16.4.0",
"react-final-form": "^3.6.4",
"react-popper": "^1.0.0-beta.6",
"react-relay": "github:coralproject/patched#react-relay",
"react-responsive": "^4.1.0",
"react-test-renderer": "^16.4.1",
@@ -199,7 +202,7 @@
"typed-css-modules": "^0.3.4",
"typeface-manuale": "0.0.54",
"typeface-source-sans-pro": "0.0.54",
"typescript": "^2.9.2",
"typescript": "^3.0.0",
"uglifyjs-webpack-plugin": "^1.2.5",
"webpack": "4.12.0",
"webpack-cli": "^3.0.2",
+5 -2
View File
@@ -5,9 +5,9 @@ import {
Decorator,
withAutoHeight,
withClickEvent,
withCommentID,
withEventEmitter,
withIOSSafariWidthWorkaround,
withSetCommentID,
} from "./decorators";
import PymControl from "./PymControl";
import { ensureEndSlash } from "./utils";
@@ -15,6 +15,7 @@ import { ensureEndSlash } from "./utils";
interface CreatePymControlConfig {
assetID?: string;
assetURL?: string;
commentID?: string;
title?: string;
eventEmitter: EventEmitter2;
id: string;
@@ -26,13 +27,14 @@ export function createPymControl(config: CreatePymControlConfig) {
withIOSSafariWidthWorkaround,
withAutoHeight,
withClickEvent,
withCommentID,
withSetCommentID,
withEventEmitter(config.eventEmitter),
];
const query = qs.stringify({
assetID: config.assetID,
assetURL: config.assetURL,
commentID: config.commentID,
});
const url = `${ensureEndSlash(config.rootURL)}stream.html?${query}`;
return new PymControl({
@@ -73,6 +75,7 @@ export type StreamInterface = ReturnType<typeof createStreamInterface>;
export interface CreateConfig {
assetID?: string;
assetURL?: string;
commentID?: string;
title?: string;
eventEmitter: EventEmitter2;
id: string;
+1 -1
View File
@@ -4,7 +4,7 @@ export type CleanupCallback = () => void;
export type Decorator = (pym: pym.Parent) => CleanupCallback | void;
export { default as withAutoHeight } from "./withAutoHeight";
export { default as withClickEvent } from "./withClickEvent";
export { default as withCommentID } from "./withCommentID";
export { default as withSetCommentID } from "./withSetCommentID";
export { default as withEventEmitter } from "./withEventEmitter";
export {
default as withIOSSafariWidthWorkaround,
@@ -1,36 +0,0 @@
import qs from "query-string";
import { buildURL } from "../utils";
import { Decorator } from "./";
const withCommentID: Decorator = pym => {
// Remove the comment id from the query.
pym.onMessage("view-all-comments", () => {
const search = qs.stringify({
...qs.parse(location.search),
commentId: undefined,
});
// Remove the commentId url param.
const url = buildURL({ search });
// Change the url.
window.history.replaceState({}, document.title, url);
});
// Add the permalink comment id to the query.
pym.onMessage("view-comment", (id: string) => {
const search = qs.stringify({
...qs.parse(location.search),
commentId: id,
});
// Remove the commentId url param.
const url = buildURL({ search });
// Change the url.
window.history.replaceState({}, document.title, url);
});
};
export default withCommentID;
@@ -1,17 +1,17 @@
import withCommentID from "./withCommentID";
import withSetCommentID from "./withSetCommentID";
it("should add commentID", () => {
const previousLocation = location.toString();
const previousState = window.history.state;
const fakePym = {
onMessage: (type: string, callback: (id: string) => void) => {
if (type === "view-comment") {
if (type === "setCommentID") {
callback("comment-id");
}
},
};
withCommentID(fakePym as any);
expect(location.toString()).toBe("http://localhost/?commentId=comment-id");
withSetCommentID(fakePym as any);
expect(location.toString()).toBe("http://localhost/?commentID=comment-id");
window.history.replaceState(previousState, document.title, previousLocation);
});
@@ -21,16 +21,16 @@ it("should remove commentID", () => {
window.history.replaceState(
previousState,
document.title,
"http://localhost/?commentId=comment-id"
"http://localhost/?commentID=comment-id"
);
const fakePym = {
onMessage: (type: string, callback: () => void) => {
if (type === "view-all-comments") {
if (type === "setCommentID") {
callback();
}
},
};
withCommentID(fakePym as any);
withSetCommentID(fakePym as any);
expect(location.toString()).toBe("http://localhost/");
window.history.replaceState(previousState, document.title, previousLocation);
});
@@ -0,0 +1,22 @@
import qs from "query-string";
import { buildURL } from "../utils";
import { Decorator } from "./";
const withSetCommentID: Decorator = pym => {
// Add the permalink comment id to the query.
pym.onMessage("setCommentID", (id: string) => {
const search = qs.stringify({
...qs.parse(location.search),
commentID: id || undefined,
});
// Remove the commentId url param.
const url = buildURL({ search });
// Change the url.
window.history.replaceState({}, document.title, url);
});
};
export default withSetCommentID;
+1 -1
View File
@@ -10,7 +10,7 @@
<body>
<h1 style="text-align: center" }>Talk 5.0 Embed Stream</h1>
<div id="coralStreamEmbed"></div>
<div id="coralStreamEmbed" style="max-width: 600px; margin: 0 auto"></div>
<script>
window.TalkEmbed = Talk.render(document.getElementById('coralStreamEmbed'));
</script>
+2
View File
@@ -6,6 +6,7 @@ import createStreamInterface from "./Stream";
export interface Config {
assetID?: string;
assetURL?: string;
commentID?: string;
rootURL?: string;
id?: string;
events?: (eventEmitter: EventEmitter2) => void;
@@ -23,6 +24,7 @@ export function render(config: Config = {}) {
return createStreamInterface({
assetID: config.assetID || query.assetID,
assetURL: config.assetURL || query.assetURL,
commentID: config.commentID || query.commentID,
id: config.id || "talk-embed-stream",
rootURL: config.rootURL || location.origin,
eventEmitter,
@@ -7,7 +7,7 @@ import {
} from "recompose";
import { Environment } from "relay-runtime";
import { withContext } from "../bootstrap";
import { TalkContext, withContext } from "../bootstrap";
/**
* createMutationContainer creates a HOC that
@@ -18,10 +18,14 @@ import { withContext } from "../bootstrap";
*/
function createMutationContainer<T extends string, I, R>(
propName: T,
commit: (environment: Environment, input: I) => Promise<R>
commit: (
environment: Environment,
input: I,
context: TalkContext
) => Promise<R>
): InferableComponentEnhancer<{ [P in T]: (input: I) => Promise<R> }> {
return compose(
withContext(({ relayEnvironment }) => ({ relayEnvironment })),
withContext(context => ({ context })),
hoistStatics((BaseComponent: React.ComponentType<any>) => {
class CreateMutationContainer extends React.Component<any> {
public static displayName = wrapDisplayName(
@@ -30,7 +34,11 @@ function createMutationContainer<T extends string, I, R>(
);
private commit = (input: I) => {
return commit(this.props.relayEnvironment, input);
return commit(
this.props.context.relayEnvironment,
input,
this.props.context
);
};
public render() {
@@ -1,8 +1,14 @@
import * as React from "react";
import { compose, hoistStatics, InferableComponentEnhancer } from "recompose";
import {
compose,
hoistStatics,
InferableComponentEnhancer,
wrapDisplayName,
} from "recompose";
import {
CSelector,
CSnapshot,
Disposable,
Environment,
GraphQLTaggedNode,
} from "relay-runtime";
@@ -36,6 +42,12 @@ function withLocalStateContainer<T>(
withContext(({ relayEnvironment }) => ({ relayEnvironment })),
hoistStatics((BaseComponent: React.ComponentType<any>) => {
class LocalStateContainer extends React.Component<Props, any> {
public static displayName = wrapDisplayName(
BaseComponent,
"withLocalStateContainer"
);
private subscription: Disposable;
constructor(props: Props) {
super(props);
const fragment = (fragmentSpec as any).data().default;
@@ -53,7 +65,10 @@ function withLocalStateContainer<T>(
variables: {},
};
const snapshot = props.relayEnvironment.lookup(selector);
props.relayEnvironment.subscribe(snapshot, this.updateSnapshot);
this.subscription = props.relayEnvironment.subscribe(
snapshot,
this.updateSnapshot
);
this.state = {
data: snapshot.data,
};
@@ -63,6 +78,10 @@ function withLocalStateContainer<T>(
this.setState({ data: snapshot.data });
};
public componentWillUnmount() {
this.subscription.dispose();
}
public render() {
const { relayEnvironment: _, ...rest } = this.props;
return <BaseComponent {...rest} local={this.state.data} />;
@@ -0,0 +1,78 @@
import { IResolvers } from "graphql-tools";
import { createFetch } from "relay-local-schema";
import {
commitLocalUpdate,
Environment,
Network,
RecordProxy,
RecordSource,
RecordSourceProxy,
Store,
} from "relay-runtime";
import {
createAndRetain,
LOCAL_ID,
LOCAL_TYPE,
wrapFetchWithLogger,
} from "talk-framework/lib/relay";
import { loadSchema } from "talk-common/graphql";
export interface CreateRelayEnvironmentNetworkParams {
/** project name of graphql-config */
projectName: string;
/** graphql resolvers */
resolvers: IResolvers<any, any>;
/** If enabled, graphql responses will be logged to the console */
logNetwork?: boolean;
}
export interface CreateRelayEnvironmentParams {
/** If set, creates a network to a local graphql server with a local schema */
network?: CreateRelayEnvironmentNetworkParams;
/** Allows to set initial state for Local state */
initLocalState?: (
local: RecordProxy,
source: RecordSourceProxy,
environment: Environment
) => void;
/** Use this source for creating the environment */
source?: RecordSource;
}
/**
* create Relay environment for tests environments.
*/
export default function createRelayEnvironment(
params: CreateRelayEnvironmentParams = {}
) {
let network: Network = null as any;
if (params.network) {
const schema = loadSchema(
params.network.projectName,
params.network.resolvers
);
network = Network.create(
wrapFetchWithLogger(createFetch({ schema }), params.network.logNetwork)
);
}
const environment = new Environment({
network,
store: new Store(params.source || new RecordSource()),
});
commitLocalUpdate(environment, sourceProxy => {
const root = sourceProxy.getRoot();
const localRecord = createAndRetain(
environment,
sourceProxy,
LOCAL_ID,
LOCAL_TYPE
);
root.setLinkedRecord(localRecord, "local");
if (params.initLocalState) {
params.initLocalState!(localRecord, sourceProxy, environment);
}
});
return environment;
}
@@ -0,0 +1,4 @@
export {
default as createRelayEnvironment,
CreateRelayEnvironmentParams,
} from "./createRelayEnvironment";
@@ -11,4 +11,5 @@
}
.root {
width: 100%;
}
@@ -5,17 +5,17 @@ import { PropTypesOf } from "talk-framework/types";
import App from "./App";
it("renders correctly", () => {
it("renders stream", () => {
const props: PropTypesOf<typeof App> = {
asset: {},
showPermalinkView: false,
};
const wrapper = shallow(<App {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("renders correctly when asset is null", () => {
it("renders permalink view", () => {
const props: PropTypesOf<typeof App> = {
asset: null,
showPermalinkView: true,
};
const wrapper = shallow(<App {...props} />);
expect(wrapper).toMatchSnapshot();
+13 -10
View File
@@ -3,23 +3,26 @@ import { StatelessComponent } from "react";
import { Flex } from "talk-ui/components";
import StreamContainer from "../containers/StreamContainer";
import PermalinkViewQuery from "../queries/PermalinkViewQuery";
import StreamQuery from "../queries/StreamQuery";
import * as styles from "./App.css";
export interface AppProps {
asset: {} | null;
showPermalinkView: boolean;
}
const App: StatelessComponent<AppProps> = props => {
if (props.asset) {
return (
<Flex justifyContent="center" className={styles.root}>
<StreamContainer asset={props.asset} />
</Flex>
);
}
return <div>Asset not found </div>;
const view = props.showPermalinkView ? (
<PermalinkViewQuery />
) : (
<StreamQuery />
);
return (
<Flex justifyContent="center" className={styles.root}>
{view}
</Flex>
);
};
export default App;
@@ -0,0 +1,3 @@
.footer {
margin-top: var(--spacing-unit);
}
@@ -7,6 +7,7 @@ import Comment from "./Comment";
it("renders username and body", () => {
const props: PropTypesOf<typeof Comment> = {
id: "comment-id",
author: {
username: "Marvin",
},
@@ -1,13 +1,16 @@
import React from "react";
import { StatelessComponent } from "react";
import { Typography } from "talk-ui/components";
import * as styles from "./Comment.css";
import PermalinkButtonContainer from "../../containers/PermalinkButtonContainer";
import Timestamp from "./Timestamp";
import TopBar from "./TopBar";
import Username from "./Username";
export interface CommentProps {
id: string;
className?: string;
author: {
username: string | null;
} | null;
@@ -24,6 +27,9 @@ const Comment: StatelessComponent<CommentProps> = props => {
<Timestamp>{props.createdAt}</Timestamp>
</TopBar>
<Typography>{props.body}</Typography>
<div className={styles.footer}>
<PermalinkButtonContainer commentID={props.id} />
</div>
</div>
);
};
@@ -15,5 +15,12 @@ exports[`renders username and body 1`] = `
<withPropsOnChange(Typography)>
Woof
</withPropsOnChange(Typography)>
<div
className="Comment-footer"
>
<withContext(withLocalStateContainer(PermalinkContainer))
commentID="comment-id"
/>
</div>
</div>
`;
@@ -1,22 +0,0 @@
import { Localized } from "fluent-react/compat";
import * as React from "react";
import { StatelessComponent } from "react";
import { Typography } from "talk-ui/components";
export interface LogoProps {
className?: string;
gutterBottom?: boolean;
}
const Logo: StatelessComponent<LogoProps> = props => {
return (
<Localized id="stream-logo">
<Typography variant="heading1" gutterBottom={props.gutterBottom}>
Talk NEO
</Typography>
</Localized>
);
};
export default Logo;
@@ -0,0 +1,4 @@
.popover {
width: 350px;
max-width: 80%;
}
@@ -0,0 +1,73 @@
import { Localized } from "fluent-react/compat";
import React from "react";
import { oncePerFrame } from "talk-common/utils";
import {
Button,
ButtonIcon,
ClickOutside,
MatchMedia,
Popover,
} from "talk-ui/components";
import * as styles from "./PermalinkButton.css";
import PermalinkPopover from "./PermalinkPopover";
interface PermalinkProps {
commentID: string;
assetURL: string | null;
}
class Permalink extends React.Component<PermalinkProps> {
// Helper that prevents calling toggleVisibility more then once per frame.
// In essence this means we'll process an event only once.
// This might happen, when clicking on the button which will
// cause its onClick to happen as well as onClickOutside.
private toggleVisibilityOncePerFrame = oncePerFrame(
(toggleVisibility: () => void) => toggleVisibility()
);
public render() {
const { commentID, assetURL } = this.props;
const popoverID = "permalink-popover";
return (
<Popover
id={popoverID}
placement="top-start"
description="A dialog showing a permalink to the comment"
className={styles.popover}
body={({ toggleVisibility }) => (
<ClickOutside
onClickOutside={() =>
this.toggleVisibilityOncePerFrame(toggleVisibility)
}
>
<PermalinkPopover
permalinkURL={`${assetURL}&commentID=${commentID}`}
toggleVisibility={toggleVisibility}
/>
</ClickOutside>
)}
>
{({ toggleVisibility, forwardRef, visible }) => (
<Button
onClick={() => this.toggleVisibilityOncePerFrame(toggleVisibility)}
aria-controls={popoverID}
forwardRef={forwardRef}
variant="ghost"
active={visible}
size="small"
>
<MatchMedia minWidth="xs">
<ButtonIcon>share</ButtonIcon>
</MatchMedia>
<Localized id="comments-permalink-share">
<span>Share</span>
</Localized>
</Button>
)}
</Popover>
);
}
}
export default Permalink;
@@ -0,0 +1,7 @@
.root {
width: 100%;
}
.textField {
flex-grow: 1;
}
@@ -0,0 +1,60 @@
import { Localized } from "fluent-react/compat";
import React from "react";
import CopyToClipboard from "react-copy-to-clipboard";
import { Button, Flex, TextField } from "talk-ui/components";
import * as styles from "./PermalinkPopover.css";
interface InnerProps {
permalinkURL: string;
toggleVisibility: () => void;
}
interface State {
copied: boolean;
}
class PermalinkPopover extends React.Component<InnerProps> {
public state: State = {
copied: false,
};
private onCopy = async () => {
await this.toggleCopied();
setTimeout(() => {
this.toggleCopied();
}, 800);
};
private toggleCopied = () => {
this.setState((state: State) => ({
copied: !state.copied,
}));
};
public render() {
const { permalinkURL } = this.props;
const { copied } = this.state;
return (
<Flex itemGutter="half" className={styles.root}>
<TextField defaultValue={permalinkURL} className={styles.textField} />
<CopyToClipboard text={permalinkURL} onCopy={this.onCopy}>
<Button color="primary" variant="filled" size="small">
{copied ? (
<Localized id="comments-permalink-copied">
<span>Copied!</span>
</Localized>
) : (
<Localized id="comments-permalink-copy">
<span>Copy</span>
</Localized>
)}
</Button>
</CopyToClipboard>
</Flex>
);
}
}
export default PermalinkPopover;
@@ -0,0 +1 @@
export { default } from "./PermalinkButton";
@@ -0,0 +1,7 @@
.root {
width: 100%;
}
.button {
margin-bottom: calc(2 * var(--spacing-unit));
}
@@ -0,0 +1,43 @@
import React, { StatelessComponent } from "react";
import { Button, Flex, Typography } from "talk-ui/components";
import CommentContainer from "../containers/CommentContainer";
import * as styles from "./PermalinkView.css";
export interface PermalinkViewProps {
comment: {} | null;
assetURL: string | null;
onShowAllComments: () => void;
}
const PermalinkView: StatelessComponent<PermalinkViewProps> = ({
assetURL,
comment,
onShowAllComments,
}) => {
return (
<div className={styles.root}>
{assetURL && (
<Button
id="talk-comments-permalinkView-showAllComments"
variant="outlined"
color="primary"
onClick={onShowAllComments}
className={styles.button}
fullWidth
>
Show all Comments
</Button>
)}
{!comment && <Typography>Comment not found</Typography>}
{comment && (
<Flex direction="column">
<CommentContainer data={comment} />
</Flex>
)}
</div>
);
};
export default PermalinkView;
@@ -1,4 +1,3 @@
.root {
width: 100%;
max-width: 400px;
}
+4 -6
View File
@@ -7,22 +7,20 @@ import { Button, Flex } from "talk-ui/components";
import CommentContainer from "../containers/CommentContainer";
import PostCommentFormContainer from "../containers/PostCommentFormContainer";
import ReplyListContainer from "../containers/ReplyListContainer";
import Logo from "./Logo";
import * as styles from "./Stream.css";
export interface StreamProps {
assetID: string;
isClosed: boolean;
isClosed?: boolean;
comments: ReadonlyArray<{ id: string }>;
onLoadMore: () => void;
hasMore: boolean;
disableLoadMore: boolean;
onLoadMore?: () => void;
hasMore?: boolean;
disableLoadMore?: boolean;
}
const Stream: StatelessComponent<StreamProps> = props => {
return (
<div className={styles.root}>
<Logo gutterBottom />
<PostCommentFormContainer assetID={props.assetID} />
<Flex
direction="column"
@@ -1,18 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
exports[`renders permalink view 1`] = `
<withPropsOnChange(Flex)
className="App-root"
justifyContent="center"
>
<Relay(StreamContainer)
asset={Object {}}
/>
<withContext(withLocalStateContainer(PermalinkViewQuery)) />
</withPropsOnChange(Flex)>
`;
exports[`renders correctly when asset is null 1`] = `
<div>
Asset not found
</div>
exports[`renders stream 1`] = `
<withPropsOnChange(Flex)
className="App-root"
justifyContent="center"
>
<withContext(withLocalStateContainer(StreamQuery)) />
</withPropsOnChange(Flex)>
`;
@@ -4,9 +4,6 @@ exports[`renders correctly 1`] = `
<div
className="Stream-root"
>
<Logo
gutterBottom={true}
/>
<withContext(createMutationContainer(PostCommentFormContainer))
assetID="asset-id"
/>
@@ -65,9 +62,6 @@ exports[`when there is more disables load more button 1`] = `
<div
className="Stream-root"
>
<Logo
gutterBottom={true}
/>
<withContext(createMutationContainer(PostCommentFormContainer))
assetID="asset-id"
/>
@@ -140,9 +134,6 @@ exports[`when there is more renders a load more button 1`] = `
<div
className="Stream-root"
>
<Logo
gutterBottom={true}
/>
<withContext(createMutationContainer(PostCommentFormContainer))
assetID="asset-id"
/>
@@ -1,16 +0,0 @@
import { shallow } from "enzyme";
import React from "react";
import { PropTypesOf } from "talk-framework/types";
import { AppContainer } from "./AppContainer";
it("renders correctly", () => {
const props: PropTypesOf<typeof AppContainer> = {
data: {
asset: {},
},
};
const wrapper = shallow(<AppContainer {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -1,30 +1,27 @@
import React, { StatelessComponent } from "react";
import { graphql } from "react-relay";
import * as React from "react";
import { StatelessComponent } from "react";
import { withFragmentContainer } from "talk-framework/lib/relay";
import { PropTypesOf } from "talk-framework/types";
import { AppContainer as Data } from "talk-stream/__generated__/AppContainer.graphql";
import { graphql, withLocalStateContainer } from "talk-framework/lib/relay";
import { AppContainerLocal as Local } from "talk-stream/__generated__/AppContainerLocal.graphql";
import App from "../components/App";
interface InnerProps {
data: Data;
local: Local;
}
export const AppContainer: StatelessComponent<InnerProps> = props => {
return <App {...props.data} />;
const AppContainer: StatelessComponent<InnerProps> = ({
local: { commentID },
}) => {
return <App showPermalinkView={!!commentID} />;
};
const enhanced = withFragmentContainer<{ data: Data }>({
data: graphql`
fragment AppContainer on Query
@argumentDefinitions(assetID: { type: "ID!" }) {
asset(id: $assetID) {
...StreamContainer_asset
}
const enhanced = withLocalStateContainer<Local>(
graphql`
fragment AppContainerLocal on Local {
commentID
}
`,
})(AppContainer);
`
)(AppContainer);
export type AppContainerProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -8,6 +8,7 @@ import { CommentContainer } from "./CommentContainer";
it("renders username and body", () => {
const props: PropTypesOf<typeof CommentContainer> = {
data: {
id: "comment-id",
author: {
username: "Marvin",
},
@@ -23,6 +24,7 @@ it("renders username and body", () => {
it("renders body only", () => {
const props: PropTypesOf<typeof CommentContainer> = {
data: {
id: "comment-id",
author: {
username: null,
},
@@ -2,16 +2,19 @@ import React, { StatelessComponent } from "react";
import { graphql } from "react-relay";
import withFragmentContainer from "talk-framework/lib/relay/withFragmentContainer";
import { Omit, PropTypesOf } from "talk-framework/types";
import { PropTypesOf } from "talk-framework/types";
import { CommentContainer as Data } from "talk-stream/__generated__/CommentContainer.graphql";
import Comment, { CommentProps } from "../components/Comment";
import Comment from "../components/Comment";
type InnerProps = { data: Data } & Omit<CommentProps, keyof Data>;
interface InnerProps {
data: Data;
}
// tslint:disable-next-line:no-unused-expression
graphql`
fragment CommentContainer_comment on Comment {
id
author {
username
}
@@ -0,0 +1,30 @@
import React, { StatelessComponent } from "react";
import { graphql } from "react-relay";
import { withLocalStateContainer } from "talk-framework/lib/relay";
import { PermalinkButtonContainerLocal as Local } from "talk-stream/__generated__/PermalinkButtonContainerLocal.graphql";
import PermalinkButton from "../components/PermalinkButton";
interface InnerProps {
local: Local;
commentID: string;
}
export const PermalinkContainer: StatelessComponent<InnerProps> = ({
local,
commentID,
}) => {
return local.assetURL ? (
<PermalinkButton assetURL={local.assetURL} commentID={commentID} />
) : null;
};
const enhanced = withLocalStateContainer<Local>(
graphql`
fragment PermalinkButtonContainerLocal on Local {
assetURL
}
`
)(PermalinkContainer);
export default enhanced;
@@ -0,0 +1,54 @@
import React from "react";
import { graphql } from "react-relay";
import { withFragmentContainer } from "talk-framework/lib/relay";
import { PermalinkViewContainer_asset as AssetData } from "talk-stream/__generated__/PermalinkViewContainer_asset.graphql";
import { PermalinkViewContainer_comment as CommentData } from "talk-stream/__generated__/PermalinkViewContainer_comment.graphql";
import {
SetCommentIDMutation,
withSetCommentIDMutation,
} from "talk-stream/mutations";
import PermalinkView from "../components/PermalinkView";
interface PermalinkViewContainerProps {
comment: CommentData | null;
asset: AssetData | null;
setCommentID: SetCommentIDMutation;
}
class PermalinkViewContainer extends React.Component<
PermalinkViewContainerProps
> {
private showAllComments = () => {
this.props.setCommentID({ id: null });
};
public render() {
const { comment, asset } = this.props;
return (
<PermalinkView
comment={comment}
assetURL={(asset && asset.url) || null}
onShowAllComments={this.showAllComments}
/>
);
}
}
const enhanced = withSetCommentIDMutation(
withFragmentContainer<{
comment: CommentData | null;
asset: AssetData | null;
}>({
comment: graphql`
fragment PermalinkViewContainer_comment on Comment {
...CommentContainer
}
`,
asset: graphql`
fragment PermalinkViewContainer_asset on Asset {
url
}
`,
})(PermalinkViewContainer)
);
export default enhanced;
@@ -1,7 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<App
asset={Object {}}
/>
`;
@@ -9,6 +9,7 @@ exports[`renders body only 1`] = `
}
body="Woof"
createdAt="1995-12-17T03:24:00.000Z"
id="comment-id"
/>
`;
@@ -21,5 +22,6 @@ exports[`renders username and body 1`] = `
}
body="Woof"
createdAt="1995-12-17T03:24:00.000Z"
id="comment-id"
/>
`;
+2 -2
View File
@@ -9,9 +9,9 @@ import {
TalkContextProvider,
} from "talk-framework/lib/bootstrap";
import AppContainer from "./containers/AppContainer";
import { initLocalState } from "./local";
import localesData from "./locales";
import AppQuery from "./queries/AppQuery";
// This is called when the context is first initialized.
async function init({ relayEnvironment }: TalkContext) {
@@ -29,7 +29,7 @@ async function main() {
const Index: StatelessComponent = () => (
<TalkContextProvider value={context}>
<AppQuery />
<AppContainer />
</TalkContextProvider>
);
@@ -21,10 +21,23 @@ export default async function initLocalState(environment: Environment) {
// Parse query params
const query = qs.parse(location.search);
if (query.assetID) {
localRecord.setValue(query.assetID, "assetID");
}
// Saving location host for permalink until we get the asset url - the url now points to the tenant
if (location && query.assetID) {
localRecord.setValue(
`${location.origin}/?assetID=${query.assetID}`,
"assetURL"
);
}
if (query.commentID) {
localRecord.setValue(query.commentID, "commentID");
}
// Create network Record
const networkRecord = createAndRetain(
environment,
@@ -6,6 +6,8 @@ type Network {
type Local {
network: Network!
assetID: String
commentID: String
assetURL: String
}
extend type Query {
@@ -0,0 +1,54 @@
import { Environment, RecordSource } from "relay-runtime";
import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { LOCAL_ID } from "talk-framework/lib/relay";
import { createRelayEnvironment } from "talk-framework/testHelpers";
import { commit } from "./SetCommentIDMutation";
let environment: Environment;
const source: RecordSource = new RecordSource();
beforeAll(() => {
environment = createRelayEnvironment({
source,
});
});
it("Sets comment id", () => {
const id = "comment1-id";
commit(environment, { id }, {} as any);
expect(source.get(LOCAL_ID)!.commentID).toEqual(id);
});
it("Should call setCommentID in pym", async () => {
const id = "comment2-id";
const context = {
pym: {
sendMessage: sinon
.mock()
.once()
.withArgs("setCommentID", id),
},
};
commit(environment, { id }, context as any);
await timeout();
expect(source.get(LOCAL_ID)!.commentID).toEqual(id);
context.pym.sendMessage.verify();
});
it("Should call setCommentID in pym with empty id", async () => {
const context = {
pym: {
sendMessage: sinon
.mock()
.once()
.withArgs("setCommentID", ""),
},
};
commit(environment, { id: null }, context as any);
await timeout();
expect(source.get(LOCAL_ID)!.commentID).toEqual(null);
context.pym.sendMessage.verify();
});
@@ -0,0 +1,31 @@
import { commitLocalUpdate, Environment } from "relay-runtime";
import { TalkContext } from "talk-framework/lib/bootstrap";
import { createMutationContainer } from "talk-framework/lib/relay";
import { LOCAL_ID } from "talk-framework/lib/relay/withLocalStateContainer";
export interface SetCommentIDInput {
id: string | null;
}
export type SetCommentIDMutation = (input: SetCommentIDInput) => Promise<void>;
export async function commit(
environment: Environment,
input: SetCommentIDInput,
{ pym }: TalkContext
) {
return commitLocalUpdate(environment, store => {
const record = store.get(LOCAL_ID)!;
record.setValue(input.id, "commentID");
if (pym) {
// This sets the comment id on the parent url.
pym.sendMessage("setCommentID", input.id || "");
}
});
}
export const withSetCommentIDMutation = createMutationContainer(
"setCommentID",
commit
);
@@ -0,0 +1,26 @@
import { Environment, RecordSource } from "relay-runtime";
import { createRelayEnvironment } from "talk-framework/testHelpers";
import { NETWORK_ID, NETWORK_TYPE } from "../local";
import { commit } from "./SetNetworkStatusMutation";
let environment: Environment;
const source: RecordSource = new RecordSource();
beforeAll(() => {
environment = createRelayEnvironment({
source,
initLocalState: (localRecord, sourceProxy) => {
const networkRecord = sourceProxy.create(NETWORK_ID, NETWORK_TYPE);
networkRecord.setValue(false, "isOffline");
localRecord.setLinkedRecord(networkRecord, "network");
},
});
});
it("Sets comment id", () => {
commit(environment, { isOffline: true });
expect(source.get(NETWORK_ID)!.isOffline).toEqual(true);
});
@@ -8,9 +8,14 @@ export interface SetNetworkStatusInput {
isOffline: boolean;
}
export type SetNetworkStatusMutation = (input: SetNetworkStatusInput) => void;
export type SetNetworkStatusMutation = (
input: SetNetworkStatusInput
) => Promise<void>;
async function commit(environment: Environment, input: SetNetworkStatusInput) {
export async function commit(
environment: Environment,
input: SetNetworkStatusInput
) {
return commitLocalUpdate(environment, store => {
const record = store.get(NETWORK_ID)!;
record.setValue(input.isOffline, "isOffline");
+15 -2
View File
@@ -1,2 +1,15 @@
export * from "./CreateCommentMutation";
export * from "./SetNetworkStatusMutation";
export {
withCreateCommentMutation,
CreateCommentMutation,
CreateCommentInput,
} from "./CreateCommentMutation";
export {
withSetNetworkStatusMutation,
SetNetworkStatusMutation,
SetNetworkStatusInput,
} from "./SetNetworkStatusMutation";
export {
withSetCommentIDMutation,
SetCommentIDMutation,
SetCommentIDInput,
} from "./SetCommentIDMutation";
@@ -1,56 +0,0 @@
import * as React from "react";
import { StatelessComponent } from "react";
import { ReadyState } from "react-relay";
import {
graphql,
QueryRenderer,
withLocalStateContainer,
} from "talk-framework/lib/relay";
import {
AppQueryResponse,
AppQueryVariables,
} from "talk-stream/__generated__/AppQuery.graphql";
import { AppQueryLocal as Local } from "talk-stream/__generated__/AppQueryLocal.graphql";
import AppContainer from "../containers/AppContainer";
export const render = ({ error, props }: ReadyState<AppQueryResponse>) => {
if (error) {
return <div>{error.message}</div>;
}
if (props) {
return <AppContainer data={props} />;
}
return <div>Loading</div>;
};
interface InnerProps {
local: Local;
}
const AppQuery: StatelessComponent<InnerProps> = props => {
return (
<QueryRenderer<AppQueryVariables, AppQueryResponse>
query={graphql`
query AppQuery($assetID: ID!) {
...AppContainer @arguments(assetID: $assetID)
}
`}
variables={{
assetID: props.local.assetID,
}}
render={render}
/>
);
};
const enhanced = withLocalStateContainer<Local>(
graphql`
fragment AppQueryLocal on Local {
assetID
}
`
)(AppQuery);
export default enhanced;
@@ -0,0 +1,34 @@
import { shallow } from "enzyme";
import React from "react";
import { render } from "./PermalinkViewQuery";
it("renders permalink view container", () => {
const data = {
props: {
asset: {},
comment: {},
} as any,
error: null,
};
const wrapper = shallow(React.createElement(() => render(data)));
expect(wrapper).toMatchSnapshot();
});
it("renders loading", () => {
const data = {
props: null,
error: null,
};
const wrapper = shallow(React.createElement(() => render(data)));
expect(wrapper).toMatchSnapshot();
});
it("renders error", () => {
const data = {
props: null,
error: new Error("error"),
};
const wrapper = shallow(React.createElement(() => render(data)));
expect(wrapper).toMatchSnapshot();
});
@@ -0,0 +1,68 @@
import * as React from "react";
import { StatelessComponent } from "react";
import { ReadyState } from "react-relay";
import {
graphql,
QueryRenderer,
withLocalStateContainer,
} from "talk-framework/lib/relay";
import {
PermalinkViewQueryResponse,
PermalinkViewQueryVariables,
} from "talk-stream/__generated__/PermalinkViewQuery.graphql";
import { PermalinkViewQueryLocal as Local } from "talk-stream/__generated__/PermalinkViewQueryLocal.graphql";
import PermalinkViewContainer from "../containers/PermalinkViewContainer";
interface InnerProps {
local: Local;
}
export const render = ({
error,
props,
}: ReadyState<PermalinkViewQueryResponse>) => {
if (error) {
return <div>{error.message}</div>;
}
if (props) {
return (
<PermalinkViewContainer asset={props.asset} comment={props.comment} />
);
}
return <div>Loading</div>;
};
const PermalinkViewQuery: StatelessComponent<InnerProps> = ({
local: { commentID, assetID },
}) => (
<QueryRenderer<PermalinkViewQueryVariables, PermalinkViewQueryResponse>
query={graphql`
query PermalinkViewQuery($assetID: ID!, $commentID: ID!) {
asset(id: $assetID) {
...PermalinkViewContainer_asset
}
comment(id: $commentID) {
...PermalinkViewContainer_comment
}
}
`}
variables={{
assetID,
commentID,
}}
render={render}
/>
);
const enhanced = withLocalStateContainer<Local>(
graphql`
fragment PermalinkViewQueryLocal on Local {
assetID
commentID
}
`
)(PermalinkViewQuery);
export default enhanced;
@@ -1,11 +1,13 @@
import { shallow } from "enzyme";
import React from "react";
import { render } from "./AppQuery";
import { render } from "./StreamQuery";
it("renders app", () => {
it("renders stream container", () => {
const data = {
props: {} as any,
props: {
asset: {},
} as any,
error: null,
};
const wrapper = shallow(React.createElement(() => render(data)));
@@ -0,0 +1,58 @@
import * as React from "react";
import { StatelessComponent } from "react";
import { ReadyState } from "react-relay";
import {
graphql,
QueryRenderer,
withLocalStateContainer,
} from "talk-framework/lib/relay";
import {
StreamQueryResponse,
StreamQueryVariables,
} from "talk-stream/__generated__/StreamQuery.graphql";
import { StreamQueryLocal as Local } from "talk-stream/__generated__/StreamQueryLocal.graphql";
import StreamContainer from "../containers/StreamContainer";
interface InnerProps {
local: Local;
}
export const render = ({ error, props }: ReadyState<StreamQueryResponse>) => {
if (error) {
return <div>{error.message}</div>;
}
if (props) {
return <StreamContainer asset={props.asset} />;
}
return <div>Loading</div>;
};
const StreamQuery: StatelessComponent<InnerProps> = ({
local: { assetID },
}) => (
<QueryRenderer<StreamQueryVariables, StreamQueryResponse>
query={graphql`
query StreamQuery($assetID: ID!) {
asset(id: $assetID) {
...StreamContainer_asset
}
}
`}
variables={{
assetID,
}}
render={render}
/>
);
const enhanced = withLocalStateContainer<Local>(
graphql`
fragment StreamQueryLocal on Local {
assetID
}
`
)(StreamQuery);
export default enhanced;
@@ -0,0 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders error 1`] = `
<div>
error
</div>
`;
exports[`renders loading 1`] = `
<div>
Loading
</div>
`;
exports[`renders permalink view container 1`] = `
<withContext(createMutationContainer(Relay(PermalinkViewContainer)))
asset={Object {}}
comment={Object {}}
/>
`;
@@ -1,11 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders app 1`] = `
<Relay(AppContainer)
data={Object {}}
/>
`;
exports[`renders error 1`] = `
<div>
error
@@ -17,3 +11,9 @@ exports[`renders loading 1`] = `
Loading
</div>
`;
exports[`renders stream container 1`] = `
<Relay(StreamContainer)
asset={Object {}}
/>
`;
@@ -7,11 +7,6 @@ exports[`loads more comments 1`] = `
<div
className="Stream-root"
>
<h1
className="Typography-root Typography-heading1 Typography-colorTextPrimary Typography-gutterBottom"
>
Talk NEO
</h1>
<form
autoComplete="off"
onSubmit={[Function]}
@@ -74,6 +69,9 @@ exports[`loads more comments 1`] = `
>
Joining Too
</p>
<div
className="Comment-footer"
/>
</div>
</div>
<div
@@ -103,6 +101,9 @@ exports[`loads more comments 1`] = `
>
What's up?
</p>
<div
className="Comment-footer"
/>
</div>
</div>
<div
@@ -132,6 +133,9 @@ exports[`loads more comments 1`] = `
>
Hey!
</p>
<div
className="Comment-footer"
/>
</div>
</div>
</div>
@@ -146,11 +150,6 @@ exports[`renders comment stream 1`] = `
<div
className="Stream-root"
>
<h1
className="Typography-root Typography-heading1 Typography-colorTextPrimary Typography-gutterBottom"
>
Talk NEO
</h1>
<form
autoComplete="off"
onSubmit={[Function]}
@@ -213,6 +212,9 @@ exports[`renders comment stream 1`] = `
>
Joining Too
</p>
<div
className="Comment-footer"
/>
</div>
</div>
<div
@@ -242,6 +244,9 @@ exports[`renders comment stream 1`] = `
>
What's up?
</p>
<div
className="Comment-footer"
/>
</div>
</div>
<button
@@ -0,0 +1,136 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders permalink view 1`] = `
<div
className="Flex-root App-root Flex-justifyCenter Flex-alignCenter"
>
<div
className="PermalinkView-root"
>
<button
className="BaseButton-root Button-root PermalinkView-button Button-sizeRegular Button-colorPrimary Button-variantOutlined Button-fullWidth"
id="talk-comments-permalinkView-showAllComments"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
>
Show all Comments
</button>
<div
className="Flex-root Flex-alignFlexStart Flex-directionColumn"
>
<div
role="article"
>
<div
className="Flex-root TopBar-root Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
>
<span
className="Typography-root Typography-heading3 Typography-colorTextPrimary Username-root"
>
Markus
</span>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:24:00.000Z
</time>
</div>
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Joining Too
</p>
<div
className="Comment-footer"
/>
</div>
</div>
</div>
</div>
`;
exports[`show all comments 1`] = `
<div
className="Flex-root App-root Flex-justifyCenter Flex-alignCenter"
>
<div
className="Stream-root"
>
<form
autoComplete="off"
onSubmit={[Function]}
>
<div>
<textarea
className="PostCommentForm-textarea"
name="body"
onChange={[Function]}
value=""
/>
</div>
<div
className="PostCommentForm-postButtonContainer"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
>
Post
</button>
</div>
</form>
<div
aria-live="polite"
className="Flex-root Flex-itemGutter Flex-alignFlexStart Flex-directionColumn"
id="talk-comments-stream-log"
role="log"
>
<div
className="Flex-root Flex-itemGutter Flex-alignFlexStart Flex-directionColumn"
>
<div
role="article"
>
<div
className="Flex-root TopBar-root Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
>
<span
className="Typography-root Typography-heading3 Typography-colorTextPrimary Username-root"
>
Markus
</span>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:24:00.000Z
</time>
</div>
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Joining Too
</p>
<div
className="Comment-footer"
/>
</div>
</div>
</div>
</div>
</div>
`;
@@ -0,0 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders permalink view with unknown asset 1`] = `
<div
className="Flex-root App-root Flex-justifyCenter Flex-alignCenter"
>
<div
className="PermalinkView-root"
>
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Comment not found
</p>
</div>
</div>
`;
@@ -0,0 +1,109 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders permalink view with unknown comment 1`] = `
<div
className="Flex-root App-root Flex-justifyCenter Flex-alignCenter"
>
<div
className="PermalinkView-root"
>
<button
className="BaseButton-root Button-root PermalinkView-button Button-sizeRegular Button-colorPrimary Button-variantOutlined Button-fullWidth"
id="talk-comments-permalinkView-showAllComments"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
>
Show all Comments
</button>
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Comment not found
</p>
</div>
</div>
`;
exports[`show all comments 1`] = `
<div
className="Flex-root App-root Flex-justifyCenter Flex-alignCenter"
>
<div
className="Stream-root"
>
<form
autoComplete="off"
onSubmit={[Function]}
>
<div>
<textarea
className="PostCommentForm-textarea"
name="body"
onChange={[Function]}
value=""
/>
</div>
<div
className="PostCommentForm-postButtonContainer"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled"
disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
>
Post
</button>
</div>
</form>
<div
aria-live="polite"
className="Flex-root Flex-itemGutter Flex-alignFlexStart Flex-directionColumn"
id="talk-comments-stream-log"
role="log"
>
<div
className="Flex-root Flex-itemGutter Flex-alignFlexStart Flex-directionColumn"
>
<div
role="article"
>
<div
className="Flex-root TopBar-root Flex-halfItemGutter Flex-alignBaseline Flex-directionColumn"
>
<span
className="Typography-root Typography-heading3 Typography-colorTextPrimary Username-root"
>
Markus
</span>
<time
className="Timestamp-root RelativeTime-root"
dateTime="2018-07-06T18:24:00.000Z"
title="2018-07-06T18:24:00.000Z"
>
2018-07-06T18:24:00.000Z
</time>
</div>
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Joining Too
</p>
<div
className="Comment-footer"
/>
</div>
</div>
</div>
</div>
</div>
`;
@@ -7,11 +7,6 @@ exports[`renders comment stream 1`] = `
<div
className="Stream-root"
>
<h1
className="Typography-root Typography-heading1 Typography-colorTextPrimary Typography-gutterBottom"
>
Talk NEO
</h1>
<form
autoComplete="off"
onSubmit={[Function]}
@@ -74,6 +69,9 @@ exports[`renders comment stream 1`] = `
>
Joining Too
</p>
<div
className="Comment-footer"
/>
</div>
</div>
<div
@@ -103,6 +101,9 @@ exports[`renders comment stream 1`] = `
>
I like yoghurt
</p>
<div
className="Comment-footer"
/>
</div>
<div
className="Indent-root Indent-level0"
@@ -136,6 +137,9 @@ exports[`renders comment stream 1`] = `
>
Joining Too
</p>
<div
className="Comment-footer"
/>
</div>
<div
role="article"
@@ -161,6 +165,9 @@ exports[`renders comment stream 1`] = `
>
What's up?
</p>
<div
className="Comment-footer"
/>
</div>
</div>
</div>
@@ -7,11 +7,6 @@ exports[`renders comment stream 1`] = `
<div
className="Stream-root"
>
<h1
className="Typography-root Typography-heading1 Typography-colorTextPrimary Typography-gutterBottom"
>
Talk NEO
</h1>
<form
autoComplete="off"
onSubmit={[Function]}
@@ -74,6 +69,9 @@ exports[`renders comment stream 1`] = `
>
Joining Too
</p>
<div
className="Comment-footer"
/>
</div>
</div>
<div
@@ -103,6 +101,9 @@ exports[`renders comment stream 1`] = `
>
What's up?
</p>
<div
className="Comment-footer"
/>
</div>
</div>
</div>
@@ -7,11 +7,6 @@ exports[`renders comment stream 1`] = `
<div
className="Stream-root"
>
<h1
className="Typography-root Typography-heading1 Typography-colorTextPrimary Typography-gutterBottom"
>
Talk NEO
</h1>
<form
autoComplete="off"
onSubmit={[Function]}
@@ -74,6 +69,9 @@ exports[`renders comment stream 1`] = `
>
Joining Too
</p>
<div
className="Comment-footer"
/>
</div>
<div
className="Indent-root Indent-level0"
@@ -107,6 +105,9 @@ exports[`renders comment stream 1`] = `
>
What's up?
</p>
<div
className="Comment-footer"
/>
</div>
<button
aria-controls="talk-comments-replyList-log--comment-0"
@@ -138,11 +139,6 @@ exports[`show all replies 1`] = `
<div
className="Stream-root"
>
<h1
className="Typography-root Typography-heading1 Typography-colorTextPrimary Typography-gutterBottom"
>
Talk NEO
</h1>
<form
autoComplete="off"
onSubmit={[Function]}
@@ -205,6 +201,9 @@ exports[`show all replies 1`] = `
>
Joining Too
</p>
<div
className="Comment-footer"
/>
</div>
<div
className="Indent-root Indent-level0"
@@ -238,6 +237,9 @@ exports[`show all replies 1`] = `
>
What's up?
</p>
<div
className="Comment-footer"
/>
</div>
<div
role="article"
@@ -263,6 +265,9 @@ exports[`show all replies 1`] = `
>
Hey!
</p>
<div
className="Comment-footer"
/>
</div>
</div>
</div>
@@ -1,49 +0,0 @@
import { IResolvers } from "graphql-tools";
import { createFetch } from "relay-local-schema";
import {
commitLocalUpdate,
Environment,
Network,
RecordProxy,
RecordSource,
Store,
} from "relay-runtime";
import { loadSchema } from "talk-common/graphql";
import {
createAndRetain,
LOCAL_ID,
LOCAL_TYPE,
wrapFetchWithLogger,
} from "talk-framework/lib/relay";
export interface CreateEnvironmentParams {
/** graphql resolvers */
resolvers: IResolvers<any, any>;
/** Allows to set initial state for Local state */
initLocalState?: (local: RecordProxy) => void;
/** If enabled, graphql responses will be logged to the console */
logNetwork?: boolean;
}
/**
* create Relay environment for integration tests.
*/
export default function createEnvironment(params: CreateEnvironmentParams) {
const schema = loadSchema("tenant", params.resolvers);
const environment = new Environment({
network: Network.create(
wrapFetchWithLogger(createFetch({ schema }), params.logNetwork)
),
store: new Store(new RecordSource()),
});
if (params.initLocalState) {
commitLocalUpdate(environment, s => {
const root = s.getRoot();
const localRecord = createAndRetain(environment, s, LOCAL_ID, LOCAL_TYPE);
root.setLinkedRecord(localRecord, "local");
params.initLocalState!(localRecord);
});
}
return environment;
}
+2
View File
@@ -37,6 +37,7 @@ export const comments = [
export const assets = [
{
id: "asset-1",
url: "http://localhost/assets/asset-1",
isClosed: false,
comments: {
edges: [
@@ -68,6 +69,7 @@ export const commentWithReplies = {
export const assetWithReplies = {
id: "asset-with-replies",
url: "http://localhost/assets/asset-with-replies",
isClosed: false,
comments: {
edges: [
+10 -7
View File
@@ -5,9 +5,9 @@ import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import AppQuery from "talk-stream/queries/AppQuery";
import { createRelayEnvironment } from "talk-framework/testHelpers";
import AppContainer from "talk-stream/containers/AppContainer";
import createEnvironment from "./createEnvironment";
import { assets, comments } from "./fixtures";
const connectionStub = sinon.stub().throws();
@@ -61,10 +61,13 @@ const resolvers = {
},
};
const environment = createEnvironment({
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
const environment = createRelayEnvironment({
network: {
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
projectName: "tenant",
},
initLocalState: (localRecord: RecordProxy) => {
localRecord.setValue(assetStub.id, "assetID");
},
@@ -77,7 +80,7 @@ const context: TalkContext = {
const testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppQuery />
<AppContainer />
</TalkContextProvider>
);
@@ -0,0 +1,85 @@
import React from "react";
import TestRenderer from "react-test-renderer";
import { RecordProxy } from "relay-runtime";
import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import { createRelayEnvironment } from "talk-framework/testHelpers";
import AppContainer from "talk-stream/containers/AppContainer";
import { assets, comments } from "./fixtures";
const commentStub = {
...comments[0],
};
const assetStub = {
...assets[0],
comments: {
pageInfo: {
hasNextPage: false,
},
edges: [
{
node: commentStub,
cursor: commentStub.createdAt,
},
],
},
};
const resolvers = {
Query: {
comment: sinon
.stub()
.throws()
.withArgs(undefined, { id: commentStub.id })
.returns(commentStub),
asset: sinon
.stub()
.throws()
.withArgs(undefined, { id: assetStub.id })
.returns(assetStub),
},
};
const environment = createRelayEnvironment({
network: {
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
projectName: "tenant",
},
initLocalState: (localRecord: RecordProxy) => {
localRecord.setValue(assetStub.id, "assetID");
localRecord.setValue(commentStub.id, "commentID");
},
});
const context: TalkContext = {
relayEnvironment: environment,
localeMessages: [],
};
const testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>
);
it("renders permalink view", async () => {
// Wait for loading.
await timeout();
expect(testRenderer.toJSON()).toMatchSnapshot();
});
it("show all comments", async () => {
testRenderer.root
.findByProps({
id: "talk-comments-permalinkView-showAllComments",
})
.props.onClick();
await timeout();
expect(testRenderer.toJSON()).toMatchSnapshot();
});
@@ -0,0 +1,45 @@
import React from "react";
import TestRenderer from "react-test-renderer";
import { RecordProxy } from "relay-runtime";
import { timeout } from "talk-common/utils";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import { createRelayEnvironment } from "talk-framework/testHelpers";
import AppContainer from "talk-stream/containers/AppContainer";
const resolvers = {
Query: {
comment: () => null,
asset: () => null,
},
};
const environment = createRelayEnvironment({
network: {
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
projectName: "tenant",
},
initLocalState: (localRecord: RecordProxy) => {
localRecord.setValue("unknown-asset-id", "assetID");
localRecord.setValue("unknown-comment-id", "commentID");
},
});
const context: TalkContext = {
relayEnvironment: environment,
localeMessages: [],
};
const testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>
);
it("renders permalink view with unknown asset", async () => {
// Wait for loading.
await timeout();
expect(testRenderer.toJSON()).toMatchSnapshot();
});
@@ -0,0 +1,81 @@
import React from "react";
import TestRenderer from "react-test-renderer";
import { RecordProxy } from "relay-runtime";
import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import { createRelayEnvironment } from "talk-framework/testHelpers";
import AppContainer from "talk-stream/containers/AppContainer";
import { assets, comments } from "./fixtures";
const commentStub = {
...comments[0],
};
const assetStub = {
...assets[0],
comments: {
pageInfo: {
hasNextPage: false,
},
edges: [
{
node: commentStub,
cursor: commentStub.createdAt,
},
],
},
};
const resolvers = {
Query: {
comment: () => null,
asset: sinon
.stub()
.throws()
.withArgs(undefined, { id: assetStub.id })
.returns(assetStub),
},
};
const environment = createRelayEnvironment({
network: {
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
projectName: "tenant",
},
initLocalState: (localRecord: RecordProxy) => {
localRecord.setValue(assetStub.id, "assetID");
localRecord.setValue("unknown-comment-id", "commentID");
},
});
const context: TalkContext = {
relayEnvironment: environment,
localeMessages: [],
};
const testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppContainer />
</TalkContextProvider>
);
it("renders permalink view with unknown comment", async () => {
// Wait for loading.
await timeout();
expect(testRenderer.toJSON()).toMatchSnapshot();
});
it("show all comments", async () => {
testRenderer.root
.findByProps({
id: "talk-comments-permalinkView-showAllComments",
})
.props.onClick();
await timeout();
expect(testRenderer.toJSON()).toMatchSnapshot();
});
@@ -5,9 +5,9 @@ import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import AppQuery from "talk-stream/queries/AppQuery";
import { createRelayEnvironment } from "talk-framework/testHelpers";
import AppContainer from "talk-stream/containers/AppContainer";
import createEnvironment from "./createEnvironment";
import { assetWithReplies } from "./fixtures";
const resolvers = {
@@ -20,10 +20,13 @@ const resolvers = {
},
};
const environment = createEnvironment({
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
const environment = createRelayEnvironment({
network: {
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
projectName: "tenant",
},
initLocalState: (localRecord: RecordProxy) => {
localRecord.setValue(assetWithReplies.id, "assetID");
},
@@ -36,7 +39,7 @@ const context: TalkContext = {
const testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppQuery />
<AppContainer />
</TalkContextProvider>
);
@@ -5,9 +5,9 @@ import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import AppQuery from "talk-stream/queries/AppQuery";
import { createRelayEnvironment } from "talk-framework/testHelpers";
import AppContainer from "talk-stream/containers/AppContainer";
import createEnvironment from "./createEnvironment";
import { assets } from "./fixtures";
const resolvers = {
@@ -20,10 +20,13 @@ const resolvers = {
},
};
const environment = createEnvironment({
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
const environment = createRelayEnvironment({
network: {
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
projectName: "tenant",
},
initLocalState: (localRecord: RecordProxy) => {
localRecord.setValue(assets[0].id, "assetID");
},
@@ -36,7 +39,7 @@ const context: TalkContext = {
const testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppQuery />
<AppContainer />
</TalkContextProvider>
);
@@ -5,9 +5,9 @@ import sinon from "sinon";
import { timeout } from "talk-common/utils";
import { TalkContext, TalkContextProvider } from "talk-framework/lib/bootstrap";
import AppQuery from "talk-stream/queries/AppQuery";
import { createRelayEnvironment } from "talk-framework/testHelpers";
import AppContainer from "talk-stream/containers/AppContainer";
import createEnvironment from "./createEnvironment";
import { assets, comments } from "./fixtures";
const connectionStub = sinon.stub().throws();
@@ -77,10 +77,13 @@ const resolvers = {
},
};
const environment = createEnvironment({
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
const environment = createRelayEnvironment({
network: {
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
projectName: "tenant",
},
initLocalState: (localRecord: RecordProxy) => {
localRecord.setValue(assetStub.id, "assetID");
},
@@ -93,7 +96,7 @@ const context: TalkContext = {
const testRenderer = TestRenderer.create(
<TalkContextProvider value={context}>
<AppQuery />
<AppContainer />
</TalkContextProvider>
);
+8 -13
View File
@@ -1,20 +1,15 @@
{
"extends": [
"../../../tslint.json",
"tslint-react"
],
"extends": ["../../../tslint.json", "tslint-react"],
"rules": {
"jsx-curly-spacing": false,
"jsx-no-multiline-js": false,
"jsx-boolean-value": [
true,
"never"
]
"jsx-boolean-value": [true, "never"],
"jsx-no-lambda": false
},
"jsRules": {
"jsx-curly-spacing": false,
"jsx-no-multiline-js": false,
"jsx-boolean-value": [
true,
"never"
]
"jsx-boolean-value": [true, "never"],
"jsx-no-lambda": false
}
}
}
@@ -19,8 +19,11 @@ clicks outside the component.
Click the blue background. It should trigger an alert. Nothing should happen if you click the button.
<Playground>
<div style={{background: 'blue', padding: '10px'}}>
<ClickOutside onClickOutside={() => alert('You clicked outside!')}>
<div id="outside" style={{background: 'blue', padding: '10px'}}>
<ClickOutside onClickOutside={e => {
if (e.srcElement.id === "outside") {
alert('You clicked outside!');
}}}>
<Button variant="filled">Push Me</Button>
</ClickOutside>
</div>
@@ -10,8 +10,8 @@ export type ClickFarAwayRegister = (
callback: ClickFarAwayCallback
) => ClickFarAwayUnlistenCallback;
interface Props {
onClickOutside: () => void;
export interface ClickOutsideProps {
onClickOutside: (e?: MouseEvent) => void;
/**
* A way to listen for clicks that are e.g. outside of the
@@ -22,7 +22,7 @@ interface Props {
children: React.ReactNode;
}
export class ClickOutside extends React.Component<Props> {
export class ClickOutside extends React.Component<ClickOutsideProps> {
public domNode: Element | null = null;
private unlisten?: ClickFarAwayUnlistenCallback;
@@ -30,7 +30,7 @@ export class ClickOutside extends React.Component<Props> {
const { onClickOutside } = this.props;
if (!e || !this.domNode!.contains(e.target as HTMLInputElement)) {
// tslint:disable-next-line:no-unused-expression
onClickOutside && onClickOutside();
onClickOutside && onClickOutside(e);
}
};
@@ -65,7 +65,9 @@ export class ClickOutside extends React.Component<Props> {
}
}
const ClickOutsideWithContext: StatelessComponent<Props> = props => (
const ClickOutsideWithContext: StatelessComponent<
ClickOutsideProps
> = props => (
<UIContext.Consumer>
{({ registerClickFarAway }) => (
<ClickOutside {...props} registerClickFarAway={registerClickFarAway} />
@@ -1 +1 @@
export { default as ClickOutside, ClickFarAwayRegister } from "./ClickOutside";
export { default, ClickFarAwayRegister } from "./ClickOutside";
+6 -6
View File
@@ -37,18 +37,18 @@
}
.sm {
font-size: 14px;
width: 14px;
}
.md {
font-size: 18px;
width: 18px;
}
.md {
.lg {
font-size: 24px;
width: 24px;
}
.lg {
.xl {
font-size: 36px;
width: 36px;
}
.xl {
font-size: 48px;
width: 48px;
}
@@ -0,0 +1,21 @@
.root {
background: var(--palette-common-white);
border: 1px solid var(--palette-grey-lighter);
box-sizing: border-box;
box-shadow: var(--elevation-main);
border-radius: var(--round-corners);
padding: calc(0.5 * var(--spacing-unit));
}
.top {
margin: calc(0.5 * var(--spacing-unit)) 0;
}
.left {
margin: 0 calc(0.5 * var(--spacing-unit));
}
.right {
margin: 0 calc(0.5 * var(--spacing-unit));
}
.bottom {
margin: calc(0.5 * var(--spacing-unit)) 0;
}
@@ -0,0 +1,49 @@
---
name: Popover
menu: UI Kit
---
import { Playground } from 'docz'
import Popover from './Popover'
import Button from '../Button'
import Flex from '../Flex'
import Typography from '../Typography'
import ButtonIcon from '../Button/ButtonIcon'
# Popover
`Popover` renders a popover dialog attached to another `Element`.
## Basic usage
<Playground>
<Popover
body={<Typography>This is the body</Typography>}
>
{({ toggleVisibility, forwardRef }) => (
<Button onClick={toggleVisibility} forwardRef={forwardRef} variant="filled" color="primary">
Click me!
</Button>
)}
</Popover>
</Playground>
#### Example with `placement=top`
<Playground>
<Popover
placement="top"
body={({ toggleVisibility }) => (
<Flex itemGutter="half">
<Typography>This is the body</Typography>
<Button onClick={toggleVisibility} size="small">
<ButtonIcon>close</ButtonIcon>
</Button>
</Flex>
)}
>
{({ toggleVisibility, forwardRef }) => (
<Button onClick={toggleVisibility} forwardRef={forwardRef} variant="filled" color="primary">
Click me!
</Button>
)}
</Popover>
</Playground>
@@ -0,0 +1,147 @@
import cn from "classnames";
import React from "react";
import {
Manager,
Popper,
PopperArrowProps,
Reference,
RefHandler,
} from "react-popper";
import AriaInfo from "../AriaInfo";
import * as styles from "./Popover.css";
type Placement =
| "top-start"
| "top"
| "top-end"
| "right-start"
| "right"
| "right-end"
| "bottom-end"
| "bottom"
| "bottom-start"
| "left-end"
| "left"
| "left-start";
interface BodyRenderProps {
toggleVisibility: () => void;
visible: boolean;
}
interface ChildrenRenderProps {
toggleVisibility: () => void;
forwardRef?: RefHandler;
visible: boolean;
}
interface PopoverProps {
body: (props: BodyRenderProps) => React.ReactNode | React.ReactElement<any>;
children: (props: ChildrenRenderProps) => React.ReactNode;
description: string;
id: string;
onClose?: () => void;
className?: string;
placement?: Placement;
}
interface State {
visible: false;
}
class Popover extends React.Component<PopoverProps> {
public static defaultProps = {
placement: "top",
};
public state: State = {
visible: false,
};
public toggleVisibility = () => {
this.setState((state: State) => ({
visible: !state.visible,
}));
};
public close = () => {
this.setState((state: State) => ({
visible: false,
}));
};
public handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
this.close();
}
};
public componentDidMount() {
document.addEventListener("keydown", this.handleEsc, true);
}
public componentWillUnmount() {
document.removeEventListener("keydown", this.handleEsc, true);
}
public render() {
const {
id,
body,
children,
description,
className,
placement,
} = this.props;
const { visible } = this.state;
const popoverClassName = cn(styles.root, className, {
[styles.top]: placement!.startsWith("top"),
[styles.left]: placement!.startsWith("left"),
[styles.right]: placement!.startsWith("right"),
[styles.bottom]: placement!.startsWith("bottom"),
});
return (
<Manager>
<Reference>
{(props: PopperArrowProps) =>
children({
forwardRef: props.ref,
toggleVisibility: this.toggleVisibility,
visible: this.state.visible,
})
}
</Reference>
<Popper placement={placement} eventsEnabled positionFixed={false}>
{(props: PopperArrowProps) => (
<div
id={id}
role="popup"
aria-labelledby={`${id}-ariainfo`}
aria-hidden={!visible}
>
<AriaInfo id={`${id}-ariainfo`}>{description}</AriaInfo>
{visible && (
<div
style={props.style}
className={popoverClassName}
ref={props.ref}
>
{typeof body === "function"
? body({
toggleVisibility: this.toggleVisibility,
visible: this.state.visible,
})
: body}
</div>
)}
</div>
)}
</Popper>
</Manager>
);
}
}
export default Popover;
@@ -0,0 +1 @@
export { default } from "./Popover";
@@ -0,0 +1,8 @@
.root {
composes: textField from "talk-ui/shared/typography.css";
background: var(--palette-common-white);
border: 1px solid var(--palette-grey-lighter);
box-sizing: border-box;
border-radius: var(--round-corners);
padding: calc(0.5 * var(--spacing-unit));
}
@@ -0,0 +1,17 @@
---
name: TextField
menu: UI Kit
---
import { Playground, PropsTable } from 'docz'
import TextField from './TextField'
# TextField
`TextField`
## Basic usage
<Playground>
<TextField value="Hallo Talk" />
</Playground>
@@ -0,0 +1,17 @@
import cn from "classnames";
import React, { InputHTMLAttributes, StatelessComponent } from "react";
import * as styles from "./TextField.css";
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
classes?: typeof styles;
}
const TextField: StatelessComponent<TextFieldProps> = ({
className,
...rest
}) => {
return <input {...rest} className={cn(styles.root, className)} />;
};
export default TextField;
@@ -0,0 +1,2 @@
export * from "./TextField";
export { default } from "./TextField";
+4
View File
@@ -1,9 +1,13 @@
export { default as BaseButton } from "./BaseButton";
export { default as Button } from "./Button";
export { default as ButtonIcon } from "./Button/ButtonIcon";
export { default as Typography } from "./Typography";
export { default as Popover } from "./Popover";
export { default as TextField } from "./TextField";
export { default as RelativeTime } from "./RelativeTime";
export { default as UIContext, UIContextProps } from "./UIContext";
export { default as Flex } from "./Flex";
export { default as MatchMedia } from "./MatchMedia";
export { default as TrapFocus } from "./TrapFocus";
export { default as ClickOutside } from "./ClickOutside";
export { default as Popup } from "./Popup";
+9
View File
@@ -126,3 +126,12 @@
line-height: calc(18em / 16);
letter-spacing: calc(0.2em / 16);
}
.textField {
color: var(--palette-common-black);
font-family: "Source Sans Pro";
font-weight: var(--font-weight-regular);
font-size: 16px;
line-height: calc(18em / 16);
letter-spacing: calc(0.57em / 16);
}
+3
View File
@@ -4,6 +4,9 @@
*/
const variables = {
elevation: {
main: "1px 0px 4px rgba(0, 0, 0, 0.25)",
},
palette: {
/* Primary colors */
primary: {
+1
View File
@@ -1,2 +1,3 @@
export { default as timeout } from "./timeout";
export { default as pascalCase } from "./pascalCase";
export { default as oncePerFrame } from "./oncePerFrame";
+18
View File
@@ -0,0 +1,18 @@
/**
* Function decorator that prevents calling `fn` more then once per frame.
* If called more than once, the last return value gets returned.
*/
const oncePerFrame = <T extends (...args: any[]) => any>(fn: T) => {
let toggledThisFrame = false;
let lastResult: any = null;
return ((...args: any[]) => {
if (toggledThisFrame) {
return lastResult;
}
toggledThisFrame = true;
lastResult = fn(...args);
setTimeout(() => (toggledThisFrame = false), 0);
}) as T;
};
export default oncePerFrame;
@@ -2,6 +2,8 @@ import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generate
const Query: GQLQueryTypeResolver<void> = {
asset: (source, args, ctx) => ctx.loaders.Assets.findOrCreate(args),
comment: (source, { id }, ctx) =>
id ? ctx.loaders.Comments.comment.load(id) : null,
settings: (source, args, ctx) => ctx.tenant,
me: (source, args, ctx) => ctx.user,
};
+7 -1
View File
@@ -5,5 +5,11 @@ route: '/'
# Introduction
Hello, I'm a mdx file!
## Running the Talk V5
Run this command to start Talk V5 in watch mode:
```sh
npm run watch
```
+23
View File
@@ -78,3 +78,26 @@ const enhanced = withLocalStateContainer(
export type ContainerProps = ReturnPropTypes<typeof enhanced>;
export default enhanced;
```
A working chaining example looks like this:
```
const enhanced = withFragmentContainer<{ data: Data }>({
data: graphql`
fragment PermalinkViewContainerQuery on Query
@argumentDefinitions(commentID: { type: "ID!" }) {
comment(id: $commentID) {
...CommentContainer
}
}
`,
})(
withLocalStateContainer<Local>(
graphql`
fragment PermalinkViewContainerLocal on Local {
assetURL
}
`
)(PermalinkViewContainer)
);
```
+4
View File
@@ -5,3 +5,7 @@
comments-postCommentForm-post = Post
comments-stream-loadMore = Load more
comments-replyList-showAll = Show all
comments-permalink-share = Share
comments-permalink-copy = Copy
comments-permalink-copied = Copied