Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions apps/web/e2e/tests/public/comments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ test.describe('Unauthenticated user — comments section', () => {
await expect(signInButton).toBeVisible()
})

// -------------------------------------------------------------------------
test('Edit button is NOT shown to unauthenticated users', async ({ page }) => {
const commentItems = page.locator('[id^="comment-"]')
if ((await commentItems.count()) === 0) return
await expect(page.getByRole('button', { name: /^edit$/i })).toHaveCount(0, { timeout: 5000 })
})

// -------------------------------------------------------------------------
test('existing comments are visible to unauthenticated users', async ({ page }) => {
// The comment list is always rendered regardless of auth state.
Expand Down Expand Up @@ -599,3 +606,181 @@ test.describe('Edge cases — comment content', () => {
}
})
})

// ===========================================================================
// COMMENT EDITING (authenticated)
// ===========================================================================
test.describe('Comment editing', () => {
test.setTimeout(90000)

let sharedContext: BrowserContext

test.beforeAll(async ({ browser }) => {
sharedContext = await browser.newContext()
const page = await sharedContext.newPage()
await authenticateViaOTP(page)
await page.close()
})

test.afterAll(async () => {
if (sharedContext) await sharedContext.close()
})

/** Submit a new comment and return a stable element ID + the unique text used. */
async function submitAndLocate(page: Page): Promise<{ elementId: string; uniqueText: string }> {
await goToFirstPost(page)
const uniqueText = `Edit test ${Date.now()}`
const textarea = page.locator('textarea[placeholder*="Write a comment" i]').first()
await textarea.fill(uniqueText)
await page.getByRole('button', { name: /^comment$/i }).first().click()
await expect(textarea).toHaveValue('', { timeout: 10000 })
await expect(page.getByText(uniqueText)).toBeVisible({ timeout: 10000 })
// Wait for this specific comment to receive a real server ID (not optimistic placeholder)
await page.waitForFunction(
(text) =>
Array.from(document.querySelectorAll('[id^="comment-"]')).some(
(el) => el.textContent?.includes(text) && !el.id.includes('optimistic')
),
uniqueText
)
const commentItem = page.locator('[id^="comment-"]').filter({ hasText: uniqueText }).first()
const elementId = await commentItem.getAttribute('id')
if (!elementId) throw new Error(`Could not resolve element ID for comment: ${uniqueText}`)
return { elementId, uniqueText }
}

// -------------------------------------------------------------------------
test('Edit button is visible on own comments', async () => {
const page = await sharedContext.newPage()
try {
const { uniqueText } = await submitAndLocate(page)
const commentItem = page.locator('[id^="comment-"]').filter({ hasText: uniqueText }).first()
await expect(commentItem.getByRole('button', { name: /^edit$/i })).toBeVisible({
timeout: 5000,
})
} finally {
await page.close()
}
})

// -------------------------------------------------------------------------
test('clicking Edit opens an inline form with the current content', async () => {
const page = await sharedContext.newPage()
try {
const { elementId, uniqueText } = await submitAndLocate(page)
const comment = page.locator(`[id="${elementId}"]`)

await comment.getByRole('button', { name: /^edit$/i }).click()

const editTextarea = comment.getByTestId('edit-comment-textarea')
await expect(editTextarea).toBeVisible({ timeout: 5000 })
await expect(editTextarea).toHaveValue(uniqueText)
// Edit button is replaced by Save / Cancel
await expect(comment.getByRole('button', { name: /^edit$/i })).not.toBeVisible()
await expect(comment.getByRole('button', { name: /^save$/i })).toBeVisible()
// The reply form also renders a hidden Cancel, so use first() to target the edit Cancel
await expect(comment.getByRole('button', { name: /^cancel$/i }).first()).toBeVisible()
} finally {
await page.close()
}
})

// -------------------------------------------------------------------------
test('Cancel closes the edit form and restores the original content', async () => {
const page = await sharedContext.newPage()
try {
const { elementId, uniqueText } = await submitAndLocate(page)
const comment = page.locator(`[id="${elementId}"]`)

await comment.getByRole('button', { name: /^edit$/i }).click()
await comment.getByTestId('edit-comment-textarea').fill('should not be saved')
await comment.getByRole('button', { name: /^cancel$/i }).first().click()

await expect(comment.getByTestId('edit-comment-textarea')).not.toBeVisible({ timeout: 5000 })
await expect(comment.getByText(uniqueText)).toBeVisible()
} finally {
await page.close()
}
})

// -------------------------------------------------------------------------
test('Escape closes the edit form without saving', async () => {
const page = await sharedContext.newPage()
try {
const { elementId, uniqueText } = await submitAndLocate(page)
const comment = page.locator(`[id="${elementId}"]`)

await comment.getByRole('button', { name: /^edit$/i }).click()
const editTextarea = comment.getByTestId('edit-comment-textarea')
await expect(editTextarea).toBeVisible({ timeout: 5000 })

await editTextarea.press('Escape')

await expect(editTextarea).not.toBeVisible({ timeout: 5000 })
await expect(comment.getByText(uniqueText)).toBeVisible()
} finally {
await page.close()
}
})

// -------------------------------------------------------------------------
test('Save updates the comment and shows an (edited) marker', async () => {
const page = await sharedContext.newPage()
try {
const { elementId, uniqueText } = await submitAndLocate(page)
const comment = page.locator(`[id="${elementId}"]`)

await comment.getByRole('button', { name: /^edit$/i }).click()
const editedText = `Edited: ${uniqueText}`
await comment.getByTestId('edit-comment-textarea').fill(editedText)
await comment.getByRole('button', { name: /^save$/i }).click()

await expect(comment.getByTestId('edit-comment-textarea')).not.toBeVisible({
timeout: 10000,
})
await expect(comment.getByText(editedText)).toBeVisible({ timeout: 10000 })
await expect(comment.getByText('(edited)')).toBeVisible({ timeout: 10000 })
} finally {
await page.close()
}
})

// -------------------------------------------------------------------------
test('Cmd+Enter saves the edit', async () => {
const page = await sharedContext.newPage()
try {
const { elementId, uniqueText } = await submitAndLocate(page)
const comment = page.locator(`[id="${elementId}"]`)

await comment.getByRole('button', { name: /^edit$/i }).click()
const editedText = `Cmd-Enter edit: ${uniqueText}`
const editTextarea = comment.getByTestId('edit-comment-textarea')
await editTextarea.fill(editedText)
await editTextarea.press('Meta+Enter')

await expect(editTextarea).not.toBeVisible({ timeout: 10000 })
await expect(comment.getByText(editedText)).toBeVisible({ timeout: 10000 })
await expect(comment.getByText('(edited)')).toBeVisible({ timeout: 10000 })
} finally {
await page.close()
}
})

// -------------------------------------------------------------------------
test('Save button is disabled when the edit content is empty', async () => {
const page = await sharedContext.newPage()
try {
const { elementId } = await submitAndLocate(page)
const comment = page.locator(`[id="${elementId}"]`)

await comment.getByRole('button', { name: /^edit$/i }).click()
await comment.getByTestId('edit-comment-textarea').fill('')

await expect(comment.getByRole('button', { name: /^save$/i })).toBeDisabled({
timeout: 5000,
})
} finally {
await page.close()
}
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function toPortalComments(post: PostDetails): PublicCommentView[] {
parentId: c.parentId as CommentId | null,
isTeamMember: c.isTeamMember,
isPrivate: c.isPrivate,
isEdited: !!c.updatedAt,
avatarUrl: (c.principalId && post.avatarUrls?.[c.principalId]) || null,
statusChange: c.statusChange ?? null,
reactions: c.reactions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ function MergePreviewContent({
!!c.deletedAt && !!c.deletedByPrincipalId && c.deletedByPrincipalId !== c.principalId,
parentId: c.parentId as CommentId | null,
isTeamMember: c.isTeamMember,
isEdited: !!c.updatedAt,
avatarUrl: c.avatarUrl ?? null,
statusChange: c.statusChange ?? null,
reactions: c.reactions,
Expand Down
39 changes: 3 additions & 36 deletions apps/web/src/components/admin/roadmap-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,10 @@ import {
} from '@/lib/client/mutations'
import { addPostToRoadmapFn, removePostFromRoadmapFn } from '@/lib/server/functions/roadmaps'
import { Route } from '@/routes/admin/roadmap'
import {
type PostId,
type StatusId,
type TagId,
type RoadmapId,
type CommentId,
} from '@quackback/ids'
import { type PostId, type StatusId, type TagId, type RoadmapId } from '@quackback/ids'
import type { PostDetails, CurrentUser } from '@/lib/shared/types'
import type { PublicPostDetailView } from '@/lib/client/queries/portal-detail'
import { toPortalComments } from '@/components/admin/feedback/detail/post-utils'

interface RoadmapModalProps {
postId: string | undefined
Expand Down Expand Up @@ -63,35 +58,7 @@ function toPortalPostView(post: PostDetails): PublicPostDetailView {
board: post.board,
tags: post.tags,
roadmaps: [],
comments: post.comments.map((c) => ({
id: c.id as CommentId,
content: c.content,
authorName: c.authorName,
principalId: c.principalId,
createdAt: c.createdAt,
deletedAt: c.deletedAt ?? null,
isRemovedByTeam:
!!c.deletedAt && !!c.deletedByPrincipalId && c.deletedByPrincipalId !== c.principalId,
parentId: c.parentId as CommentId | null,
isTeamMember: c.isTeamMember,
avatarUrl: (c.principalId && post.avatarUrls?.[c.principalId]) || null,
reactions: c.reactions,
replies: c.replies.map((r) => ({
id: r.id as CommentId,
content: r.content,
authorName: r.authorName,
principalId: r.principalId,
createdAt: r.createdAt,
deletedAt: r.deletedAt ?? null,
isRemovedByTeam:
!!r.deletedAt && !!r.deletedByPrincipalId && r.deletedByPrincipalId !== r.principalId,
parentId: r.parentId as CommentId | null,
isTeamMember: r.isTeamMember,
avatarUrl: (r.principalId && post.avatarUrls?.[r.principalId]) || null,
reactions: r.reactions,
replies: [],
})),
})),
comments: toPortalComments(post),
pinnedComment: post.pinnedComment,
pinnedCommentId: post.pinnedCommentId,
}
Expand Down
Loading
Loading