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
20 changes: 10 additions & 10 deletions .github/extensions/replica-prototype/assets/renderer-vendor.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .github/extensions/replica-prototype/assets/renderer.css

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions .github/extensions/replica-prototype/assets/renderer.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -567,16 +567,16 @@ async function routeRequest(
const method = request.method
const valid = !operation
? ["PATCH", "DELETE"].includes(method)
: operation === "replies" || operation === "read"
: operation === "replies"
? method === "POST" && !content
: operation === "resolution"
: operation === "resolution" || operation === "read"
? ["POST", "DELETE"].includes(method) && !content
: (method === "POST" && !content) || (method === "DELETE" && !!content)
if (!valid) throw httpError(405, "Review action not supported.")
const body =
(!operation && method === "PATCH") ||
operation === "replies" ||
operation === "read" ||
(operation === "read" && method === "POST") ||
operation === "reactions"
? await readJson(request)
: undefined
Expand All @@ -587,7 +587,7 @@ async function routeRequest(
await hub.request(`${await reviewPath(hub)}/${id}${suffix}`, {
method,
body,
interactive: operation !== "read",
interactive: operation !== "read" || method === "DELETE",
}),
)
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1067,6 +1067,20 @@ esac
.every(({ activity }) => activity === undefined),
)
assert.equal((await send("/api/hub/comments/9/resolution", "POST")).status, 200)
assert.equal(
(
await send("/api/hub/comments/9/read", "POST", {
review_thread_read: { activity_version: 1 },
})
).status,
200,
)
assert.equal(calls.at(-1).activity, undefined)
assert.equal((await send("/api/hub/comments/9/read", "DELETE")).status, 200)
assert.ok(calls.at(-1).path.endsWith("/review_comments/9/read"))
assert.equal(calls.at(-1).method, "DELETE")
assert.equal(calls.at(-1).body, "")
assert.equal(calls.at(-1).activity, "interactive")
assert.equal(
(
await send("/api/hub/comments/9/reactions/%2B1", "DELETE", {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,12 @@ def create
root.thread.record_read_by! Current.user, activity_version: positive_activity_version(value)
render_review_comment_change root, broadcast: false
end

# Clears only the paired reviewer's read state.
# @route DELETE /api/canvas/repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:review_comment_id/read (canvas_api_review_comment_read)
def destroy
root = @prototype.review_comments.roots.find(params[:review_comment_id])
root.thread.record_unread_by! Current.user
render_review_comment_change root, broadcast: false
end
end
8 changes: 8 additions & 0 deletions hub/app/controllers/review_thread_reads_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ def create
render_review_comment_change root, broadcast: false
end

# Clears only the current reviewer's read state.
# @route DELETE /repositories/:repository_github_id/prototypes/:prototype_id/review_comments/:review_comment_id/read (repository_prototype_review_comment_read)
def destroy
root = @prototype.review_comments.roots.find(params[:review_comment_id])
root.thread.record_unread_by! Current.user
render_review_comment_change root, broadcast: false
end

private

# Requires the browser to name the positive activity version it displayed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,30 @@ afterEach(() => {
})

describe("PublicationDemo()", () => {
it("shows the updated comments in the complete canvas sidebar without contacting Hub", async () => {
const externalFetch = vi.fn(() => Promise.reject(new Error("Unexpected external request.")))
vi.stubGlobal("fetch", externalFetch)
const Demo = story.component
const view = render(
<Demo scenario="Published" prototypeUrl="http://localhost:4321/" withComments />,
{ wrapper: HubThemeProvider },
)
fireEvent.click(await screen.findByRole("button", { name: /^Comments, 3/ }, { timeout: 5_000 }))
const sidebar = screen.getByRole("complementary", { name: "Comments" })
expect(sidebar).toHaveClass("hub-sidebar")
expect(within(sidebar).getByRole("link", { name: "build 1" })).toHaveAttribute(
"href",
"https://hub.example.com/repositories/2001/prototypes/1?build=1",
)
expect(within(sidebar).getByText("1 reply")).toBeVisible()
expect(within(sidebar).getByText("2 replies")).toBeVisible()
expect(within(sidebar).getAllByRole("button", { pressed: false })).toHaveLength(3)
expect(within(sidebar).queryByRole("link", { name: "build 2" })).not.toBeInTheDocument()
expect(within(sidebar).queryByText("Checking")).not.toBeInTheDocument()
expect(externalFetch).not.toHaveBeenCalled()
view.unmount()
})

it("shows the Publish/Share group and opens fixture management without contacting Hub", async () => {
const externalFetch = vi.fn(() => Promise.reject(new Error("Unexpected external request.")))
vi.stubGlobal("fetch", externalFetch)
Expand Down
131 changes: 127 additions & 4 deletions hub/app/javascript/application/prototype-canvas/stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,23 @@ import type { Meta, StoryObj } from "@storybook/react-vite"

import { App } from "."
import { publicationExamples } from "@utils/resolve-prototype-publication/fixtures"
import { repliedReviewThread } from "@/storybook/fixtures/review/comments"
import type { ShareLink } from "@hooks/use-canvas-share-links/types"
import type { HubStatus, PrototypeState, PublishPreparation } from "@utils/prototype-canvas/types"
import type { ReviewComment } from "@utils/review-comment/types"

import "./stories.css"

function PublicationDemo({
scenario,
prototypeUrl,
withShareLinks = false,
withComments = false,
}: {
scenario: string
prototypeUrl: string
withShareLinks?: boolean
withComments?: boolean
}) {
const [ready, setReady] = useState(false)
useEffect(() => {
Expand All @@ -26,6 +30,50 @@ function PublicationDemo({
let remember = false
if (withShareLinks && hub?.prototype)
hub.prototype = { ...hub.prototype, canManageShareLinks: true }
if (withComments && hub?.prototype?.build)
hub.prototype = {
...hub.prototype,
build: {
...hub.prototype.build,
number: 2,
url: "https://hub.example.com/repositories/2001/prototypes/1?build=2",
},
}
let comments: ReviewComment[] = withComments
? [
repliedReviewThread.root,
...repliedReviewThread.replies,
{
...repliedReviewThread.root,
id: 3,
buildNumber: 2,
buildPath: "/repositories/2001/prototypes/1?build=2",
body: "The updated button spacing looks good.",
author: repliedReviewThread.replies[0]!.author,
viewerLastSeenActivityVersion: 2,
},
{
...repliedReviewThread.root,
id: 4,
buildNumber: 2,
buildPath: "/repositories/2001/prototypes/1?build=2",
body: "Can we keep the heading aligned?",
},
{
...repliedReviewThread.replies[0]!,
id: 5,
rootId: 4,
body: "I checked the narrow layout.",
},
{
...repliedReviewThread.replies[0]!,
id: 6,
rootId: 4,
body: "Thanks, the heading now stays aligned.",
author: repliedReviewThread.root.author,
},
]
: []
let links: ShareLink[] = ["Usability review", "Design review"].map((label, index) => ({
id: index + 1,
label,
Expand Down Expand Up @@ -131,8 +179,25 @@ function PublicationDemo({
hub = { ...hub, prototype: { ...hub.prototype, registered: true, published: true } }
data = hub.prototype
} else if (path === "/api/hub/comments")
data = { comments: [], hasMore: false, nextCursor: null }
else if (path === "/api/hub/events") {
data = {
comments,
hasMore: false,
nextCursor: null,
}
else if (withComments && /^\/api\/hub\/comments\/\d+\/read$/.test(path)) {
const id = Number(path.split("/").at(-2))
const root = comments.find((comment) => comment.id === id && comment.kind === "root")
if (!root || root.kind !== "root") throw new Error("Unknown demo conversation.")
if (init?.method !== "POST" && init?.method !== "DELETE")
throw new Error("Unsupported demo read-state action.")

const updated = {
...root,
viewerLastSeenActivityVersion: init.method === "DELETE" ? null : root.activityVersion,
}
comments = comments.map((comment) => (comment.id === root.id ? updated : comment))
data = { comments: [updated] }
} else if (path === "/api/hub/events") {
return new Response(
new ReadableStream({
start(controller) {
Expand All @@ -156,7 +221,7 @@ function PublicationDemo({
window.clearTimeout(timer)
window.fetch = originalFetch
}
}, [scenario, withShareLinks])
}, [scenario, withShareLinks, withComments])
return (
<div className="publication-demo">
<div className="publication-demo-canvas">
Expand Down Expand Up @@ -189,7 +254,12 @@ const meta = {
: "http://127.0.0.1:38763/",
},
argTypes: { scenario: { control: "select", options: Object.keys(publicationExamples) } },
render: (args) => <PublicationDemo key={`${args.scenario}/${args.withShareLinks}`} {...args} />,
render: (args) => (
<PublicationDemo
key={`${args.scenario}/${args.withShareLinks}/${args.withComments}`}
{...args}
/>
),
} satisfies Meta<typeof PublicationDemo>

export default meta
Expand Down Expand Up @@ -230,6 +300,59 @@ export const UnknownRegistrationReason: Story = {
}
export const Failed: Story = { args: { scenario: "Build failed" } }
export const Ready: Story = { args: { scenario: "Published" } }
export const CommentsSidebar: Story = {
args: {
scenario: "Published",
withComments: true,
},
render: (args) => (
<PublicationDemo
{...args}
prototypeUrl={new URL("/prototype-preview/index.html", window.location.href).href}
/>
),
play: async ({ canvas, userEvent }) => {
await userEvent.click(
await canvas.findByRole("button", { name: /^Comments, 3/ }, { timeout: 5_000 }),
)
await canvas.findByRole("complementary", { name: "Comments" })
await canvas.findByRole("link", { name: "build 1" })
await canvas.findByText("1 reply")
await canvas.findByText("2 replies")
const link = await canvas.findByRole("link", { name: "build 1" })
const row = link.closest("article")
const unread = row?.querySelector(".review-comment-row__unread")
if (!row || !unread) throw new Error("The unread comment fixture is missing.")
const bounds = row.getBoundingClientRect()
const surface = getComputedStyle(row, "::before")
const surfaceLeft = bounds.left + parseFloat(surface.left)
const surfaceRight = bounds.right - parseFloat(surface.right)
const scroller = row.closest(".review-comments-sidebar__body")?.getBoundingClientRect()
if (
!scroller ||
!Number.isFinite(surfaceLeft) ||
!Number.isFinite(surfaceRight) ||
surfaceLeft < scroller.left - 0.5 ||
surfaceRight > scroller.right + 0.5
)
throw new Error("The wider hover surface must remain inside the comment scroll area.")
const body = row.querySelector(".review-comment-row__body")?.getBoundingClientRect()
const avatar = row.querySelector(".review-comment-row__avatar")?.getBoundingClientRect()
const author = row.querySelector(".review-comment-row__author")?.getBoundingClientRect()
const dot = unread.getBoundingClientRect()
if (!body || Math.abs(dot.top + dot.height / 2 - body.top - body.height / 2) > 0.5)
throw new Error("The unread dot must be vertically centered beside the comment text.")
if (
!avatar ||
!author ||
Math.abs(avatar.width - 20) > 0.5 ||
Math.abs(avatar.left - surfaceLeft - 8) > 0.5 ||
Math.abs(avatar.left + avatar.width / 2 - dot.left - dot.width / 2) > 0.5 ||
Math.abs(avatar.top + avatar.height / 2 - author.top - author.height / 2) > 0.5
)
throw new Error("The compact avatar must align above the unread dot beside the author.")
},
}
export const ShareManagement: Story = {
args: {
scenario: "Published",
Expand Down
Loading
Loading