[next] Comment Reply Revisions (#2072)

* feat: added revision id support for replies

* feat: use createCommentReply
This commit is contained in:
Wyatt Johnson
2018-11-27 21:56:12 +00:00
committed by GitHub
parent 71ce74e1d1
commit a5c98cd587
14 changed files with 383 additions and 136 deletions
@@ -0,0 +1,17 @@
import { RecordSourceSelectorProxy } from "relay-runtime";
export default function incrementStoryCommentCounts(
store: RecordSourceSelectorProxy,
storyID: string
) {
// Updating Comment Count
const story = store.get(storyID);
if (story) {
const record = story.getLinkedRecord("commentCounts");
if (record) {
// TODO: when we have moderation, we'll need to be careful here.
const currentCount = record.getValue("totalVisible");
record.setValue(currentCount + 1, "totalVisible");
}
}
}
+6
View File
@@ -0,0 +1,6 @@
export {
default as incrementStoryCommentCounts,
} from "./incrementStoryCommentCounts";
export {
default as prependCommentEdgeToProfile,
} from "./prependCommentEdgeToProfile";
@@ -0,0 +1,25 @@
import {
ConnectionHandler,
Environment,
RecordProxy,
RecordSourceSelectorProxy,
} from "relay-runtime";
import { getMeSourceID } from "talk-framework/helpers";
export default function prependCommentEdgeToProfile(
environment: Environment,
store: RecordSourceSelectorProxy,
commentEdge: RecordProxy
) {
const meProxy = store.get(getMeSourceID(environment)!);
const con = ConnectionHandler.getConnection(
meProxy,
"CommentHistory_comments"
);
// Note: Currently this is always null, until Relay comes
// with better data retaintion and data from store support.
if (con) {
ConnectionHandler.insertEdgeBefore(con, commentEdge);
}
}
@@ -5,144 +5,69 @@ import {
RecordSourceSelectorProxy,
} from "relay-runtime";
import { getMe, getMeSourceID } from "talk-framework/helpers";
import { getMe } from "talk-framework/helpers";
import { TalkContext } from "talk-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
createMutationContainer,
} from "talk-framework/lib/relay";
import { Omit } from "talk-framework/types";
import { CreateCommentMutation as MutationTypes } from "talk-stream/__generated__/CreateCommentMutation.graphql";
import {
incrementStoryCommentCounts,
prependCommentEdgeToProfile,
} from "../helpers";
export type CreateCommentInput = Omit<
MutationTypes["variables"]["input"],
"clientMutationId"
> & { local?: boolean };
>;
function sharedUpdater(
environment: Environment,
store: RecordSourceSelectorProxy,
input: CreateCommentInput
) {
updateStory(store, input);
if (input.local) {
localUpdate(store, input);
} else {
update(store, input);
}
updateProfile(environment, store, input);
}
function updateStory(
store: RecordSourceSelectorProxy,
input: CreateCommentInput
) {
// Updating Comment Count
const story = store.get(input.storyID);
if (story) {
const record = story.getLinkedRecord("commentCounts");
if (record) {
// TODO: when we have moderation, we'll need to be careful here.
const currentCount = record.getValue("totalVisible");
record.setValue(currentCount + 1, "totalVisible");
}
}
incrementStoryCommentCounts(store, input.storyID);
prependCommentEdgeToProfile(
environment,
store,
store.getRootField("createComment")!.getLinkedRecord("edge")!
);
addCommentToStory(store, input);
}
/**
* update integrates new comment into the CommentConnection.
*/
function update(store: RecordSourceSelectorProxy, input: CreateCommentInput) {
function addCommentToStory(
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.storyID);
// Get stream proxy.
const streamProxy = store.get(input.storyID);
const connectionKey = "Stream_comments";
const filters = { orderBy: "CREATED_AT_DESC" };
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) {
if (streamProxy) {
const con = ConnectionHandler.getConnection(
parentProxy,
streamProxy,
connectionKey,
filters
);
if (con) {
if (where === "prepend") {
ConnectionHandler.insertEdgeBefore(con, newEdge);
} else {
ConnectionHandler.insertEdgeAfter(con, newEdge);
}
ConnectionHandler.insertEdgeBefore(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");
}
}
/**
* updateProfile integrates new comment into the profile.
*/
function updateProfile(
environment: Environment,
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 meProxy = store.get(getMeSourceID(environment)!);
const con = ConnectionHandler.getConnection(
meProxy,
"CommentHistory_comments"
);
// Note: Currently this is always null, until Relay comes
// with better data retaintion and data from store support.
if (con) {
ConnectionHandler.insertEdgeBefore(con, newEdge);
}
}
const mutation = graphql`
mutation CreateCommentMutation($input: CreateCommentInput!) {
createComment(input: $input) {
@@ -172,7 +97,6 @@ function commit(
variables: {
input: {
storyID: input.storyID,
parentID: input.parentID,
body: input.body,
clientMutationId: clientMutationId.toString(),
},
@@ -0,0 +1,178 @@
import { graphql } from "react-relay";
import {
ConnectionHandler,
Environment,
RecordSourceSelectorProxy,
} from "relay-runtime";
import { getMe } from "talk-framework/helpers";
import { TalkContext } from "talk-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
createMutationContainer,
} from "talk-framework/lib/relay";
import { Omit } from "talk-framework/types";
import { CreateCommentReplyMutation as MutationTypes } from "talk-stream/__generated__/CreateCommentReplyMutation.graphql";
import {
incrementStoryCommentCounts,
prependCommentEdgeToProfile,
} from "../helpers";
export type CreateCommentReplyInput = Omit<
MutationTypes["variables"]["input"],
"clientMutationId"
> & { local?: boolean };
function sharedUpdater(
environment: Environment,
store: RecordSourceSelectorProxy,
input: CreateCommentReplyInput
) {
incrementStoryCommentCounts(store, input.storyID);
prependCommentEdgeToProfile(
environment,
store,
store.getRootField("createCommentReply")!.getLinkedRecord("edge")!
);
if (input.local) {
addLocalCommentReplyToStory(store, input);
} else {
addCommentReplyToStory(store, input);
}
}
/**
* update integrates new comment into the CommentConnection.
*/
function addCommentReplyToStory(
store: RecordSourceSelectorProxy,
input: CreateCommentReplyInput
) {
// Get the payload returned from the server.
const payload = store.getRootField("createCommentReply")!;
// Get the edge of the newly created comment.
const newEdge = payload.getLinkedRecord("edge")!;
// Get parent proxy.
const parentProxy = store.get(input.parentID);
const connectionKey = "ReplyList_replies";
const filters = { orderBy: "CREATED_AT_ASC" };
if (parentProxy) {
const con = ConnectionHandler.getConnection(
parentProxy,
connectionKey,
filters
);
if (con) {
ConnectionHandler.insertEdgeAfter(con, newEdge);
}
}
}
/**
* localUpdate is like update but updates the `localReplies` endpoint.
*/
function addLocalCommentReplyToStory(
store: RecordSourceSelectorProxy,
input: CreateCommentReplyInput
) {
// Get the payload returned from the server.
const payload = store.getRootField("createCommentReply")!;
// 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 CreateCommentReplyMutation($input: CreateCommentReplyInput!) {
createCommentReply(input: $input) {
edge {
cursor
node {
...StreamContainer_comment @relay(mask: false)
}
}
clientMutationId
}
}
`;
let clientMutationId = 0;
function commit(
environment: Environment,
input: CreateCommentReplyInput,
{ uuidGenerator }: TalkContext
) {
const me = getMe(environment)!;
const currentDate = new Date().toISOString();
const id = uuidGenerator();
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation,
variables: {
input: {
storyID: input.storyID,
parentID: input.parentID,
parentRevisionID: input.parentRevisionID,
body: input.body,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
createCommentReply: {
edge: {
cursor: currentDate,
node: {
id,
createdAt: currentDate,
author: {
id: me.id,
username: me.username,
},
body: input.body,
editing: {
editableUntil: new Date(Date.now() + 10000),
},
actionCounts: {
reaction: {
total: 0,
},
},
},
},
clientMutationId: (clientMutationId++).toString(),
},
} as any, // TODO: (cvle) generated types should contain one for the optimistic response.
optimisticUpdater: store => {
sharedUpdater(environment, store, input);
store.get(id)!.setValue(true, "pending");
},
updater: store => {
sharedUpdater(environment, store, input);
},
});
}
export const withCreateCommentReplyMutation = createMutationContainer(
"createCommentReply",
commit
);
export type CreateCommentReplyMutation = (
input: CreateCommentReplyInput
) => Promise<MutationTypes["response"]["createCommentReply"]>;
@@ -3,6 +3,11 @@ export {
CreateCommentMutation,
CreateCommentInput,
} from "./CreateCommentMutation";
export {
withCreateCommentReplyMutation,
CreateCommentReplyMutation,
CreateCommentReplyInput,
} from "./CreateCommentReplyMutation";
export {
withEditCommentMutation,
EditCommentMutation,
@@ -19,7 +19,7 @@ function getContextKey(commentID: string) {
it("renders correctly", async () => {
const props: PropTypesOf<typeof ReplyCommentFormContainerN> = {
createComment: noop as any,
createCommentReply: noop as any,
story: {
id: "story-id",
},
@@ -28,6 +28,9 @@ it("renders correctly", async () => {
author: {
username: "Joe",
},
revision: {
id: "revision-id",
},
},
sessionStorage: createPromisifiedStorage(),
autofocus: false,
@@ -41,7 +44,7 @@ it("renders correctly", async () => {
it("renders with initialValues", async () => {
const props: PropTypesOf<typeof ReplyCommentFormContainerN> = {
createComment: noop as any,
createCommentReply: noop as any,
story: {
id: "story-id",
},
@@ -50,6 +53,9 @@ it("renders with initialValues", async () => {
author: {
username: "Joe",
},
revision: {
id: "revision-id",
},
},
sessionStorage: createPromisifiedStorage(),
autofocus: false,
@@ -68,7 +74,7 @@ it("renders with initialValues", async () => {
it("save values", async () => {
const props: PropTypesOf<typeof ReplyCommentFormContainerN> = {
createComment: noop as any,
createCommentReply: noop as any,
story: {
id: "story-id",
},
@@ -77,6 +83,9 @@ it("save values", async () => {
author: {
username: "Joe",
},
revision: {
id: "revision-id",
},
},
sessionStorage: createPromisifiedStorage(),
autofocus: false,
@@ -107,7 +116,7 @@ it("creates a comment", async () => {
const onCloseStub = sinon.stub();
const props: PropTypesOf<typeof ReplyCommentFormContainerN> = {
createComment: createCommentStub,
createCommentReply: createCommentStub,
story: {
id: "story-id",
},
@@ -116,6 +125,9 @@ it("creates a comment", async () => {
author: {
username: "Joe",
},
revision: {
id: "revision-id",
},
},
sessionStorage: createPromisifiedStorage(),
onClose: onCloseStub,
@@ -138,6 +150,7 @@ it("creates a comment", async () => {
createCommentStub.calledWith({
storyID,
parentID: props.comment.id,
parentRevisionID: "revision-id",
...input,
})
).toBeTruthy();
@@ -148,7 +161,7 @@ it("creates a comment", async () => {
it("closes on cancel", async () => {
const onCloseStub = sinon.stub();
const props: PropTypesOf<typeof ReplyCommentFormContainerN> = {
createComment: noop as any,
createCommentReply: noop as any,
story: {
id: "story-id",
},
@@ -157,6 +170,9 @@ it("closes on cancel", async () => {
author: {
username: "Joe",
},
revision: {
id: "revision-id",
},
},
sessionStorage: createPromisifiedStorage(),
onClose: onCloseStub,
@@ -186,7 +202,7 @@ it("autofocuses", async () => {
const focusStub = sinon.stub();
const rte = { focus: focusStub };
const props: PropTypesOf<typeof ReplyCommentFormContainerN> = {
createComment: noop as any,
createCommentReply: noop as any,
story: {
id: "story-id",
},
@@ -195,6 +211,9 @@ it("autofocuses", async () => {
author: {
username: "Joe",
},
revision: {
id: "revision-id",
},
},
sessionStorage: createPromisifiedStorage(),
autofocus: true,
@@ -10,8 +10,8 @@ import { PropTypesOf } from "talk-framework/types";
import { ReplyCommentFormContainer_comment as CommentData } from "talk-stream/__generated__/ReplyCommentFormContainer_comment.graphql";
import { ReplyCommentFormContainer_story as StoryData } from "talk-stream/__generated__/ReplyCommentFormContainer_story.graphql";
import {
CreateCommentMutation,
withCreateCommentMutation,
CreateCommentReplyMutation,
withCreateCommentReplyMutation,
} from "talk-stream/mutations";
import ReplyCommentForm, {
@@ -19,7 +19,7 @@ import ReplyCommentForm, {
} from "../components/ReplyCommentForm";
interface InnerProps {
createComment: CreateCommentMutation;
createCommentReply: CreateCommentReplyMutation;
sessionStorage: PromisifiedStorage;
comment: CommentData;
story: StoryData;
@@ -74,9 +74,10 @@ export class ReplyCommentFormContainer extends Component<InnerProps, State> {
form
) => {
try {
await this.props.createComment({
await this.props.createCommentReply({
storyID: this.props.story.id,
parentID: this.props.comment.id,
parentRevisionID: this.props.comment.revision.id,
local: this.props.localReply,
...input,
});
@@ -127,7 +128,7 @@ const enhanced = withContext(({ sessionStorage, browserInfo }) => ({
// Disable autofocus on ios and enable for the rest.
autofocus: !browserInfo.ios,
}))(
withCreateCommentMutation(
withCreateCommentReplyMutation(
withFragmentContainer<InnerProps>({
story: graphql`
fragment ReplyCommentFormContainer_story on Story {
@@ -140,6 +141,9 @@ const enhanced = withContext(({ sessionStorage, browserInfo }) => ({
author {
username
}
revision {
id
}
}
`,
})(ReplyCommentFormContainer)
@@ -28,7 +28,7 @@ beforeEach(() => {
),
},
Mutation: {
createComment: createSinonStub(
createCommentReply: createSinonStub(
s => s.throws(),
s =>
s
@@ -36,6 +36,7 @@ beforeEach(() => {
input: {
storyID: storyWithDeepestReplies.id,
parentID: "comment-with-deepest-replies-5",
parentRevisionID: "revision-0",
body: "<strong>Hello world!</strong>",
clientMutationId: "0",
},
@@ -23,7 +23,7 @@ beforeEach(() => {
),
},
Mutation: {
createComment: createSinonStub(
createCommentReply: createSinonStub(
s => s.throws(),
s =>
s
@@ -31,6 +31,7 @@ beforeEach(() => {
input: {
storyID: stories[0].id,
parentID: stories[0].comments.edges[0].node.id,
parentRevisionID: stories[0].comments.edges[0].node.revision.id,
body: "<strong>Hello world!</strong>",
clientMutationId: "0",
},
@@ -4,6 +4,7 @@ import {
GQLCreateCommentFlagInput,
GQLCreateCommentInput,
GQLCreateCommentReactionInput,
GQLCreateCommentReplyInput,
GQLEditCommentInput,
GQLRemoveCommentDontAgreeInput,
GQLRemoveCommentFlagInput,
@@ -20,12 +21,15 @@ import {
} from "talk-server/services/comments/actions";
export const Comment = (ctx: TenantContext) => ({
create: ({ storyID, body, parentID }: GQLCreateCommentInput) =>
create: ({
clientMutationId,
...comment
}: GQLCreateCommentInput | GQLCreateCommentReplyInput) =>
create(
ctx.mongo,
ctx.tenant,
ctx.user!,
{ authorID: ctx.user!.id, storyID, body, parentID },
{ authorID: ctx.user!.id, ...comment },
ctx.req
),
edit: ({ commentID, body }: GQLEditCommentInput) =>
@@ -1,24 +1,30 @@
import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
export const Mutation: GQLMutationTypeResolver<void> = {
export const Mutation: Required<GQLMutationTypeResolver<void>> = {
editComment: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.edit(input),
clientMutationId: input.clientMutationId,
}),
createComment: async (source, { input }, ctx) => {
const comment = await ctx.mutators.Comment.create(input);
return {
edge: {
// NOTE: (cvle)
// Depending on the sort we can't determine the accurate cursor in a
// performant way, so we return null instead. It seems that Relay does
// not directly use this value.
cursor: null,
node: comment,
},
clientMutationId: input.clientMutationId,
};
},
createComment: async (source, { input }, ctx) => ({
edge: {
// Depending on the sort we can't determine the accurate cursor in a
// performant way, so we return null instead. It seems that Relay does
// not directly use this value.
cursor: null,
node: await ctx.mutators.Comment.create(input),
},
clientMutationId: input.clientMutationId,
}),
createCommentReply: async (source, { input }, ctx) => ({
edge: {
// Depending on the sort we can't determine the accurate cursor in a
// performant way, so we return null instead. It seems that Relay does
// not directly use this value.
cursor: null,
node: await ctx.mutators.Comment.create(input),
},
clientMutationId: input.clientMutationId,
}),
updateSettings: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.update(input.settings),
clientMutationId: input.clientMutationId,
@@ -1529,11 +1529,6 @@ input CreateCommentInput {
"""
storyID: ID!
"""
parentID is the optional ID of the Comment that we are replying to.
"""
parentID: ID
"""
body is the Comment body, the content of the Comment.
"""
@@ -1561,6 +1556,56 @@ type CreateCommentPayload {
clientMutationId: String!
}
##################
## createCommentReply
##################
"""
CreateCommentReplyInput provides the input for the createCommentReply Mutation.
"""
input CreateCommentReplyInput {
"""
storyID is the ID of the Story where we are creating a comment on.
"""
storyID: ID!
"""
parentID is the ID of the Comment that we are replying to.
"""
parentID: ID!
"""
parentRevisionID is the ID of the CommentRevision that we are replying to.
"""
parentRevisionID: ID!
"""
body is the Comment body, the content of the Comment.
"""
body: String!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
"""
CreateCommentReplyPayload contains the created Comment after the createCommentReply
mutation.
"""
type CreateCommentReplyPayload {
"""
edge is the possibly created comment edge.
"""
edge: CommentEdge
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## editComment
##################
@@ -2805,6 +2850,12 @@ type Mutation {
"""
createComment(input: CreateCommentInput!): CreateCommentPayload
"""
createCommentReply will create a Comment as the current logged in User that is
in reply to another Comment.
"""
createCommentReply(input: CreateCommentReplyInput!): CreateCommentReplyPayload
"""
editComment will allow the author of a comment to change the body within the
time allotment.
+6
View File
@@ -64,6 +64,12 @@ export interface Comment extends TenantResource {
*/
parentID?: string;
/**
* parentRevisionID is the ID of the Revision on the parent Comment that this
* was a reply to.
*/
parentRevisionID?: string;
/**
* authorID stores the ID of the User that created this Comment.
*/