Merge branch 'next' into next-server-threading

This commit is contained in:
Wyatt Johnson
2018-09-24 21:45:11 +00:00
committed by GitHub
19 changed files with 3465 additions and 56 deletions
@@ -20,7 +20,11 @@ type AuthPopup {
}
extend type Comment {
# pending is true during the optimistic response.
pending: Boolean
# localReplies contains only comments created by the user
# on the ultimate threading level.
localReplies: [Comment!]
}
type Local {
@@ -1,5 +1,9 @@
import { graphql } from "react-relay";
import { Environment, RelayMutationConfig } from "relay-runtime";
import {
ConnectionHandler,
Environment,
RecordSourceSelectorProxy,
} from "relay-runtime";
import { getMe } from "talk-framework/helpers";
import { TalkContext } from "talk-framework/lib/bootstrap";
@@ -14,7 +18,85 @@ import { CreateCommentMutation as MutationTypes } from "talk-stream/__generated_
export type CreateCommentInput = Omit<
MutationTypes["variables"]["input"],
"clientMutationId"
>;
> & { local?: boolean };
function sharedUpdater(
store: RecordSourceSelectorProxy,
input: CreateCommentInput
) {
if (input.local) {
localUpdate(store, input);
} else {
update(store, input);
}
}
/**
* update integrates new comment into the CommentConnection.
*/
function update(store: RecordSourceSelectorProxy, input: CreateCommentInput) {
// Get the payload returned from the server.
const payload = store.getRootField("createComment")!;
// Get the edge of the newly created comment.
const newEdge = payload.getLinkedRecord("edge")!;
// Get parent proxy.
const parentProxy = input.parentID
? store.get(input.parentID)
: store.get(input.assetID);
const connectionKey = input.parentID
? "ReplyList_replies"
: "Stream_comments";
const filters = input.parentID
? { orderBy: "CREATED_AT_ASC" }
: { orderBy: "CREATED_AT_DESC" };
const where = input.parentID ? "append" : "prepend";
if (parentProxy) {
const con = ConnectionHandler.getConnection(
parentProxy,
connectionKey,
filters
);
if (con) {
if (where === "prepend") {
ConnectionHandler.insertEdgeBefore(con, newEdge);
} else {
ConnectionHandler.insertEdgeAfter(con, newEdge);
}
}
}
}
/**
* localUpdate is like update but updates the `localReplies` endpoint.
*/
function localUpdate(
store: RecordSourceSelectorProxy,
input: CreateCommentInput
) {
// Get the payload returned from the server.
const payload = store.getRootField("createComment")!;
// Get the edge of the newly created comment.
const newEdge = payload.getLinkedRecord("edge")!;
const newComment = newEdge.getLinkedRecord("node");
// Get parent proxy.
const parentProxy = store.get(input.parentID!);
if (parentProxy) {
const localReplies = parentProxy.getLinkedRecords("localReplies");
const nextLocalReplies = localReplies
? localReplies.concat(newComment)
: [newComment];
parentProxy.setLinkedRecords(nextLocalReplies, "localReplies");
}
}
const mutation = graphql`
mutation CreateCommentMutation($input: CreateCommentInput!) {
@@ -32,39 +114,6 @@ const mutation = graphql`
let clientMutationId = 0;
function getConfig(input: CreateCommentInput): RelayMutationConfig[] {
if (!input.parentID) {
return [
{
type: "RANGE_ADD",
connectionInfo: [
{
key: "Stream_comments",
rangeBehavior: "prepend",
filters: { orderBy: "CREATED_AT_DESC" },
},
],
parentID: input.assetID,
edgeName: "edge",
},
];
}
return [
{
type: "RANGE_ADD",
connectionInfo: [
{
key: "ReplyList_replies",
rangeBehavior: "append",
filters: { orderBy: "CREATED_AT_ASC" },
},
],
parentID: input.parentID,
edgeName: "edge",
},
];
}
function commit(
environment: Environment,
input: CreateCommentInput,
@@ -77,7 +126,9 @@ function commit(
mutation,
variables: {
input: {
...input,
assetID: input.assetID,
parentID: input.parentID,
body: input.body,
clientMutationId: clientMutationId.toString(),
},
},
@@ -102,9 +153,12 @@ function commit(
},
} as any, // TODO: (cvle) generated types should contain one for the optimistic response.
optimisticUpdater: store => {
sharedUpdater(store, input);
store.get(id)!.setValue(true, "pending");
},
configs: getConfig(input),
updater: store => {
sharedUpdater(store, input);
},
});
}
@@ -30,6 +30,12 @@
border-left: 3px solid var(--palette-grey-lighter);
}
.level6 {
padding-left: var(--spacing-unit);
margin-left: calc(5 * var(--spacing-unit));
border-left: 3px solid var(--palette-grey-lightest);
}
.noBorder {
border: 0;
}
@@ -10,16 +10,28 @@ export interface IndentProps {
children: React.ReactNode;
}
const levels = [
"",
styles.level1,
styles.level2,
styles.level3,
styles.level4,
styles.level5,
styles.level6,
];
function getLevelClassName(level: number = 0) {
if (!(level in levels)) {
throw new Error(`Indent level ${level} does not exist`);
}
return levels[level];
}
const Indent: StatelessComponent<IndentProps> = props => {
return (
<div className={cn(props.className, styles.root)}>
<div
className={cn({
[styles.level1]: props.level === 1,
[styles.level2]: props.level === 2,
[styles.level3]: props.level === 3,
[styles.level4]: props.level === 4,
[styles.level5]: props.level === 5,
className={cn(getLevelClassName(props.level), {
[styles.noBorder]: props.noBorder,
})}
>
@@ -20,6 +20,8 @@ it("renders correctly", () => {
disableShowAll: false,
indentLevel: 1,
me: null,
localReply: false,
disableReplies: false,
};
const wrapper = shallow(<ReplyListN {...props} />);
expect(wrapper).toMatchSnapshot();
@@ -17,11 +17,13 @@ export interface ReplyListProps {
comments: ReadonlyArray<
{ id: string } & PropTypesOf<typeof CommentContainer>["comment"]
>;
onShowAll: () => void;
hasMore: boolean;
disableShowAll: boolean;
onShowAll?: () => void;
hasMore?: boolean;
disableShowAll?: boolean;
indentLevel?: number;
ReplyListComponent?: React.ComponentType<any>;
localReply?: boolean;
disableReplies?: boolean;
}
function getReplyListElement(
@@ -48,6 +50,8 @@ const ReplyList: StatelessComponent<ReplyListProps> = props => {
comment={comment}
asset={props.asset}
indentLevel={props.indentLevel}
localReply={props.localReply}
disableReplies={props.disableReplies}
/>
{getReplyListElement(props, comment)}
</HorizontalGutter>
@@ -19,8 +19,10 @@ exports[`renders correctly 1`] = `
"id": "comment-1",
}
}
disableReplies={false}
indentLevel={1}
key="comment-1"
localReply={false}
me={null}
/>
</withPropsOnChange(HorizontalGutter)>
@@ -38,8 +40,10 @@ exports[`renders correctly 1`] = `
"id": "comment-2",
}
}
disableReplies={false}
indentLevel={1}
key="comment-2"
localReply={false}
me={null}
/>
</withPropsOnChange(HorizontalGutter)>
@@ -32,6 +32,8 @@ it("renders username and body", () => {
},
indentLevel: 1,
showAuthPopup: noop as any,
localReply: false,
disableReplies: false,
};
const wrapper = shallow(<CommentContainerN {...props} />);
@@ -65,3 +67,33 @@ it("renders body only", () => {
const wrapper = shallow(<CommentContainerN {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("hide reply button", () => {
const props: PropTypesOf<typeof CommentContainerN> = {
me: null,
asset: {
id: "asset-id",
},
comment: {
id: "comment-id",
author: {
id: "author-id",
username: "Marvin",
},
body: "Woof",
createdAt: "1995-12-17T03:24:00.000Z",
editing: {
edited: false,
editableUntil: "1995-12-17T03:24:30.000Z",
},
pending: false,
},
indentLevel: 1,
showAuthPopup: noop as any,
localReply: false,
disableReplies: true,
};
const wrapper = shallow(<CommentContainerN {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -26,6 +26,13 @@ interface InnerProps {
asset: AssetData;
indentLevel?: number;
showAuthPopup: ShowAuthPopupMutation;
/**
* localReply will integrate the mutation response into
* localReplies
*/
localReply?: boolean;
/** disableReplies will remove the ReplyButton */
disableReplies?: boolean;
}
interface State {
@@ -107,7 +114,13 @@ export class CommentContainer extends Component<InnerProps, State> {
}
public render() {
const { comment, asset, indentLevel } = this.props;
const {
comment,
asset,
indentLevel,
localReply,
disableReplies,
} = this.props;
const { showReplyDialog, showEditDialog, editable } = this.state;
if (showEditDialog) {
return (
@@ -144,11 +157,13 @@ export class CommentContainer extends Component<InnerProps, State> {
}
footer={
<>
<ReplyButton
id={`comments-commentContainer-replyButton-${comment.id}`}
onClick={this.openReplyDialog}
active={showReplyDialog}
/>
{!disableReplies && (
<ReplyButton
id={`comments-commentContainer-replyButton-${comment.id}`}
onClick={this.openReplyDialog}
active={showReplyDialog}
/>
)}
<PermalinkButtonContainer commentID={comment.id} />
</>
}
@@ -158,6 +173,7 @@ export class CommentContainer extends Component<InnerProps, State> {
comment={comment}
asset={asset}
onClose={this.closeReplyDialog}
localReply={localReply}
/>
)}
</>
@@ -0,0 +1,66 @@
import React, { Component } from "react";
import { graphql } from "react-relay";
import withFragmentContainer from "talk-framework/lib/relay/withFragmentContainer";
import { PropTypesOf } from "talk-framework/types";
import { LocalReplyListContainer_asset as AssetData } from "talk-stream/__generated__/LocalReplyListContainer_asset.graphql";
import { LocalReplyListContainer_comment as CommentData } from "talk-stream/__generated__/LocalReplyListContainer_comment.graphql";
import { LocalReplyListContainer_me as MeData } from "talk-stream/__generated__/LocalReplyListContainer_me.graphql";
import ReplyList from "../components/ReplyList";
interface InnerProps {
indentLevel: number;
me: MeData;
asset: AssetData;
comment: CommentData;
}
/**
* LocalReplyListContainer renders the replies from the endpoint
* `localReplies` instead of `replies`. This is e.g. used for the
* ultimate threading level to only display the newly created comments
* from the current user.
*/
export class LocalReplyListContainer extends Component<InnerProps> {
public render() {
if (!this.props.comment.localReplies) {
return null;
}
return (
<ReplyList
me={this.props.me}
comment={this.props.comment}
comments={this.props.comment.localReplies}
asset={this.props.asset}
indentLevel={this.props.indentLevel}
disableReplies
/>
);
}
}
const enhanced = withFragmentContainer<InnerProps>({
me: graphql`
fragment LocalReplyListContainer_me on User {
...CommentContainer_me
}
`,
asset: graphql`
fragment LocalReplyListContainer_asset on Asset {
...CommentContainer_asset
}
`,
comment: graphql`
fragment LocalReplyListContainer_comment on Comment {
id
localReplies {
id
...CommentContainer_comment
}
}
`,
})(LocalReplyListContainer);
export type LocalReplyListContainerProps = PropTypesOf<typeof enhanced>;
export default enhanced;
@@ -92,7 +92,7 @@ it("save values", async () => {
it("creates a comment", async () => {
const assetID = "asset-id";
const input = { body: "Hello World!" };
const input = { body: "Hello World!", local: false };
const createCommentStub = sinon.stub();
const form = { reset: noop };
const onCloseStub = sinon.stub();
@@ -25,6 +25,7 @@ interface InnerProps {
asset: AssetData;
onClose?: () => void;
autofocus: boolean;
localReply?: boolean;
}
interface State {
@@ -76,6 +77,7 @@ export class ReplyCommentFormContainer extends Component<InnerProps, State> {
await this.props.createComment({
assetID: this.props.asset.id,
parentID: this.props.comment.id,
local: this.props.localReply,
...input,
});
@@ -29,6 +29,7 @@ it("renders correctly", () => {
me: null,
indentLevel: 1,
ReplyListComponent: () => null,
localReply: false,
};
const wrapper = shallow(<ReplyListContainerN {...props} />);
expect(wrapper).toMatchSnapshot();
@@ -50,6 +51,7 @@ it("renders correctly when replies are empty", () => {
me: null,
indentLevel: 1,
ReplyListComponent: undefined,
localReply: false,
};
const wrapper = shallow(<ReplyListContainerN {...props} />);
expect(wrapper).toMatchSnapshot();
@@ -75,6 +77,7 @@ describe("when has more replies", () => {
me: null,
indentLevel: 1,
ReplyListComponent: undefined,
localReply: false,
};
let wrapper: ShallowWrapper;
@@ -3,6 +3,7 @@ import { graphql, GraphQLTaggedNode, RelayPaginationProp } from "react-relay";
import { withProps } from "recompose";
import { withPaginationContainer } from "talk-framework/lib/relay";
import { PropTypesOf } from "talk-framework/types";
import { ReplyListContainer1_asset as AssetData } from "talk-stream/__generated__/ReplyListContainer1_asset.graphql";
import { ReplyListContainer1_comment as CommentData } from "talk-stream/__generated__/ReplyListContainer1_comment.graphql";
import { ReplyListContainer1_me as MeData } from "talk-stream/__generated__/ReplyListContainer1_me.graphql";
@@ -11,7 +12,9 @@ import {
ReplyListContainer1PaginationQueryVariables,
} from "talk-stream/__generated__/ReplyListContainer1PaginationQuery.graphql";
import { StatelessComponent } from "enzyme";
import ReplyList from "../components/ReplyList";
import LocalReplyListContainer from "./LocalReplyListContainer";
export interface InnerProps {
me: MeData | null;
@@ -20,6 +23,7 @@ export interface InnerProps {
relay: RelayPaginationProp;
indentLevel: number;
ReplyListComponent: React.ComponentType<any> | undefined;
localReply: boolean | undefined;
}
// TODO: (cvle) This should be autogenerated.
@@ -52,6 +56,7 @@ export class ReplyListContainer extends React.Component<InnerProps> {
disableShowAll={this.state.disableShowAll}
indentLevel={this.props.indentLevel}
ReplyListComponent={this.props.ReplyListComponent}
localReply={this.props.localReply}
/>
);
}
@@ -83,9 +88,10 @@ function createReplyListContainer(
comment: GraphQLTaggedNode;
},
query: GraphQLTaggedNode,
ReplyListComponent?: React.ComponentType<any>
ReplyListComponent?: React.ComponentType<any>,
localReply?: boolean
) {
return withProps({ indentLevel, ReplyListComponent })(
return withProps({ indentLevel, ReplyListComponent, localReply })(
withPaginationContainer<
InnerProps,
ReplyListContainer1PaginationQueryVariables,
@@ -115,17 +121,26 @@ function createReplyListContainer(
);
}
/**
* LastReplyList uses the LocalReplyListContainer.
*/
const LastReplyList: StatelessComponent<
PropTypesOf<typeof LocalReplyListContainer>
> = props => <LocalReplyListContainer {...props} indentLevel={6} />;
const ReplyListContainer5 = createReplyListContainer(
5,
{
me: graphql`
fragment ReplyListContainer5_me on User {
...CommentContainer_me
...LocalReplyListContainer_me
}
`,
asset: graphql`
fragment ReplyListContainer5_asset on Asset {
...CommentContainer_asset
...LocalReplyListContainer_asset
}
`,
comment: graphql`
@@ -142,6 +157,7 @@ const ReplyListContainer5 = createReplyListContainer(
node {
id
...CommentContainer_comment
...LocalReplyListContainer_comment
}
}
}
@@ -162,7 +178,9 @@ const ReplyListContainer5 = createReplyListContainer(
@arguments(count: $count, cursor: $cursor, orderBy: $orderBy)
}
}
`
`,
LastReplyList,
true
);
const ReplyListContainer4 = createReplyListContainer(
@@ -1,5 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`hide reply button 1`] = `
<React.Fragment>
<IndentedComment
author={
Object {
"id": "author-id",
"username": "Marvin",
}
}
blur={false}
body="Woof"
createdAt="1995-12-17T03:24:00.000Z"
footer={
<React.Fragment>
<withContext(withLocalStateContainer(PermalinkContainer))
commentID="comment-id"
/>
</React.Fragment>
}
id="comment-comment-id"
indentLevel={1}
showEditedMarker={false}
/>
</React.Fragment>
`;
exports[`renders body only 1`] = `
<React.Fragment>
<IndentedComment
@@ -39,6 +39,7 @@ exports[`renders correctly 1`] = `
}
disableShowAll={false}
indentLevel={1}
localReply={false}
me={null}
onShowAll={[Function]}
/>
@@ -85,6 +86,7 @@ exports[`when has more replies renders hasMore 1`] = `
disableShowAll={false}
hasMore={true}
indentLevel={1}
localReply={false}
me={null}
onShowAll={[Function]}
/>
@@ -129,6 +131,7 @@ exports[`when has more replies when showing all disables show all button 1`] = `
disableShowAll={true}
hasMore={true}
indentLevel={1}
localReply={false}
me={null}
onShowAll={[Function]}
/>
@@ -173,6 +176,7 @@ exports[`when has more replies when showing all enable show all button after loa
disableShowAll={false}
hasMore={true}
indentLevel={1}
localReply={false}
me={null}
onShowAll={[Function]}
/>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,110 @@
import { ReactTestRenderer } from "react-test-renderer";
import timekeeper from "timekeeper";
import { timeout } from "talk-common/utils";
import { createSinonStub } from "talk-framework/testHelpers";
import { assetWithDeepestReplies, users } from "../fixtures";
import create from "./create";
let testRenderer: ReactTestRenderer;
beforeEach(() => {
const resolvers = {
Query: {
asset: createSinonStub(
s => s.throws(),
s => s.returns(assetWithDeepestReplies)
),
me: createSinonStub(s => s.throws(), s => s.returns(users[0])),
},
Mutation: {
createComment: createSinonStub(
s => s.throws(),
s =>
s
.withArgs(undefined, {
input: {
assetID: assetWithDeepestReplies.id,
parentID: "comment-with-deepest-replies-5",
body: "<strong>Hello world!</strong>",
clientMutationId: "0",
},
})
.returns({
edge: {
cursor: null,
node: {
id: "comment-x",
author: users[0],
body: "<strong>Hello world! (from server)</strong>",
createdAt: "2018-07-06T18:24:00.000Z",
replies: {
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
},
editing: {
edited: false,
editableUntil: "2018-07-06T18:24:30.000Z",
},
},
},
clientMutationId: "0",
})
),
},
};
({ testRenderer } = create({
// Set this to true, to see graphql responses.
logNetwork: false,
resolvers,
initLocalState: localRecord => {
localRecord.setValue(assetWithDeepestReplies.id, "assetID");
},
}));
});
it("renders comment stream", async () => {
// Wait for loading.
await timeout();
expect(testRenderer.toJSON()).toMatchSnapshot();
});
it("post a reply", async () => {
// Wait for loading.
await timeout();
// Open reply form.
testRenderer.root
.findByProps({
id:
"comments-commentContainer-replyButton-comment-with-deepest-replies-5",
})
.props.onClick();
await timeout();
expect(testRenderer.toJSON()).toMatchSnapshot("open reply form");
// Write reply .
testRenderer.root
.findByProps({
inputId: "comments-replyCommentForm-rte-comment-with-deepest-replies-5",
})
.props.onChange({ html: "<strong>Hello world!</strong>" });
timekeeper.freeze(new Date("2018-07-06T18:24:00.000Z"));
testRenderer.root
.findByProps({
id: "comments-replyCommentForm-form-comment-with-deepest-replies-5",
})
.props.onSubmit();
// Test optimistic response.
expect(testRenderer.toJSON()).toMatchSnapshot("optimistic response");
timekeeper.reset();
// Wait for loading.
await timeout();
// Test after server response.
expect(testRenderer.toJSON()).toMatchSnapshot("server response");
});
+107
View File
@@ -171,3 +171,110 @@ export const assetWithDeepReplies = {
},
},
};
export const commentWithDeepestReplies = {
...commentWithReplies,
id: "comment-with-deepest-replies",
body: "body 0",
replies: {
...commentWithReplies.replies,
edges: [
{
cursor: commentWithReplies.createdAt,
node: {
...commentWithReplies,
id: "comment-with-deepest-replies-1",
body: "body 1",
replies: {
...commentWithReplies.replies,
edges: [
{
cursor: commentWithReplies.createdAt,
node: {
...commentWithReplies,
id: "comment-with-deepest-replies-2",
body: "body 2",
replies: {
...commentWithReplies.replies,
edges: [
{
cursor: commentWithReplies.createdAt,
node: {
...commentWithReplies,
id: "comment-with-deepest-replies-3",
body: "body 3",
replies: {
...commentWithReplies.replies,
edges: [
{
cursor: commentWithReplies.createdAt,
node: {
...commentWithReplies,
id: "comment-with-deepest-replies-4",
body: "body 4",
replies: {
...commentWithReplies.replies,
edges: [
{
cursor: commentWithReplies.createdAt,
node: {
...commentWithReplies,
id: "comment-with-deepest-replies-5",
body: "body 5",
replies: {
...commentWithReplies.replies,
edges: [
{
cursor:
commentWithReplies.createdAt,
node: {
...commentWithReplies,
id:
"comment-with-deepest-replies-6",
body: "body 6",
replies: {
...commentWithReplies.replies,
edges: [],
},
},
},
],
},
},
},
],
},
},
},
],
},
},
},
],
},
},
},
],
},
},
},
],
},
};
export const assetWithDeepestReplies = {
id: "asset-with-deepest-replies",
url: "http://localhost/assets/asset-with-replies",
isClosed: false,
comments: {
edges: [
{
node: commentWithDeepestReplies,
cursor: commentWithDeepestReplies.createdAt,
},
],
pageInfo: {
hasNextPage: false,
},
},
};