mirror of
https://github.com/wassname/talk.git
synced 2026-07-02 21:23:06 +08:00
Implement local reply list for last threading level
This commit is contained in:
@@ -21,7 +21,7 @@ type AuthPopup {
|
||||
|
||||
extend type Comment {
|
||||
pending: Boolean
|
||||
localReplies: CommentsConnection
|
||||
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,79 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +108,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 +120,9 @@ function commit(
|
||||
mutation,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
assetID: input.assetID,
|
||||
parentID: input.parentID,
|
||||
body: input.body,
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
@@ -102,9 +147,13 @@ 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);
|
||||
},
|
||||
// configs: getConfig(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;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const Indent: StatelessComponent<IndentProps> = props => {
|
||||
[styles.level3]: props.level === 3,
|
||||
[styles.level4]: props.level === 4,
|
||||
[styles.level5]: props.level === 5,
|
||||
[styles.level6]: props.level === 6,
|
||||
[styles.noBorder]: props.noBorder,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface ReplyListProps {
|
||||
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>
|
||||
|
||||
@@ -26,6 +26,8 @@ interface InnerProps {
|
||||
asset: AssetData;
|
||||
indentLevel?: number;
|
||||
showAuthPopup: ShowAuthPopupMutation;
|
||||
localReply?: boolean;
|
||||
disableReplies?: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -107,7 +109,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 +152,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 +168,7 @@ export class CommentContainer extends Component<InnerProps, State> {
|
||||
comment={comment}
|
||||
asset={asset}
|
||||
onClose={this.closeReplyDialog}
|
||||
localReply={localReply}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -25,9 +25,10 @@ export class LocalReplyListContainer extends Component<InnerProps> {
|
||||
<ReplyList
|
||||
me={this.props.me}
|
||||
comment={this.props.comment}
|
||||
comments={this.props.comment.localReplies.edges.map(e => e.node)}
|
||||
comments={this.props.comment.localReplies}
|
||||
asset={this.props.asset}
|
||||
indentLevel={this.props.indentLevel}
|
||||
disableReplies
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -48,12 +49,8 @@ const enhanced = withFragmentContainer<InnerProps>({
|
||||
fragment LocalReplyListContainer_comment on Comment {
|
||||
id
|
||||
localReplies {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...CommentContainer_comment
|
||||
}
|
||||
}
|
||||
id
|
||||
...CommentContainer_comment
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -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 null", () => {
|
||||
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;
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface InnerProps {
|
||||
relay: RelayPaginationProp;
|
||||
indentLevel: number;
|
||||
ReplyListComponent: React.ComponentType<any> | undefined;
|
||||
localReply: boolean | undefined;
|
||||
}
|
||||
|
||||
// TODO: (cvle) This should be autogenerated.
|
||||
@@ -54,6 +55,7 @@ export class ReplyListContainer extends React.Component<InnerProps> {
|
||||
disableShowAll={this.state.disableShowAll}
|
||||
indentLevel={this.props.indentLevel}
|
||||
ReplyListComponent={this.props.ReplyListComponent}
|
||||
localReply={this.props.localReply}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -85,9 +87,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,
|
||||
@@ -170,7 +173,8 @@ const ReplyListContainer5 = createReplyListContainer(
|
||||
`,
|
||||
(props: PropTypesOf<typeof LocalReplyListContainer>) => (
|
||||
<LocalReplyListContainer {...props} indentLevel={6} />
|
||||
)
|
||||
),
|
||||
true
|
||||
);
|
||||
|
||||
const ReplyListContainer4 = createReplyListContainer(
|
||||
|
||||
+4
@@ -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");
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user