Add reactions to patch revisions

- Also adapts to the new reactions API schema everywhere.
This commit is contained in:
Sebastian Martinez
2024-02-02 13:33:07 +01:00
parent ebfec3fd8e
commit f0758de9f3
16 changed files with 273 additions and 173 deletions
+1 -3
View File
@@ -6,7 +6,7 @@ import type {
Tree,
DiffResponse,
} from "./lib/project.js";
import type { SuccessResponse } from "./lib/shared.js";
import type { SuccessResponse, CodeLocation, Range } from "./lib/shared.js";
import type { Comment, Embed } from "./lib/project/comment.js";
import type {
Commit,
@@ -20,13 +20,11 @@ import type {
} from "./lib/project/commit.js";
import type { Issue, IssueState } from "./lib/project/issue.js";
import type {
CodeLocation,
LifecycleState,
Merge,
Patch,
PatchState,
PatchUpdateAction,
Range,
Review,
Revision,
Verdict,
+5 -2
View File
@@ -1,5 +1,6 @@
import type { z } from "zod";
import { array, number, object, string, tuple } from "zod";
import { array, boolean, number, object, string } from "zod";
import { codeLocationSchema } from "../shared";
export type Comment = z.infer<typeof commentSchema>;
export type Embed = z.infer<typeof commentSchema>["embeds"][0];
@@ -17,7 +18,9 @@ export const commentSchema = object({
}),
),
embeds: array(object({ name: string(), content: string() })),
reactions: array(tuple([string(), string()])),
reactions: array(object({ emoji: string(), authors: array(string()) })),
timestamp: number(),
location: codeLocationSchema.nullable().optional(),
resolved: boolean(),
replyTo: string().nullable(),
});
+5 -13
View File
@@ -1,5 +1,5 @@
import type { Comment, Embed } from "./comment.js";
import type { ZodSchema } from "zod";
import type { Embed } from "./comment.js";
import type { ZodSchema, z } from "zod";
import { array, boolean, literal, object, string, union } from "zod";
import { commentSchema } from "./comment.js";
@@ -16,16 +16,6 @@ const issueStateSchema = union([
}),
]) satisfies ZodSchema<IssueState>;
export interface Issue {
id: string;
author: { id: string; alias?: string };
title: string;
state: IssueState;
discussion: Comment[];
labels: string[];
assignees: string[];
}
export const issueSchema = object({
id: string(),
author: object({ id: string(), alias: string().optional() }),
@@ -34,7 +24,9 @@ export const issueSchema = object({
discussion: array(commentSchema),
labels: array(string()),
assignees: array(string()),
}) satisfies ZodSchema<Issue>;
});
export type Issue = z.infer<typeof issueSchema>;
export interface IssueCreated {
success: boolean;
+23 -40
View File
@@ -1,5 +1,6 @@
import type { Embed } from "./comment.js";
import type { ZodSchema, z } from "zod";
import type { CodeLocation } from "../shared.js";
import { commentSchema } from "./comment.js";
@@ -14,6 +15,7 @@ import {
tuple,
union,
} from "zod";
import { codeLocationSchema } from "../shared.js";
export type PatchState =
| { status: "draft" }
@@ -55,23 +57,15 @@ const mergeSchema = object({
export type Verdict = "accept" | "reject";
export interface Review {
author: { id: string; alias?: string };
verdict?: Verdict | null;
summary: string | null;
comments: string[];
timestamp: number;
}
const reviewSchema = object({
author: object({ id: string(), alias: string().optional() }),
verdict: optional(union([literal("accept"), literal("reject")]).nullable()),
comments: array(string()),
comments: array(commentSchema),
summary: string().nullable(),
timestamp: number(),
}) satisfies ZodSchema<Review>;
});
export type Revision = z.infer<typeof revisionSchema>;
export type Review = z.infer<typeof reviewSchema>;
const revisionSchema = object({
id: string(),
@@ -85,6 +79,13 @@ const revisionSchema = object({
timestamp: number(),
}),
),
reactions: array(
object({
emoji: string(),
location: codeLocationSchema.nullable(),
authors: array(string()),
}),
),
base: string(),
oid: string(),
refs: array(string()),
@@ -93,17 +94,7 @@ const revisionSchema = object({
timestamp: number(),
});
export interface Patch {
id: string;
author: { id: string; alias?: string };
title: string;
state: PatchState;
target: string;
labels: string[];
merges: Merge[];
assignees: string[];
revisions: Revision[];
}
export type Revision = z.infer<typeof revisionSchema>;
export const patchSchema = object({
id: string(),
@@ -115,7 +106,9 @@ export const patchSchema = object({
merges: array(mergeSchema),
assignees: array(string()),
revisions: array(revisionSchema),
}) satisfies ZodSchema<Patch>;
});
export type Patch = z.infer<typeof patchSchema>;
export const patchesSchema = array(patchSchema) satisfies ZodSchema<Patch[]>;
@@ -124,23 +117,6 @@ export type LifecycleState =
| { status: "open" }
| { status: "archived" };
export type Range =
| {
type: "lines";
range: { start: number; end: number };
}
| {
type: "chars";
line: number;
range: { start: number; end: number };
};
export type CodeLocation = {
path: string;
old?: Range;
new?: Range;
};
export type PatchUpdateAction =
| { type: "edit"; title: string; target: "delegates" }
| { type: "label"; labels: string[] }
@@ -189,6 +165,13 @@ export type PatchUpdateAction =
description: string;
embeds?: Embed[];
}
| {
type: "revision.react";
revision: string;
reaction: string;
location?: CodeLocation;
active: boolean;
}
| { type: "revision.redact"; revision: string }
| {
type: "revision.comment";
+23 -1
View File
@@ -1,4 +1,4 @@
import type { ZodSchema } from "zod";
import type { ZodSchema, z } from "zod";
import { array, boolean, literal, number, object, string, union } from "zod";
@@ -41,3 +41,25 @@ export const nodeConfigSchema = object({
policy: union([literal("allow"), literal("block")]),
scope: union([literal("followed"), literal("all")]),
});
export const rangeSchema = union([
object({
type: literal("lines"),
range: object({ start: number(), end: number() }),
}),
object({
type: literal("chars"),
line: number(),
range: object({ start: number(), end: number() }),
}),
]);
export type Range = z.infer<typeof rangeSchema>;
export const codeLocationSchema = object({
path: string(),
old: rangeSchema.optional(),
new: rangeSchema.optional(),
});
export type CodeLocation = z.infer<typeof codeLocationSchema>;
+9 -10
View File
@@ -1,6 +1,5 @@
<script lang="ts" strictEvents>
import type { Comment, Embed } from "@httpd-client";
import type { GroupedReactions } from "@app/lib/reactions";
import { tick } from "svelte";
@@ -20,7 +19,7 @@
export let authorAlias: string | undefined = undefined;
export let body: string;
export let enableAttachments: boolean = false;
export let reactions: GroupedReactions | undefined = undefined;
export let reactions: Comment["reactions"] | undefined = undefined;
export let embeds: Map<string, Embed> | undefined = undefined;
export let caption = "commented";
export let rawPath: string;
@@ -34,7 +33,7 @@
export let editComment:
| ((body: string, embeds: Embed[]) => Promise<void>)
| undefined = undefined;
export let handleReaction:
export let reactOnComment:
| ((nids: string[], reaction: string) => Promise<void>)
| undefined = undefined;
</script>
@@ -175,22 +174,22 @@
<Markdown {rawPath} content={body} />
{/if}
</div>
{#if (id && handleReaction) || (id && reactions && reactions.size > 0)}
{#if (id && reactOnComment) || (id && reactions && reactions.length > 0)}
<div class="actions">
{#if id && handleReaction}
{@const handleReaction_ = handleReaction}
{#if id && reactOnComment}
{@const reactOnComment_ = reactOnComment}
<ReactionSelector
{reactions}
on:select={async ({ detail: { nids, reaction } }) => {
on:select={async ({ detail: { authors, emoji } }) => {
try {
await handleReaction_(nids, reaction);
await reactOnComment_(authors, emoji);
} finally {
closeFocused();
}
}} />
{/if}
{#if id && reactions && reactions.size > 0}
<Reactions {handleReaction} {reactions} />
{#if id && reactions && reactions.length > 0}
<Reactions handleReaction={reactOnComment} {reactions} />
{/if}
</div>
{/if}
+13 -9
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import type { GroupedReactions } from "@app/lib/reactions";
import type { Comment } from "@httpd-client";
import { createEventDispatcher } from "svelte";
@@ -9,10 +9,10 @@
import IconSmall from "./IconSmall.svelte";
import Popover from "./Popover.svelte";
export let reactions: GroupedReactions | undefined;
export let reactions: Comment["reactions"] | undefined = undefined;
const dispatch = createEventDispatcher<{
select: { nids: string[]; reaction: string };
select: { emoji: string; authors: string[] };
}>();
</script>
@@ -56,13 +56,17 @@
<div class="selector" slot="popover">
{#each config.reactions as reaction}
{@const lookedUpReaction = reactions?.find(
({ emoji }) => emoji === reaction,
)}
<button
class:active={Boolean(reactions?.get(reaction)?.self)}
on:click={() =>
dispatch("select", {
nids: reactions?.get(reaction)?.all ?? [],
reaction,
})}>
class:active={Boolean(lookedUpReaction)}
on:click={() => {
dispatch(
"select",
lookedUpReaction || { emoji: reaction, authors: [] },
);
}}>
{reaction}
</button>
{/each}
+7 -7
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import type { GroupedReactions } from "@app/lib/reactions";
import type { Comment } from "@httpd-client";
import IconButton from "./IconButton.svelte";
export let reactions: GroupedReactions;
export let reactions: Comment["reactions"];
export let handleReaction:
| ((nids: string[], reaction: string) => Promise<void>)
| ((authors: string[], reaction: string) => Promise<void>)
| undefined;
</script>
@@ -24,16 +24,16 @@
</style>
<div class="reactions">
{#each reactions as [reaction, { all: nids }]}
{#each reactions as { emoji, authors }}
<IconButton
on:click={async () => {
if (handleReaction) {
await handleReaction(nids, reaction);
await handleReaction(authors, emoji);
}
}}>
<div class="reaction txt-tiny">
<span>{reaction}</span>
<span title={nids.join("\n")}>{nids.length}</span>
<span>{emoji}</span>
<span title={authors.join("\n")}>{authors.length}</span>
</div>
</IconButton>
{/each}
+7 -28
View File
@@ -1,26 +1,6 @@
<script lang="ts" context="module">
import type { Comment } from "@httpd-client";
import { groupReactions } from "@app/lib/reactions";
function groupReactionsInThread(thread: {
root: Comment;
replies: Comment[];
}) {
return {
root: {
...thread.root,
reactions: groupReactions(thread.root.reactions),
},
replies: thread.replies.map(reply => ({
...reply,
reactions: groupReactions(reply.reactions),
})),
};
}
</script>
<script lang="ts" strictEvents>
import type { Embed } from "@httpd-client";
import type { Comment } from "@httpd-client";
import * as utils from "@app/lib/utils";
import partial from "lodash/partial";
@@ -44,7 +24,7 @@
export let createReply:
| ((commentId: string, comment: string, embeds: Embed[]) => Promise<void>)
| undefined;
export let handleReaction:
export let reactOnComment:
| ((commentId: string, nids: string[], reaction: string) => Promise<void>)
| undefined;
@@ -57,9 +37,8 @@
});
}
$: threadWithReactions = groupReactionsInThread(thread);
$: root = threadWithReactions.root;
$: replies = threadWithReactions.replies;
$: root = thread.root;
$: replies = thread.replies;
</script>
<style>
@@ -102,7 +81,7 @@
editComment={editComment &&
canEditComment(root.author.id) &&
partial(editComment, root.id)}
handleReaction={handleReaction && partial(handleReaction, root.id)}>
reactOnComment={reactOnComment && partial(reactOnComment, root.id)}>
<IconSmall name="chat" slot="icon" />
</CommentComponent>
</div>
@@ -126,8 +105,8 @@
editComment={editComment &&
canEditComment(reply.author.id) &&
partial(editComment, reply.id)}
handleReaction={handleReaction &&
partial(handleReaction, reply.id)} />
reactOnComment={reactOnComment &&
partial(reactOnComment, reply.id)} />
{/each}
</div>
{/if}
-17
View File
@@ -1,17 +0,0 @@
export type GroupedReactions = Map<string, { all: string[]; self: boolean }>;
// Takes reactions from a comment and groups them by emoji
// and if the current user has reacted with that emoji.
export function groupReactions(
reactions: [string, string][],
publicKey?: string,
) {
return reactions.reduce(
(acc, [nid, emoji]) =>
acc.set(emoji, {
all: [...(acc.get(emoji)?.all ?? []), nid],
self: publicKey === nid,
}),
new Map<string, { all: string[]; self: boolean }>(),
);
}
+46 -6
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import type {
BaseUrl,
Comment,
DiffResponse,
Embed,
PatchState,
@@ -11,6 +12,7 @@
import * as utils from "@app/lib/utils";
import { HttpdClient } from "@httpd-client";
import { closeFocused } from "@app/components/Popover.svelte";
import { onMount } from "svelte";
import { parseEmbedIntoMap } from "@app/lib/file";
@@ -29,10 +31,12 @@
import Markdown from "@app/components/Markdown.svelte";
import NodeId from "@app/components/NodeId.svelte";
import Popover from "@app/components/Popover.svelte";
import ReactionSelector from "@app/components/ReactionSelector.svelte";
import Reactions from "@app/components/Reactions.svelte";
import Thread from "@app/components/Thread.svelte";
export let baseUrl: BaseUrl;
export let initialExpanded: boolean = false;
export let initiallyExpanded: boolean = false;
export let rawPath: (commit?: string) => string;
export let patchId: string;
export let patchState: PatchState;
@@ -44,6 +48,7 @@
export let revisionEdits: Revision["edits"];
export let revisionOid: string;
export let revisionTimestamp: number;
export let revisionReactions: Comment["reactions"];
export let revisionAuthor: { id: string; alias?: string | undefined };
export let revisionDescription: string;
export let timelines: Timeline[];
@@ -57,14 +62,21 @@
export let editComment:
| ((commentId: string, body: string, embeds: Embed[]) => Promise<void>)
| undefined;
export let handleReaction:
| ((commentId: string, nids: string[], reaction: string) => Promise<void>)
export let reactOnRevision:
| ((authors: string[], reaction: string) => Promise<void>)
| undefined;
export let reactOnComment:
| ((
commentId: string,
authors: string[],
reaction: string,
) => Promise<void>)
| undefined;
export let createReply:
| ((commentId: string, comment: string, embeds: Embed[]) => Promise<void>)
| undefined;
let expanded = initialExpanded;
let expanded = initiallyExpanded;
const api = new HttpdClient(baseUrl);
const latestEdit = revisionEdits.pop();
@@ -184,7 +196,6 @@
color: var(--color-foreground-dim);
}
.revision-description {
margin-bottom: 1rem;
margin-left: 2rem;
}
.compare-dropdown-item {
@@ -209,6 +220,14 @@
font-size: var(--font-size-small);
color: var(--color-fill-gray);
}
.actions {
display: flex;
flex-direction: row;
align-items: center;
padding-left: 2rem;
gap: 0.5rem;
padding-bottom: 1rem;
}
.commits {
position: relative;
display: flex;
@@ -431,6 +450,27 @@
content={revisionDescription} />
</div>
{/if}
{#if reactOnRevision || revisionReactions.length > 0}
<div class="actions">
{#if reactOnRevision}
{@const reactOnRevision_ = reactOnRevision}
<ReactionSelector
reactions={revisionReactions}
on:select={async ({ detail: { emoji, authors } }) => {
try {
await reactOnRevision_(authors, emoji);
} finally {
closeFocused();
}
}} />
{/if}
{#if revisionReactions && revisionReactions.length > 0}
<Reactions
handleReaction={reactOnRevision}
reactions={revisionReactions} />
{/if}
</div>
{/if}
</div>
{#if loading}
<div style:height="3.5rem">
@@ -471,7 +511,7 @@
canEditComment={canEdit}
{editComment}
{createReply}
{handleReaction} />
{reactOnComment} />
{:else if element.type === "merge"}
<div class="connector" />
<div class="action merge">
+7 -8
View File
@@ -19,7 +19,6 @@
import * as utils from "@app/lib/utils";
import { HttpdClient } from "@httpd-client";
import { closeFocused } from "@app/components/Popover.svelte";
import { groupReactions } from "@app/lib/reactions";
import { httpdStore } from "@app/lib/httpd";
import { parseEmbedIntoMap } from "@app/lib/file";
@@ -164,7 +163,7 @@
}
}
async function handleReaction(
async function reactOnComment(
session: Session,
commentId: string,
nids: string[],
@@ -569,11 +568,11 @@
<div class="reactions">
{#if session}
<ReactionSelector
reactions={groupReactions(issue.discussion[0].reactions)}
on:select={async ({ detail: { nids, reaction } }) => {
reactions={issue.discussion[0].reactions}
on:select={async ({ detail: { authors, emoji } }) => {
try {
if (session) {
await handleReaction(session, issue.id, nids, reaction);
await reactOnComment(session, issue.id, authors, emoji);
}
} finally {
closeFocused();
@@ -582,9 +581,9 @@
{/if}
{#if issue.discussion[0].reactions.length > 0}
<Reactions
reactions={groupReactions(issue.discussion[0].reactions)}
reactions={issue.discussion[0].reactions}
handleReaction={session &&
partial(handleReaction, session, issue.id)} />
partial(reactOnComment, session, issue.id)} />
{/if}
</div>
</div>
@@ -621,7 +620,7 @@
)}
editComment={session && partial(editComment, session.id)}
createReply={session && partial(createReply, session.id)}
handleReaction={session && partial(handleReaction, session)} />
reactOnComment={session && partial(reactOnComment, session)} />
<div class="connector" />
{/each}
</div>
+91 -10
View File
@@ -40,6 +40,7 @@
import type { PatchView } from "./router";
import type { Route } from "@app/lib/router";
import type { ComponentProps } from "svelte";
import type { Session } from "@app/lib/httpd";
import * as modal from "@app/lib/modal";
import * as role from "@app/lib/roles";
@@ -50,7 +51,7 @@
import partial from "lodash/partial";
import uniqBy from "lodash/uniqBy";
import { HttpdClient } from "@httpd-client";
import { httpdStore, type Session } from "@app/lib/httpd";
import { httpdStore } from "@app/lib/httpd";
import { parseEmbedIntoMap } from "@app/lib/file";
import Badge from "@app/components/Badge.svelte";
@@ -75,6 +76,8 @@
import Placeholder from "@app/components/Placeholder.svelte";
import Popover, { closeFocused } from "@app/components/Popover.svelte";
import Radio from "@app/components/Radio.svelte";
import ReactionSelector from "@app/components/ReactionSelector.svelte";
import Reactions from "@app/components/Reactions.svelte";
import RevisionComponent from "@app/views/projects/Cob/Revision.svelte";
import TextInput from "@app/components/TextInput.svelte";
import Share from "./Share.svelte";
@@ -203,11 +206,11 @@
}
}
async function handleReaction(
async function reactOnComment(
session: Session,
revisionId: string,
commentId: string,
nids: string[],
authors: string[],
reaction: string,
) {
try {
@@ -219,7 +222,7 @@
revision: revisionId,
comment: commentId,
reaction,
active: nids.includes(session.publicKey) ? false : true,
active: authors.includes(session.publicKey) ? false : true,
},
session.id,
);
@@ -363,6 +366,46 @@
}
}
async function reactOnRevision(
session: Session,
revisionId: string,
authors: string[],
reaction: string,
) {
try {
await api.project.updatePatch(
project.id,
patch.id,
{
type: "revision.react",
revision: revisionId,
reaction,
active: authors.includes(session.publicKey) ? false : true,
},
session.id,
);
} catch (error) {
if (error instanceof Error) {
modal.show({
component: ErrorModal,
props: {
title: "Reacting on revision failed",
subtitle: [
"There was an error while trying to react to a revision.",
"Check your radicle-httpd logs for details.",
],
error: {
message: error.message,
stack: error.stack,
},
},
});
}
} finally {
await refreshPatch();
}
}
async function saveLabels(sessionId: string, labels: string[]) {
try {
await api.project.updatePatch(
@@ -511,6 +554,7 @@
revisionBase: string;
revisionOid: string;
revisionEdits: Revision["edits"];
revisionReactions: Revision["reactions"];
revisionAuthor: { id: string; alias?: string | undefined };
revisionDescription: string;
},
@@ -523,6 +567,7 @@
revisionBase: rev.base,
revisionOid: rev.oid,
revisionEdits: rev.edits,
revisionReactions: rev.reactions,
revisionAuthor: rev.author,
revisionDescription: rev.description,
},
@@ -553,6 +598,7 @@
})),
].sort((a, b) => a.timestamp - b.timestamp),
]);
$: firstRevision = timelineTuple[0][0];
$: session =
$httpdStore.state === "authenticated" && utils.isLocal(baseUrl.hostname)
? $httpdStore.session
@@ -597,6 +643,12 @@
padding: 1rem;
height: 100%;
}
.actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.tabs {
font-size: var(--font-size-tiny);
display: flex;
@@ -640,6 +692,7 @@
}
.revision-description {
display: flex;
flex-direction: column;
width: 100%;
}
.diff-button-range {
@@ -736,12 +789,38 @@
}
}} />
{:else if description}
<Markdown
content={description}
rawPath={rawPath(patch.revisions[0].id)} />
<Markdown content={description} rawPath={rawPath(patch.id)} />
{:else}
<span class="txt-missing">No description available</span>
{/if}
{#if session || (firstRevision.revisionReactions && firstRevision.revisionReactions.length > 0)}
<div class="actions">
{#if session}
<ReactionSelector
reactions={firstRevision.revisionReactions}
on:select={async ({ detail: { emoji, authors } }) => {
if (session) {
try {
await reactOnRevision(
session,
patch.id,
authors,
emoji,
);
} finally {
closeFocused();
}
}
}} />
{/if}
{#if firstRevision.revisionReactions.length > 0}
<Reactions
handleReaction={session &&
partial(reactOnRevision, session, patch.id)}
reactions={firstRevision.revisionReactions} />
{/if}
</div>
{/if}
</div>
</svelte:fragment>
<div class="author" slot="author">
@@ -882,13 +961,15 @@
partial(editRevision, session.id, revision.revisionId)}
editComment={session &&
partial(editComment, session.id, revision.revisionId)}
handleReaction={session &&
partial(handleReaction, session, revision.revisionId)}
reactOnComment={session &&
partial(reactOnComment, session, revision.revisionId)}
reactOnRevision={session &&
partial(reactOnRevision, session, revision.revisionId)}
createReply={session &&
partial(createReply, session.id, revision.revisionId)}
patchId={patch.id}
patchState={patch.state}
initialExpanded={index === patch.revisions.length - 1}
initiallyExpanded={index === patch.revisions.length - 1}
previousRevId={previousRevision?.id}
previousRevOid={previousRevision?.oid}>
{#if index === patch.revisions.length - 1}
+2 -1
View File
@@ -126,7 +126,8 @@ test("add and remove reactions", async ({ page, authenticatedPeer }) => {
await page
.getByRole("link", { name: "This is an issue to test reactions" })
.click();
await expectReactionsToWork(page);
const reactionsLocator = page.locator(".actions").first();
await expectReactionsToWork(page, reactionsLocator);
});
test("handling embeds", async ({ page, authenticatedPeer }) => {
+2 -1
View File
@@ -67,7 +67,8 @@ test("add and remove reactions", async ({ page, authenticatedPeer }) => {
}),
);
await page.goto(`${authenticatedPeer.uiUrl()}/${rid}/patches/${patchId}`);
await expectReactionsToWork(page);
const reactionsLocator = page.locator(".actions").first();
await expectReactionsToWork(page, reactionsLocator);
});
test("handling embeds", async ({ page, authenticatedPeer }) => {
+32 -17
View File
@@ -1,4 +1,4 @@
import type { Page } from "@playwright/test";
import type { Locator, Page } from "@playwright/test";
import type { RadiclePeer } from "@tests/support/peerManager";
import type { ExecaReturnValue } from "execa";
@@ -120,28 +120,43 @@ export async function expectLabelEditingToWork(page: Page) {
).toBeHidden();
}
export async function expectReactionsToWork(page: Page) {
export async function expectReactionsToWork(
page: Page,
reactionsLocator: Locator,
) {
await page.getByRole("button", { name: "Leave your comment" }).click();
await page.getByPlaceholder("Leave your comment").fill("This is a comment");
await page.getByRole("button", { name: "Comment" }).click();
const commentReactionToggle = page.getByTitle("toggle-reaction").first();
await commentReactionToggle.click();
await page.getByRole("button", { name: "👍" }).click();
await expect(page.getByRole("button", { name: "👍 1" })).toBeVisible();
const reactionsToggleBtn = reactionsLocator.getByRole("button", {
name: "toggle-reaction-popover",
});
await reactionsToggleBtn.click();
await reactionsLocator.getByRole("button", { name: "👍" }).click();
await expect(
reactionsLocator.getByRole("button", { name: "👍 1" }),
).toBeVisible();
await commentReactionToggle.click();
await page.getByRole("button", { name: "🎉" }).click();
await expect(page.getByRole("button", { name: "🎉 1" })).toBeVisible();
await expect(page.locator(".reaction")).toHaveCount(2);
await reactionsToggleBtn.click();
await reactionsLocator.getByRole("button", { name: "🎉" }).click();
await expect(
reactionsLocator.getByRole("button", { name: "🎉 1" }),
).toBeVisible();
await expect(reactionsLocator.locator(".reaction")).toHaveCount(2);
await page.getByRole("button", { name: "👍" }).click();
await expect(page.locator("span").filter({ hasText: "👍 1" })).toBeHidden();
await expect(page.locator(".reaction")).toHaveCount(1);
await reactionsLocator.getByRole("button", { name: "👍" }).click();
await expect(
reactionsLocator.locator("span").filter({ hasText: "👍 1" }),
).toBeHidden();
await expect(reactionsLocator.locator(".reaction")).toHaveCount(1);
await commentReactionToggle.click();
await page.getByRole("button", { name: "🎉" }).first().click();
await expect(page.getByRole("button", { name: "🎉 1" })).toBeHidden();
await expect(page.locator(".reaction")).toHaveCount(0);
await reactionsToggleBtn.click();
await reactionsLocator
.getByRole("button", { name: "🎉", exact: true })
.click();
await expect(
reactionsLocator.getByRole("button", { name: "🎉 1" }),
).toBeHidden();
await expect(reactionsLocator.locator(".reaction")).toHaveCount(0);
}
export async function addEmbed(