@edux-design/comments 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +61 -0
- package/dist/index.d.mts +52 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +579 -0
- package/dist/index.mjs +556 -0
- package/package.json +51 -0
- package/src/demos/Comment.stories.jsx +472 -0
- package/src/demos/CommentComposer.stories.jsx +62 -0
- package/src/elements/Comment.jsx +234 -0
- package/src/elements/Comment.test.jsx +137 -0
- package/src/elements/CommentComposer.jsx +184 -0
- package/src/elements/CommentComposer.test.jsx +125 -0
- package/src/elements/CommentMention.jsx +217 -0
- package/src/elements/CommentMention.test.jsx +138 -0
- package/src/elements/CommentMentionExtension.jsx +56 -0
- package/src/index.js +3 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useState,
|
|
6
|
+
createContext,
|
|
7
|
+
useContext,
|
|
8
|
+
isValidElement,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { cx } from "@edux-design/utils";
|
|
11
|
+
import { Avatar } from "@edux-design/avatars";
|
|
12
|
+
|
|
13
|
+
const VARIANT_STYLES = {
|
|
14
|
+
default: "border-border-minimal bg-bg-base",
|
|
15
|
+
resolved: "border-border-success-base bg-bg-success-subtle",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const CommentContext = createContext({
|
|
19
|
+
editable: true,
|
|
20
|
+
onResolve: undefined,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const useCommentContext = () => useContext(CommentContext);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {React.HTMLAttributes<HTMLDivElement> & {
|
|
27
|
+
* author?: React.ReactNode,
|
|
28
|
+
* timestamp?: React.ReactNode,
|
|
29
|
+
* avatar?: React.ReactNode,
|
|
30
|
+
* avatarName?: string,
|
|
31
|
+
* avatarProps?: import("@edux-design/avatars").AvatarProps,
|
|
32
|
+
* body?: React.ReactNode,
|
|
33
|
+
* replies?: React.ReactNode,
|
|
34
|
+
* variant?: "default" | "resolved",
|
|
35
|
+
* resolved?: boolean,
|
|
36
|
+
* onResolve?: (event: React.MouseEvent<HTMLButtonElement>) => void,
|
|
37
|
+
* editable?: boolean,
|
|
38
|
+
* children?: React.ReactNode,
|
|
39
|
+
* }} CommentProps
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Flexible comment surface with optional avatar, metadata, actions, and replies.
|
|
44
|
+
*/
|
|
45
|
+
export const Comment = forwardRef(function Comment(
|
|
46
|
+
{
|
|
47
|
+
author = "Author",
|
|
48
|
+
timestamp,
|
|
49
|
+
avatar,
|
|
50
|
+
avatarName,
|
|
51
|
+
avatarProps,
|
|
52
|
+
body,
|
|
53
|
+
replies,
|
|
54
|
+
variant = "default",
|
|
55
|
+
resolved = false,
|
|
56
|
+
onResolve,
|
|
57
|
+
editable = true,
|
|
58
|
+
children,
|
|
59
|
+
className,
|
|
60
|
+
...props
|
|
61
|
+
},
|
|
62
|
+
ref
|
|
63
|
+
) {
|
|
64
|
+
const childArray = React.Children.toArray(children);
|
|
65
|
+
const actionsSlot = childArray.find(
|
|
66
|
+
(child) =>
|
|
67
|
+
isValidElement(child) &&
|
|
68
|
+
(child.type === CommentActions ||
|
|
69
|
+
child.type?.displayName === "Comment.Actions")
|
|
70
|
+
);
|
|
71
|
+
const bodyNodes = childArray.filter((child) => child !== actionsSlot);
|
|
72
|
+
const bodyContent = body ?? bodyNodes;
|
|
73
|
+
const hasBodyContent =
|
|
74
|
+
body !== undefined ? Boolean(body) : bodyNodes.length > 0;
|
|
75
|
+
const isResolved = resolved || variant === "resolved";
|
|
76
|
+
const resolvedVariant =
|
|
77
|
+
VARIANT_STYLES[isResolved ? "resolved" : variant] ?? VARIANT_STYLES.default;
|
|
78
|
+
const resolvedAvatarName =
|
|
79
|
+
avatarName ?? (typeof author === "string" ? author : undefined);
|
|
80
|
+
const shouldRenderAvatar = Boolean(avatar || avatarProps || resolvedAvatarName);
|
|
81
|
+
const replyItems = useMemo(
|
|
82
|
+
() =>
|
|
83
|
+
React.Children.toArray(replies).filter(
|
|
84
|
+
(reply) => reply !== null && reply !== undefined && reply !== false
|
|
85
|
+
),
|
|
86
|
+
[replies]
|
|
87
|
+
);
|
|
88
|
+
const replyCount = replyItems.length;
|
|
89
|
+
const shouldCollapseReplies = replyCount > 2;
|
|
90
|
+
const [showAllReplies, setShowAllReplies] = useState(!shouldCollapseReplies);
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
setShowAllReplies(!shouldCollapseReplies);
|
|
94
|
+
}, [shouldCollapseReplies]);
|
|
95
|
+
const visibleReplies =
|
|
96
|
+
shouldCollapseReplies && !showAllReplies
|
|
97
|
+
? [replyItems[replyCount - 1]]
|
|
98
|
+
: replyItems;
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<CommentContext.Provider value={{ editable, onResolve }}>
|
|
102
|
+
<div
|
|
103
|
+
ref={ref}
|
|
104
|
+
className={cx(
|
|
105
|
+
"flex gap-12 rounded-default border p-12",
|
|
106
|
+
resolvedVariant,
|
|
107
|
+
className
|
|
108
|
+
)}
|
|
109
|
+
data-variant={variant}
|
|
110
|
+
{...props}
|
|
111
|
+
>
|
|
112
|
+
{shouldRenderAvatar && (
|
|
113
|
+
<div className="shrink-0">
|
|
114
|
+
{avatar ?? (
|
|
115
|
+
<Avatar name={resolvedAvatarName} size="small" {...avatarProps} />
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
<div className="min-w-0 flex-1">
|
|
121
|
+
<div className="flex flex-wrap items-center gap-6">
|
|
122
|
+
<span className="truncate text-sm font-semibold text-fg-base">
|
|
123
|
+
{author}
|
|
124
|
+
</span>
|
|
125
|
+
{timestamp && (
|
|
126
|
+
<span className="text-xs text-fg-subtle">{timestamp}</span>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{hasBodyContent && !isResolved ? (
|
|
131
|
+
<div className="mt-6 text-sm leading-relaxed text-fg-base">
|
|
132
|
+
{bodyContent}
|
|
133
|
+
</div>
|
|
134
|
+
) : null}
|
|
135
|
+
|
|
136
|
+
{isResolved ? (
|
|
137
|
+
<div className="mt-6 text-xs font-semibold uppercase tracking-wide text-fg-success-base">
|
|
138
|
+
Resolved
|
|
139
|
+
</div>
|
|
140
|
+
) : null}
|
|
141
|
+
|
|
142
|
+
{actionsSlot ? (
|
|
143
|
+
<div className="mt-8">{actionsSlot}</div>
|
|
144
|
+
) : null}
|
|
145
|
+
|
|
146
|
+
{replies && !isResolved ? (
|
|
147
|
+
<div className="mt-10 border-l border-border-minimal pl-12">
|
|
148
|
+
{shouldCollapseReplies && !showAllReplies && (
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
className="mb-8 text-xs font-semibold text-fg-info-base"
|
|
152
|
+
onClick={() => setShowAllReplies(true)}
|
|
153
|
+
aria-expanded={showAllReplies}
|
|
154
|
+
>
|
|
155
|
+
Show all {replyCount} replies
|
|
156
|
+
</button>
|
|
157
|
+
)}
|
|
158
|
+
{visibleReplies}
|
|
159
|
+
</div>
|
|
160
|
+
) : null}
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
</CommentContext.Provider>
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
export const CommentActions = ({ children, className, ...props }) => (
|
|
168
|
+
<div
|
|
169
|
+
className={cx(
|
|
170
|
+
"flex flex-wrap items-center gap-8 text-xs text-fg-subtle",
|
|
171
|
+
className
|
|
172
|
+
)}
|
|
173
|
+
{...props}
|
|
174
|
+
>
|
|
175
|
+
{children}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
export const CommentReply = ({ className, ...props }) => (
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
className={cx("text-xs text-fg-info-base cursor-pointer", className)}
|
|
183
|
+
{...props}
|
|
184
|
+
>
|
|
185
|
+
Reply
|
|
186
|
+
</button>
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
export const CommentEdit = ({ className, ...props }) => (
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
className={cx("text-xs text-fg-info-base cursor-pointer", className)}
|
|
193
|
+
{...props}
|
|
194
|
+
>
|
|
195
|
+
Edit
|
|
196
|
+
</button>
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
export const CommentResolve = ({
|
|
200
|
+
className,
|
|
201
|
+
disabled,
|
|
202
|
+
onClick,
|
|
203
|
+
...props
|
|
204
|
+
}) => {
|
|
205
|
+
const { editable, onResolve } = useCommentContext();
|
|
206
|
+
const isDisabled = disabled || !editable;
|
|
207
|
+
const handleClick = onClick ?? onResolve;
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
className={cx(
|
|
213
|
+
"text-xs font-semibold text-fg-success-base cursor-pointer",
|
|
214
|
+
className
|
|
215
|
+
)}
|
|
216
|
+
onClick={handleClick}
|
|
217
|
+
disabled={isDisabled}
|
|
218
|
+
aria-disabled={isDisabled}
|
|
219
|
+
{...props}
|
|
220
|
+
>
|
|
221
|
+
Resolve
|
|
222
|
+
</button>
|
|
223
|
+
);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
Comment.Actions = CommentActions;
|
|
227
|
+
Comment.Reply = CommentReply;
|
|
228
|
+
Comment.Edit = CommentEdit;
|
|
229
|
+
Comment.Resolve = CommentResolve;
|
|
230
|
+
|
|
231
|
+
CommentActions.displayName = "Comment.Actions";
|
|
232
|
+
CommentReply.displayName = "Comment.Reply";
|
|
233
|
+
CommentEdit.displayName = "Comment.Edit";
|
|
234
|
+
CommentResolve.displayName = "Comment.Resolve";
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import React, { createRef } from "react";
|
|
2
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
3
|
+
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
|
4
|
+
import "@testing-library/jest-dom";
|
|
5
|
+
|
|
6
|
+
vi.mock("@edux-design/utils", () => ({
|
|
7
|
+
cx: (...args) => args.filter(Boolean).join(" "),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("@edux-design/avatars", () => ({
|
|
11
|
+
Avatar: (props) => (
|
|
12
|
+
<div data-testid="avatar" data-name={props.name} data-size={props.size} />
|
|
13
|
+
),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import { Comment } from "./Comment";
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
cleanup();
|
|
20
|
+
vi.restoreAllMocks();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("Comment", () => {
|
|
24
|
+
it("renders the avatar using the Avatar package by default", () => {
|
|
25
|
+
render(<Comment author="Alex Johnson" body="Hello" />);
|
|
26
|
+
|
|
27
|
+
const avatar = screen.getByTestId("avatar");
|
|
28
|
+
expect(avatar).toBeInTheDocument();
|
|
29
|
+
expect(avatar).toHaveAttribute("data-name", "Alex Johnson");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("uses the provided avatar node when supplied", () => {
|
|
33
|
+
render(
|
|
34
|
+
<Comment
|
|
35
|
+
author="Alex"
|
|
36
|
+
body="Hi"
|
|
37
|
+
avatar={<span data-testid="custom-avatar" />}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(screen.getByTestId("custom-avatar")).toBeInTheDocument();
|
|
42
|
+
expect(screen.queryByTestId("avatar")).not.toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("renders actions below the body content", () => {
|
|
46
|
+
render(
|
|
47
|
+
<Comment author="Alex" body="Body copy">
|
|
48
|
+
<Comment.Actions>
|
|
49
|
+
<Comment.Reply>Reply</Comment.Reply>
|
|
50
|
+
</Comment.Actions>
|
|
51
|
+
</Comment>
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const body = screen.getByText("Body copy");
|
|
55
|
+
const actions = screen.getByRole("button", { name: "Reply" });
|
|
56
|
+
|
|
57
|
+
expect(body.compareDocumentPosition(actions)).toBe(
|
|
58
|
+
Node.DOCUMENT_POSITION_FOLLOWING
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("renders nested replies", () => {
|
|
63
|
+
render(
|
|
64
|
+
<Comment
|
|
65
|
+
author="Alex"
|
|
66
|
+
body="Root"
|
|
67
|
+
replies={<Comment author="Jamie" body="Nested" />}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(screen.getByText("Root")).toBeInTheDocument();
|
|
72
|
+
expect(screen.getByText("Nested")).toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("shows a show-all control and expands to reveal all replies", () => {
|
|
76
|
+
render(
|
|
77
|
+
<Comment
|
|
78
|
+
author="Alex"
|
|
79
|
+
body="Root"
|
|
80
|
+
replies={
|
|
81
|
+
<>
|
|
82
|
+
<Comment author="Jamie" body="First reply" />
|
|
83
|
+
<Comment author="Riley" body="Second reply" />
|
|
84
|
+
<Comment author="Casey" body="Last reply" />
|
|
85
|
+
</>
|
|
86
|
+
}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
expect(screen.getByText("Show all 3 replies")).toBeInTheDocument();
|
|
91
|
+
expect(screen.queryByText("First reply")).not.toBeInTheDocument();
|
|
92
|
+
expect(screen.queryByText("Second reply")).not.toBeInTheDocument();
|
|
93
|
+
expect(screen.getByText("Last reply")).toBeInTheDocument();
|
|
94
|
+
|
|
95
|
+
fireEvent.click(screen.getByText("Show all 3 replies"));
|
|
96
|
+
|
|
97
|
+
expect(screen.getByText("First reply")).toBeInTheDocument();
|
|
98
|
+
expect(screen.getByText("Second reply")).toBeInTheDocument();
|
|
99
|
+
expect(screen.getByText("Last reply")).toBeInTheDocument();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("applies the resolved variant", () => {
|
|
103
|
+
const { container } = render(
|
|
104
|
+
<Comment author="Alex" body="Resolved" variant="resolved" />
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(container.firstChild).toHaveAttribute("data-variant", "resolved");
|
|
108
|
+
expect(container.firstChild).toHaveClass("border-border-success-base");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("shows resolved state and hides body when resolved", () => {
|
|
112
|
+
render(<Comment author="Alex" body="Hidden body" resolved />);
|
|
113
|
+
expect(screen.getByText("Resolved")).toBeInTheDocument();
|
|
114
|
+
expect(screen.queryByText("Hidden body")).not.toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("fires onResolve when resolve button is clicked", () => {
|
|
118
|
+
const handleResolve = vi.fn();
|
|
119
|
+
render(
|
|
120
|
+
<Comment author="Alex" body="Body" onResolve={handleResolve}>
|
|
121
|
+
<Comment.Actions>
|
|
122
|
+
<Comment.Resolve />
|
|
123
|
+
</Comment.Actions>
|
|
124
|
+
</Comment>
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
fireEvent.click(screen.getByRole("button", { name: "Resolve" }));
|
|
128
|
+
expect(handleResolve).toHaveBeenCalledTimes(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("forwards the ref to the root element", () => {
|
|
132
|
+
const ref = createRef();
|
|
133
|
+
render(<Comment ref={ref} author="Alex" body="Ref" />);
|
|
134
|
+
expect(ref.current).toBeInstanceOf(HTMLElement);
|
|
135
|
+
expect(ref.current.tagName.toLowerCase()).toBe("div");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useMemo,
|
|
4
|
+
useRef,
|
|
5
|
+
createContext,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { cx } from "@edux-design/utils";
|
|
10
|
+
import { Avatar } from "@edux-design/avatars";
|
|
11
|
+
import { Button } from "@edux-design/buttons";
|
|
12
|
+
import { useEditor, EditorContent } from "@tiptap/react";
|
|
13
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
14
|
+
import Placeholder from "@tiptap/extension-placeholder";
|
|
15
|
+
import { CommentMentionExtension } from "./CommentMentionExtension";
|
|
16
|
+
|
|
17
|
+
const CommentComposerContext = createContext(null);
|
|
18
|
+
|
|
19
|
+
export const useCommentComposer = () => {
|
|
20
|
+
const context = useContext(CommentComposerContext);
|
|
21
|
+
if (!context) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
"CommentComposer components must be used within a <CommentComposer />."
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return context;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {React.FormHTMLAttributes<HTMLFormElement> & {
|
|
31
|
+
* value?: string,
|
|
32
|
+
* onChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void,
|
|
33
|
+
* onSubmit?: (event: React.FormEvent<HTMLFormElement>) => void,
|
|
34
|
+
* onCancel?: (event: React.MouseEvent<HTMLButtonElement>) => void,
|
|
35
|
+
* placeholder?: string,
|
|
36
|
+
* rows?: number,
|
|
37
|
+
* disabled?: boolean,
|
|
38
|
+
* isSubmitting?: boolean,
|
|
39
|
+
* submitLabel?: string,
|
|
40
|
+
* cancelLabel?: string,
|
|
41
|
+
* avatar?: React.ReactNode,
|
|
42
|
+
* avatarProps?: import("@edux-design/avatars").AvatarProps,
|
|
43
|
+
* actions?: React.ReactNode,
|
|
44
|
+
* children?: React.ReactNode,
|
|
45
|
+
* }} CommentComposerProps
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Comment composer with avatar, textarea, and action buttons.
|
|
50
|
+
*/
|
|
51
|
+
export const CommentComposer = forwardRef(function CommentComposer(
|
|
52
|
+
{
|
|
53
|
+
value,
|
|
54
|
+
onChange,
|
|
55
|
+
onSubmit,
|
|
56
|
+
onCancel,
|
|
57
|
+
placeholder = "Write a comment...",
|
|
58
|
+
rows = 4,
|
|
59
|
+
disabled = false,
|
|
60
|
+
isSubmitting = false,
|
|
61
|
+
submitLabel = "Post",
|
|
62
|
+
cancelLabel = "Cancel",
|
|
63
|
+
avatar,
|
|
64
|
+
avatarProps,
|
|
65
|
+
actions,
|
|
66
|
+
className,
|
|
67
|
+
children,
|
|
68
|
+
...props
|
|
69
|
+
},
|
|
70
|
+
ref
|
|
71
|
+
) {
|
|
72
|
+
const hasValue =
|
|
73
|
+
value === undefined ? true : Boolean(value && value.trim().length);
|
|
74
|
+
const isSubmitDisabled = disabled || isSubmitting || !hasValue;
|
|
75
|
+
const editor = useEditor({
|
|
76
|
+
extensions: [
|
|
77
|
+
StarterKit,
|
|
78
|
+
CommentMentionExtension,
|
|
79
|
+
Placeholder.configure({
|
|
80
|
+
placeholder,
|
|
81
|
+
}),
|
|
82
|
+
],
|
|
83
|
+
content: value ?? "",
|
|
84
|
+
editable: !disabled,
|
|
85
|
+
editorProps: {
|
|
86
|
+
attributes: {
|
|
87
|
+
class: cx(
|
|
88
|
+
"w-full bg-white border-2 rounded-md border-border-base",
|
|
89
|
+
"outline-none transition-all duration-300 ease-in-out",
|
|
90
|
+
"pl-5 py-3 font-normal text-base leading-relaxed",
|
|
91
|
+
"h-[120px] overflow-y-auto",
|
|
92
|
+
"focus:shadow-focus focus-visible:shadow-focus",
|
|
93
|
+
disabled && "bg-bg-subtle border-border-subtle text-fg-inactive"
|
|
94
|
+
),
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
onUpdate: ({ editor }) => {
|
|
98
|
+
onChange?.({ target: { value: editor.getText() } });
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const containerRef = useRef(null);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (typeof ref === "function") {
|
|
106
|
+
ref(containerRef.current);
|
|
107
|
+
} else if (ref) {
|
|
108
|
+
ref.current = containerRef.current;
|
|
109
|
+
}
|
|
110
|
+
}, [ref]);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!editor) return;
|
|
114
|
+
if (value === "") {
|
|
115
|
+
editor.commands.clearContent();
|
|
116
|
+
}
|
|
117
|
+
}, [editor, value]);
|
|
118
|
+
|
|
119
|
+
const contextValue = useMemo(
|
|
120
|
+
() => ({
|
|
121
|
+
editor,
|
|
122
|
+
placeholder,
|
|
123
|
+
editorContainerRef: containerRef,
|
|
124
|
+
}),
|
|
125
|
+
[editor, placeholder]
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<CommentComposerContext.Provider value={contextValue}>
|
|
130
|
+
<form
|
|
131
|
+
className={cx(
|
|
132
|
+
"relative flex gap-12 rounded-default border border-border-minimal bg-bg-base p-12",
|
|
133
|
+
className
|
|
134
|
+
)}
|
|
135
|
+
onSubmit={(event) => {
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
if (isSubmitDisabled) return;
|
|
138
|
+
onSubmit?.(event);
|
|
139
|
+
}}
|
|
140
|
+
{...props}
|
|
141
|
+
>
|
|
142
|
+
{(avatar || avatarProps) && (
|
|
143
|
+
<div className="shrink-0">
|
|
144
|
+
{avatar ?? <Avatar size="small" {...avatarProps} />}
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
<div className="min-w-0 flex-1">
|
|
149
|
+
<div ref={containerRef} className="relative">
|
|
150
|
+
<EditorContent editor={editor} />
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div className="mt-8 flex flex-wrap items-center justify-between gap-8">
|
|
154
|
+
{actions ? (
|
|
155
|
+
<div className="flex flex-wrap items-center gap-8 text-xs text-fg-subtle">
|
|
156
|
+
{actions}
|
|
157
|
+
</div>
|
|
158
|
+
) : (
|
|
159
|
+
<span />
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
<div className="flex items-center gap-8">
|
|
163
|
+
{onCancel && (
|
|
164
|
+
<Button
|
|
165
|
+
type="button"
|
|
166
|
+
size="small"
|
|
167
|
+
isSecondary
|
|
168
|
+
onClick={onCancel}
|
|
169
|
+
disabled={disabled || isSubmitting}
|
|
170
|
+
>
|
|
171
|
+
{cancelLabel}
|
|
172
|
+
</Button>
|
|
173
|
+
)}
|
|
174
|
+
<Button type="submit" size="small" disabled={isSubmitDisabled}>
|
|
175
|
+
{isSubmitting ? "Posting..." : submitLabel}
|
|
176
|
+
</Button>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
{children}
|
|
181
|
+
</form>
|
|
182
|
+
</CommentComposerContext.Provider>
|
|
183
|
+
);
|
|
184
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
3
|
+
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
|
4
|
+
import "@testing-library/jest-dom";
|
|
5
|
+
|
|
6
|
+
vi.mock("@edux-design/utils", () => ({
|
|
7
|
+
cx: (...args) => args.filter(Boolean).join(" "),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("@edux-design/avatars", () => ({
|
|
11
|
+
Avatar: (props) => (
|
|
12
|
+
<div data-testid="avatar" data-name={props.name} data-size={props.size} />
|
|
13
|
+
),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("@edux-design/buttons", () => ({
|
|
17
|
+
Button: ({ children, ...props }) => <button {...props}>{children}</button>,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const mockEditor = {
|
|
21
|
+
isEmpty: true,
|
|
22
|
+
getText: () => "",
|
|
23
|
+
on: vi.fn(),
|
|
24
|
+
off: vi.fn(),
|
|
25
|
+
commands: {
|
|
26
|
+
clearContent: vi.fn(),
|
|
27
|
+
},
|
|
28
|
+
state: {
|
|
29
|
+
selection: {
|
|
30
|
+
from: 0,
|
|
31
|
+
$from: {
|
|
32
|
+
parent: { isTextblock: true, textBetween: () => "" },
|
|
33
|
+
parentOffset: 0,
|
|
34
|
+
pos: 0,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
chain: () => ({
|
|
39
|
+
focus: () => ({
|
|
40
|
+
deleteRange: () => ({
|
|
41
|
+
insertContent: () => ({ run: vi.fn() }),
|
|
42
|
+
}),
|
|
43
|
+
}),
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
vi.mock("@tiptap/react", async () => {
|
|
48
|
+
const React = await import("react");
|
|
49
|
+
return {
|
|
50
|
+
useEditor: vi.fn(() => mockEditor),
|
|
51
|
+
EditorContent: () => <div data-testid="editor" />,
|
|
52
|
+
ReactNodeViewRenderer: (Component) => Component,
|
|
53
|
+
NodeViewWrapper: ({ children }) => <span>{children}</span>,
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
vi.mock("@tiptap/starter-kit", () => ({
|
|
58
|
+
default: {},
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
vi.mock("@tiptap/extension-placeholder", () => ({
|
|
62
|
+
default: {
|
|
63
|
+
configure: () => ({}),
|
|
64
|
+
},
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
vi.mock("@tiptap/core", () => ({
|
|
68
|
+
Node: {
|
|
69
|
+
create: (config) => config,
|
|
70
|
+
},
|
|
71
|
+
mergeAttributes: (...args) => Object.assign({}, ...args),
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
import { CommentComposer } from "./CommentComposer";
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
cleanup();
|
|
78
|
+
vi.restoreAllMocks();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("CommentComposer", () => {
|
|
82
|
+
it("renders avatar and editor", () => {
|
|
83
|
+
render(
|
|
84
|
+
<CommentComposer
|
|
85
|
+
value=""
|
|
86
|
+
onChange={() => {}}
|
|
87
|
+
avatarProps={{ name: "Alex" }}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(screen.getByTestId("avatar")).toBeInTheDocument();
|
|
92
|
+
expect(screen.getByTestId("editor")).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("disables submit when value is empty", () => {
|
|
96
|
+
render(<CommentComposer value="" onChange={() => {}} />);
|
|
97
|
+
|
|
98
|
+
const submitButton = screen.getByRole("button", { name: "Post" });
|
|
99
|
+
expect(submitButton).toBeDisabled();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("submits when value is present", () => {
|
|
103
|
+
const handleSubmit = vi.fn();
|
|
104
|
+
render(
|
|
105
|
+
<CommentComposer value="Hello" onChange={() => {}} onSubmit={handleSubmit} />
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
fireEvent.click(screen.getByRole("button", { name: "Post" }));
|
|
109
|
+
expect(handleSubmit).toHaveBeenCalledTimes(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("calls onCancel when cancel button is clicked", () => {
|
|
113
|
+
const handleCancel = vi.fn();
|
|
114
|
+
render(
|
|
115
|
+
<CommentComposer
|
|
116
|
+
value="Hello"
|
|
117
|
+
onChange={() => {}}
|
|
118
|
+
onCancel={handleCancel}
|
|
119
|
+
/>
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
|
123
|
+
expect(handleCancel).toHaveBeenCalledTimes(1);
|
|
124
|
+
});
|
|
125
|
+
});
|