Skip to content

Commit

Permalink
Add edit button (#107)
Browse files Browse the repository at this point in the history
* Clear cookies before tests

* Add CommentEdit to CommentDisplay

* Test edit button

* Add PUT api

* Update question of the day

* Add general error message

* Add edit button
  • Loading branch information
rendall authored Aug 31, 2023
1 parent 0835ffa commit 74ab154
Show file tree
Hide file tree
Showing 10 changed files with 388 additions and 20 deletions.
67 changes: 61 additions & 6 deletions cypress/e2e/generic/public-comment.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@ import {
} from "../../../src/tests/mockData"

describe("Guest comment", { testIsolation: false }, () => {
const commentText = generateRandomCopy()
let userId
before(() => {
cy.clearAllLocalStorage()
cy.clearAllCookies()
})

beforeEach(() => {
cy.clearLocalStorage("simple_comment_login_tab")
cy.visit("/")
})

it("Submit a comment as a guest user", () => {
const commentText = generateRandomCopy()
cy.intercept("POST", ".netlify/functions/comment/*").as("postComment")
cy.intercept("GET", ".netlify/functions/user/*").as("getUser")
cy.get("form.comment-form #guest-email").clear().type("fake@email.com")
Expand All @@ -34,17 +37,69 @@ describe("Guest comment", { testIsolation: false }, () => {
})
})

it("Edit a comment as a logged-in guest user", () => {
const commentText = generateRandomCopy()
cy.intercept("PUT", ".netlify/functions/comment/*").as("putComment")
cy.get(".comment-edit-button").first().click()
cy.get("form.comment-form .comment-field").clear().type(commentText)
cy.get("form.comment-form .comment-update-button").click()
cy.wait("@putComment").its("response.statusCode").should("eq", 204) // 204
cy.contains("article.comment-body p", commentText).should("exist")
})

it("Editing should handle errors", () => {
const commentText = generateRandomCopy()
let errorReply = true
// Stub the first response to error out
// then pass the next response through
cy.intercept("PUT", ".netlify/functions/comment/*", req => {
if (errorReply) {
errorReply = false
req.reply({
statusCode: 500,
body: "Error response body",
})
} else req.reply()
}).as("putComment")
cy.get(".comment-edit-button").first().click()

cy.get("form.comment-form .comment-field").clear().type(commentText)
cy.get("form.comment-form .comment-update-button").click()
cy.wait("@putComment")

cy.get("form.comment-form .comment-update-button").click()

cy.wait("@putComment").then(interception => {
expect(interception.response.statusCode).to.equal(204)
})
cy.contains("article.comment-body p", commentText).should("exist")
})

it("Delete a comment as a logged-in guest user", () => {
cy.intercept("DELETE", ".netlify/functions/comment/*").as("deleteComment")
cy.get(".comment-delete-button").first().click()
cy.wait("@deleteComment").its("response.statusCode").should("eq", 202) // 202 Accepted
cy.contains("article.comment-body p", commentText).should("not.exist")
cy.get(".comment-delete-button").as("deleteButton")
cy.get("@deleteButton")
.closest("article.comment-body")
.children("p")
.first()
.as("articleBody")
cy.get("@articleBody")
.invoke("text")
.then(articleBodyText => {
expect(articleBodyText).not.to.be.undefined
cy.contains("article.comment-body p", articleBodyText).should("exist")
cy.get("@deleteButton").click()
cy.wait("@deleteComment").its("response.statusCode").should("eq", 202) // 202 Accepted
cy.contains("article.comment-body p", articleBodyText).should(
"not.exist"
)
})
})

it("Reply to a comment as a logged-in guest", () => {
cy.get("button.comment-reply-button").first().as("replyButton")
cy.get("@replyButton").closest("article.comment-body").as("commentBody")
cy.get("@replyButton").first().click()
cy.get("@replyButton").click()
cy.get("form.guest-login-form").should("not.exist")
})

Expand Down
17 changes: 17 additions & 0 deletions src/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,23 @@ export const postComment = (
credentials: "include",
}).then(res => resolveBody<Comment>(res))

/** PUT (update) an edited, existing comment
* @async
* @function
* @param {string} commentId - comment to update
* @param {string} text - the updated comment copy
* @returns {ServerResponse}
*/
export const putComment = (
commentId: CommentId,
text: string
): Promise<ServerResponse<Comment>> =>
fetch(`${getSimpleCommentURL()}/comment/${commentId}`, {
body: text,
method: "PUT",
credentials: "include",
}).then(res => resolveBody<Comment>(res))

/** Delete a comment
* @async
* @function
Expand Down
61 changes: 53 additions & 8 deletions src/components/CommentDisplay.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,22 @@
import CommentInput from "./CommentInput.svelte"
import CommentList from "./CommentList.svelte"
import SkeletonCommentDelete from "./low-level/SkeletonCommentDelete.svelte"
import CommentEdit from "./CommentEdit.svelte"
export let comment: (Comment & { isNew?: true }) | undefined = undefined
export let showReply: string
export let currentUser: User | undefined
export let onDeleteSuccess
export let onDeleteCommentClick
export let onOpenCommentInput
export let onCommentPosted
export let onPostSuccess
export let onUpdateSuccess
export let depth
export let handleReplyEvent
const isRoot = depth === 0
let commentDeleted
let isEditing = false
let commentBodyHeight = 74
let commentBodyRef
Expand All @@ -30,8 +33,28 @@
// "refs" object keeps them sorted by id.
let refs = {}
// Can edit for two hours
const canEdit = (comment: Comment): boolean =>
new Date().valueOf() - new Date(comment.dateCreated).valueOf() <= 7200000
const onEditClick = (comment: Comment) => {
const commentId = comment?.id
onOpenCommentInput(commentId)()
isEditing = true
}
const onCancelEditClick = () => {
isEditing = false
onCloseCommentInput()
}
const onCloseCommentInput = onOpenCommentInput("")
const onCommentTextUpdated = text => {
isEditing = false
onUpdateSuccess({ ...comment, text })
}
const onDeleteClick = () => {
commentDeleted = comment.id
commentBodyHeight = refs[comment.id] ?? 74
Expand Down Expand Up @@ -95,25 +118,47 @@
</div>
</header>
<article class="comment-body" bind:this={commentBodyRef}>
{#each toParagraphs(comment.text) as paragraph}
<p>{paragraph}</p>
{/each}
{#if showReply === comment.id}
{#if isEditing}
<CommentEdit
placeholder="Your edit"
autofocus={isRoot ? true : false}
commentId={comment.id}
commentText={comment.text}
{currentUser}
onCancel={onCancelEditClick}
onTextUpdated={onCommentTextUpdated}
/>
{:else}
{#each toParagraphs(comment.text) as paragraph}
<p>{paragraph}</p>
{/each}
{/if}
{#if showReply === comment.id && !isEditing}
<CommentInput
placeholder="Your reply"
autofocus={isRoot ? true : false}
commentId={comment.id}
{currentUser}
onCancel={onCloseCommentInput}
on:posted={onCommentPosted}
on:posted={onPostSuccess}
/>
{:else}
{:else if !isEditing}
<div class="button-row comment-footer">
{#if currentUser && currentUser?.id === comment.user?.id && canEdit(comment)}
<button
on:click={() => onEditClick(comment)}
class="comment-edit-button"
>
Edit
</button>
{/if}

{#if currentUser?.isAdmin || (currentUser && currentUser?.id === comment.user?.id && !comment?.replies?.length)}
<button on:click={onDeleteClick} class="comment-delete-button">
Delete
</button>
{/if}

<button
on:click={onOpenCommentInput(comment.id)}
class="comment-reply-button"
Expand All @@ -128,7 +173,7 @@
<CommentList
depth={depth + 1}
on:delete={onDeleteSuccess}
on:posted={onCommentPosted}
on:posted={onPostSuccess}
on:reply={handleReplyEvent}
replies={comment.replies}
{currentUser}
Expand Down
139 changes: 139 additions & 0 deletions src/components/CommentEdit.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<script lang="ts">
import SkeletonCommentInput from "./low-level/SkeletonCommentInput.svelte"
import type { CommentId } from "../lib/simple-comment-types"
import { StateValue } from "xstate"
import { commentEditMachine } from "../lib/commentEdit.xstate"
import { onMount } from "svelte"
import { isResponseOk } from "../frontend-utilities"
import { putComment } from "../apiClient"
import { useMachine } from "@xstate/svelte"
export let commentId: CommentId
export let onCancel = null
export let autofocus = false
export let placeholder = "Your comment"
export let commentText = ""
export let onTextUpdated
let textareaRef
let textAreaWidth = "100%"
let textAreaHeight = "7rem"
let originalText = ""
let errorText
const { state, send } = useMachine(commentEditMachine)
const onSubmit = e => {
e.preventDefault()
send("SUBMIT")
}
const updatingStateHandler = async () => {
if (commentText === originalText || commentText.trim() === "") {
if (onCancel) onCancel()
return
}
try {
const response = await putComment(commentId, commentText)
if (isResponseOk(response)) send({ type: "SUCCESS", commentText })
else send({ type: "ERROR", error: response })
} catch (error) {
send({ type: "ERROR", error })
}
}
const updatedStateHandler = () => {
const { commentText } = $state.context
onTextUpdated(commentText)
send({ type: "RESET" })
}
const errorStateHandler = () => {
const error = $state.context.error
if (!error) {
console.error("Unknown error")
console.trace()
errorText = "An unknown error has occured. Try reloading the page."
} else if (typeof error === "string") {
console.error(error)
errorText = error
} else if (error.ok) {
console.warn("Error handler caught an OK response", error)
} else {
const { status, body } = error
switch (status) {
case 400:
errorText =
body === "Comment text is same"
? "The comment is identical. Edit the comment and push 'Update comment'"
: "The comment was rejected."
break
default:
errorText =
"An unknown error has occurred. Possibly the server is unavailable. Try reloading the page."
break
}
}
// At this stage the error messages should already be present on the page
send({ type: "RESET" })
}
const resizeObserver = new ResizeObserver(([textArea]) => {
if (isProcessing) return
const { inlineSize, blockSize } = textArea.borderBoxSize[0] ?? {
inlineSize: "100%",
blockSize: "7rem",
}
textAreaWidth = `${inlineSize}px`
textAreaHeight = `${blockSize}px`
})
onMount(() => {
resizeObserver.observe(textareaRef)
originalText = commentText
})
$: {
const stateHandlers: [StateValue, () => void][] = [
["updating", updatingStateHandler],
["updated", updatedStateHandler],
["error", errorStateHandler],
]
errorText = ""
stateHandlers.forEach(([stateValue, stateHandler]) => {
if ($state.value === stateValue) stateHandler()
})
}
$: isProcessing = (["updating"] as StateValue[]).includes($state.value)
</script>

<SkeletonCommentInput
width={textAreaWidth}
height={textAreaHeight}
isHidden={!isProcessing}
/>
<form class="comment-form" class:is-hidden={isProcessing} on:submit={onSubmit}>
<!-- svelte-ignore a11y-autofocus -->
<textarea
class="comment-field"
bind:this={textareaRef}
bind:value={commentText}
{autofocus}
{placeholder}
/>
{#if errorText && errorText.length > 0}
<p class="is-error">{errorText}</p>
{/if}
<div class="button-row">
{#if onCancel !== null}
<button class="comment-cancel-button" type="button" on:click={onCancel}>
Cancel
</button>
{/if}
<button class="comment-update-button" type="submit">Update comment</button>
</div>
</form>
Loading

0 comments on commit 74ab154

Please sign in to comment.