Compare commits

...

132 Commits

Author SHA1 Message Date
Anton Kulyk
d57464389a Add reactions BE 2025-09-01 19:17:27 +01:00
Stas Gavrylov
2674295fc2 Minor styling fixes for mentions 2025-09-01 17:36:47 +03:00
Stas Gavrylov
e11f8ef516 Fix inserting mentions on Enter press 2025-09-01 17:36:47 +03:00
Kamil Mielnik
cf1407a380 Merge branch 'master' into document-comments 2025-09-01 21:31:26 +07:00
Kamil Mielnik
6e617c2e17 Remove DocumentContext 2025-09-01 21:31:03 +07:00
Anton Kulyk
74b4a33b57 Resolve/unresolve deleted comments 2025-09-01 15:13:16 +01:00
Kamil Mielnik
9277413dfa Fix types 2025-09-01 20:51:38 +07:00
Kamil Mielnik
d36766decc Fix type errors 2025-09-01 20:51:32 +07:00
Uladzimir Havenchyk
783b361cf6 allow using @ character without showing mention list 2025-09-01 16:37:18 +03:00
Stas Gavrylov
92b7e56853 Remove ability to add new comments on Resolved tab 2025-09-01 16:34:06 +03:00
Stas Gavrylov
7c72e8a096 Remove ability to comment on resolved threads 2025-09-01 16:29:47 +03:00
Stas Gavrylov
7f7f505629 Add ability to resolve deleted threads 2025-09-01 16:27:22 +03:00
Kamil Mielnik
97496b814c Make it possible to mention yourself 2025-09-01 20:25:02 +07:00
Kamil Mielnik
29c84db68e Extra padding 2025-09-01 20:17:09 +07:00
Kamil Mielnik
7c3b16f768 Set max-height when editing comments, fix scrolling area 2025-09-01 20:10:40 +07:00
Kamil Mielnik
3411eb0727 Remove more/less feature 2025-09-01 20:01:37 +07:00
Kamil Mielnik
2279d9258d Use createPortal instead of Portal from metabase/ui to fix CommentsMenu appearing above CommentsSidesheet when visiting a specific comment via URL 2025-09-01 19:53:10 +07:00
Kamil Mielnik
5a5b3e60ae Clean up 2025-09-01 19:31:39 +07:00
Kamil Mielnik
21c3fa2db8 Automatically open correct tab for the comment targeted by URL hash 2025-09-01 19:14:32 +07:00
Kamil Mielnik
96bb488dee Refactor URL generation 2025-09-01 19:13:52 +07:00
Stas Gavrylov
6fb4b8232e Make document read-only when comments sidebar is open 2025-09-01 14:00:11 +03:00
Stas Gavrylov
1159fa92c8 Fix key warning in Discussions 2025-09-01 14:00:08 +03:00
Kamil Mielnik
d850f60187 Fix warning 2025-09-01 17:12:40 +07:00
Stas Gavrylov
ce08b86de1 Fix incorrect counter for unresolved comments 2025-09-01 13:00:38 +03:00
Maz Ameli
8a0cd6771d Maz Comments styling tweaks (#62964)
* add tabs hiding/showing logic

* only show persistent button for open comments

* move comment buttons to the right

* attempt to fix tests

* always display the add-a-comment input in the sidebar

* Remove unused attr

* Placeholders without flags

* Refactor comments' tabs markup

* Refactor CommentsMenu

---------

Co-authored-by: Kamil Mielnik <kamil@kamilmielnik.com>
Co-authored-by: Stas Gavrylov <stasgavrylov@gmail.com>
2025-09-01 12:04:10 +03:00
Kamil Mielnik
9d83e67d69 Merge branch 'master' into document-comments 2025-09-01 14:50:32 +07:00
Stas Gavrylov
adf1523ef2 Fix line breaking bug on submit 2025-08-29 17:59:38 +03:00
Konstantin Darutkin
15340da18d fix: sidebar style improvements 2025-08-29 17:36:16 +03:00
Kamil Mielnik
d4e6ff9bcd Deep linking to comments 2025-08-29 21:32:45 +07:00
Kamil Mielnik
4664d5a19a Stupid link copying 2025-08-29 21:32:45 +07:00
Anton Kulyk
d59245492f mention metabase entities 2025-08-29 15:26:35 +01:00
Stas Gavrylov
145d4d2911 Align 'Start new thread' button 2025-08-29 17:13:59 +03:00
Stas Gavrylov
67542849a8 Thread submit input improvements 2025-08-29 17:05:09 +03:00
Kamil Mielnik
46cf040d96 Fix comments count 2025-08-29 21:04:37 +07:00
Kamil Mielnik
9c0daa39f9 Lint 2025-08-29 20:38:10 +07:00
Kamil Mielnik
0f2b8a4e3f Lint migrations 2025-08-29 20:33:31 +07:00
Kamil Mielnik
fe2c3419b6 Lint migrations 2025-08-29 20:27:38 +07:00
Kamil Mielnik
a4fac1699c Refactor 2025-08-29 20:25:43 +07:00
Kamil Mielnik
3bb90b3182 Merge branch 'master' into document-comments 2025-08-29 20:02:58 +07:00
Stas Gavrylov
3a04219e62 Update Start new thread UI 2025-08-29 15:53:33 +03:00
Kamil Mielnik
e4350ac32c Fix 2025-08-29 19:19:48 +07:00
Kamil Mielnik
8607fdd2ea Fix 2025-08-29 19:19:10 +07:00
Anton Kulyk
2eed3b4f9f allow GET /api/user for regular users 2025-08-29 13:13:45 +01:00
Kamil Mielnik
9bf63b1a74 Harden duplicate ids security 2025-08-29 19:10:57 +07:00
Kamil Mielnik
6ffbb5cabf Support bullet lists 2025-08-29 19:06:12 +07:00
Kamil Mielnik
f165333b11 Support ordered lists 2025-08-29 18:58:37 +07:00
Kamil Mielnik
c0c3704574 Extract util 2025-08-29 18:49:18 +07:00
Kamil Mielnik
dd654d4bb7 Prevent commenting on nested paragraphs 2025-08-29 18:25:46 +07:00
Kamil Mielnik
925cf2c188 Fix ids 2025-08-29 18:23:40 +07:00
Konstantin Darutkin
f162dd29ac Merge branch 'document-comments' of github.com:metabase/metabase into document-comments 2025-08-29 14:07:54 +03:00
Konstantin Darutkin
555fe79f41 fix: add missing file 2025-08-29 14:07:16 +03:00
Kamil Mielnik
5e9e0c5eb3 Support headings 2025-08-29 18:05:09 +07:00
Kamil Mielnik
5a44ccec2d Support codeblocks 2025-08-29 18:05:09 +07:00
Kamil Mielnik
af0d5e48e9 Fix 2025-08-29 18:05:09 +07:00
Konstantin Darutkin
8e2afe3673 fix: reply styles 2025-08-29 13:28:37 +03:00
Konstantin Darutkin
fb977619fe fix: comment deleted label text 2025-08-29 13:21:46 +03:00
Konstantin Darutkin
9029d61a48 Merge branch 'document-comments' of github.com:metabase/metabase into document-comments 2025-08-29 13:21:07 +03:00
Konstantin Darutkin
3ff896ae13 fix: expand comment on edit 2025-08-29 13:20:44 +03:00
Kamil Mielnik
ec5fe9180d Support blockquotes 2025-08-29 17:18:32 +07:00
Kamil Mielnik
0190353203 Fix floating ui not following the reference elements while scrolling the document 2025-08-29 17:03:09 +07:00
Kamil Mielnik
f4621294dd Fix 2025-08-29 17:03:09 +07:00
Kamil Mielnik
e5c5d49bba Fix import 2025-08-29 17:03:09 +07:00
Konstantin Darutkin
b5c10b6a88 fix: padding 2025-08-29 12:35:06 +03:00
Stas Gavrylov
1bb049ea6a Refactor DiscussionComment 2025-08-29 12:27:08 +03:00
Anton Kulyk
e655cffc6c add mentions 2025-08-29 10:15:59 +01:00
Konstantin Darutkin
d05ea6feff Merge branch 'document-comments' of github.com:metabase/metabase into document-comments 2025-08-29 12:11:25 +03:00
Konstantin Darutkin
cce46bd803 fix: show more button style and mask 2025-08-29 12:11:05 +03:00
Kamil Mielnik
5a7eca960c Comments on charts 2025-08-29 15:58:50 +07:00
Kamil Mielnik
4678d6130f Extract CommentsMenu 2025-08-29 15:48:47 +07:00
Kamil Mielnik
1f9aade4fa Floating menu, fixes links opening in new tab, fixes divs in paragraphs 2025-08-29 15:33:06 +07:00
Kamil Mielnik
34818ea2dc Do not use Paragraph extension in DiscussionComment 2025-08-29 15:22:23 +07:00
Kamil Mielnik
3c9f24969f Do not use Paragraph extension in CommentEditor 2025-08-29 15:21:55 +07:00
Konstantin Darutkin
fa90522058 feat: add comment editing 2025-08-29 11:02:23 +03:00
Konstantin Darutkin
6c261b9b7f fix: deleted comment thread 2025-08-29 10:49:38 +03:00
Konstantin Darutkin
7642ba7f2c feat: resolve/unresolve + delete 2025-08-29 10:23:47 +03:00
Kamil Mielnik
366626a8d0 Tooltip and disable button 2025-08-28 22:07:45 +07:00
Kamil Mielnik
0f92b2c43a Fix count 2025-08-28 21:56:49 +07:00
Kamil Mielnik
9620f6c43d Do not lock scroll 2025-08-28 21:53:43 +07:00
Kamil Mielnik
e2152f6897 Fix links 2025-08-28 21:52:49 +07:00
Kamil Mielnik
9ca9c49ef1 Fix 2025-08-28 21:45:27 +07:00
Kamil Mielnik
1f0c6095ed Fix 2025-08-28 21:44:35 +07:00
Kamil Mielnik
f2ac627e4b Highlight node to be commented on 2025-08-28 21:40:22 +07:00
Kamil Mielnik
2055c80e79 Fix ids 2025-08-28 21:24:59 +07:00
Kamil Mielnik
7a0a795ee3 Fix 2025-08-28 21:23:10 +07:00
Stas Gavrylov
9d4e431c26 Add removing of empty trailing paragraphs 2025-08-28 16:42:46 +03:00
Konstantin Darutkin
2ba4b3f0c0 fix: comment display 2025-08-28 16:15:23 +03:00
Konstantin Darutkin
7491705b76 fix: spoiler mask 2025-08-28 15:59:57 +03:00
Kamil Mielnik
ee608a26e6 Use CommentEditor 2025-08-28 19:48:58 +07:00
Kamil Mielnik
bc2884322b Fix 2025-08-28 19:47:32 +07:00
Kamil Mielnik
76666fb4d9 Render comments 2025-08-28 19:47:29 +07:00
Kamil Mielnik
3e91c101de Always show the button if there are comments 2025-08-28 19:43:16 +07:00
Anton Kulyk
5096e06549 make comment content a json column 2025-08-28 13:41:01 +01:00
Kamil Mielnik
a7ba10c7ab Fix warning 2025-08-28 19:39:19 +07:00
Kamil Mielnik
db8f7001b0 Make sure ids are not duplicated 2025-08-28 19:29:21 +07:00
Kamil Mielnik
e426eae847 Fix 2025-08-28 19:24:30 +07:00
Kamil Mielnik
084113dd5f Discussions component 2025-08-28 19:20:50 +07:00
Stas Gavrylov
0d3084c3ef Fix placeholder styling 2025-08-28 15:17:21 +03:00
Kamil Mielnik
edfe1b1da4 Submit comment 2025-08-28 19:11:06 +07:00
Kamil Mielnik
d54174dc2c Fix 2025-08-28 19:03:35 +07:00
Stas Gavrylov
bf50302ea9 Merge branch 'comments-editor' into document-comments 2025-08-28 14:58:53 +03:00
Stas Gavrylov
6d5cd89e68 Update mock Discussion component 2025-08-28 14:58:34 +03:00
Kamil Mielnik
54e075d2de Comments sidebar 2025-08-28 18:58:10 +07:00
Anton Kulyk
b2c982d306 allow replying to deleted comments 2025-08-28 12:25:48 +01:00
Anton Kulyk
e85e11abac replace deleted comment's content with nil 2025-08-28 12:25:32 +01:00
Stas Gavrylov
0c76e419d2 Implement initial version of CommentEditor component 2025-08-28 14:24:48 +03:00
Kamil Mielnik
ed428209ac Fix 2025-08-28 18:03:24 +07:00
Kamil Mielnik
8902d7744e Update comment api paths, add context to document 2025-08-28 18:02:45 +07:00
Konstantin Darutkin
e1ea632e83 feat: add comment action buttons (#62858) 2025-08-28 13:08:26 +03:00
Kamil Mielnik
1cefdeeaff Comment button and icon 2025-08-28 17:00:30 +07:00
Konstantin Darutkin
5912f09719 chore: add Timeline and Spoiler Mantine componnets (#62855) 2025-08-28 12:09:25 +03:00
Kamil Mielnik
22ec7c1576 Fix 2025-08-28 15:22:17 +07:00
Kamil Mielnik
f9c3551ce0 Update types 2025-08-28 15:19:23 +07:00
Kamil Mielnik
7c9ad0ea75 Move API to enterprise dir 2025-08-28 15:18:48 +07:00
Kamil Mielnik
a668557fa7 Update path 2025-08-28 15:18:48 +07:00
Konstantin Darutkin
cd4eab939f feat: add comment thread display component (#62801)
* feat: add comment thread display component

* feat: add Discussion component
2025-08-28 11:13:23 +03:00
Kamil Mielnik
4528b2faf2 Document comments - backend (#62853)
* db migration + create/list/update

* soft delete comments

* permissions

---------

Co-authored-by: Anton Kulyk <kuliks.anton@gmail.com>
2025-08-28 14:48:17 +07:00
Kamil Mielnik
4cd8c87b05 Make Paragraph work with ids 2025-08-27 22:14:22 +07:00
Kamil Mielnik
2324a5674b Paragraph plugin 2025-08-27 22:01:47 +07:00
Kamil Mielnik
0669dbc8cf Basic NodeIds plugin (not working yet) 2025-08-27 21:43:41 +07:00
Kamil Mielnik
021ccaea75 Clean up 2025-08-27 20:03:47 +07:00
Kamil Mielnik
21ff2b47dd Inline CommentHistory 2025-08-27 19:56:06 +07:00
Kamil Mielnik
77f59a06c7 getCommentThreads 2025-08-27 19:54:53 +07:00
Kamil Mielnik
d3d2c902cc Update RTK and types 2025-08-27 19:49:33 +07:00
Kamil Mielnik
048ea6a2bf Add index 2025-08-27 17:58:28 +07:00
Kamil Mielnik
d3a4f3baf0 RTK query mappings for comments API 2025-08-27 17:42:10 +07:00
Kamil Mielnik
36d783047b Comment 2025-08-27 17:08:19 +07:00
Kamil Mielnik
a8beeef2f5 Add Discussion stub 2025-08-27 16:57:15 +07:00
Kamil Mielnik
47742c6bef Rename 2025-08-27 16:51:35 +07:00
Kamil Mielnik
2415187611 Types 2025-08-27 16:49:20 +07:00
Kamil Mielnik
e6f2c5d042 Add comment, move things to enterprise dir 2025-08-27 16:47:38 +07:00
Kamil Mielnik
c14c60fd05 Add CommentEditor stub 2025-08-27 16:44:58 +07:00
Kamil Mielnik
a70de7f860 Add types and mocks 2025-08-27 16:39:11 +07:00
91 changed files with 4628 additions and 350 deletions

View File

@@ -15,6 +15,7 @@
[metabase-enterprise.audit-app.api.routes]
[metabase-enterprise.billing.api.routes]
[metabase-enterprise.cloud-add-ons.api]
[metabase-enterprise.comments.api]
[metabase-enterprise.content-translation.routes]
[metabase-enterprise.content-verification.api.routes]
[metabase-enterprise.database-replication.api :as database-replication.api]
@@ -93,6 +94,7 @@
"/billing" metabase-enterprise.billing.api.routes/routes
"/content-translation" (premium-handler metabase-enterprise.content-translation.routes/routes :content-translation)
"/cloud-add-ons" metabase-enterprise.cloud-add-ons.api/routes
"/comment" (premium-handler metabase-enterprise.comments.api/routes :documents)
"/database-replication" (-> database-replication.api/routes ;; database-replication requires all these features.
(premium-handler :attached-dwh)
(premium-handler :etl-connections)

View File

@@ -0,0 +1,10 @@
(ns metabase-enterprise.comments.api
"`/api/ee/comment/` routes"
(:require
[metabase-enterprise.comments.api.comment]
[metabase.api.util.handlers :as handlers]))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/comment/` routes."
(handlers/routes
metabase-enterprise.comments.api.comment/routes))

View File

@@ -0,0 +1,227 @@
(ns metabase-enterprise.comments.api.comment
"`/api/ee/comment/` routes"
(:require
[metabase-enterprise.comments.models.comment :as m.comment]
[metabase-enterprise.comments.models.comment-reaction :as m.comment-reaction]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.models.interface :as mi]
[metabase.util.json :as json]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
(defn- can-read-entity?
"Check if current user can read the entity being commented on"
[target-type target-id]
(case target-type
"document" (try
(api/read-check (t2/select-one :model/Document :id target-id))
true
(catch Exception _ false))
false))
(defn- can-write-entity?
"Check if current user can write to the entity being commented on"
[target-type target-id]
(case target-type
"document" (try
(let [entity (t2/select-one :model/Document :id target-id)]
(api/read-check entity)
(mi/can-write? entity))
(catch Exception _ false))
false))
(defn- entity-archived?
"Check if the target entity is archived"
[target-type target-id]
(case target-type
"document" (let [entity (t2/select-one :model/Document :id target-id)]
(:archived entity))
false))
(defn- decode-comment-content
"Decode JSON content, handling both old text format and new JSON format"
[content]
(cond
(nil? content) nil
(string? content) (try
(json/decode content true)
(catch Exception _
;; If it fails to parse, assume it's old plain text format
{:text content}))
:else content))
(defn- process-deleted-comments
"Process comments to handle soft-deleted ones with threading logic.
- Remove deleted comments that have no replies
- Replace deleted comments that have replies with placeholder content"
[comments]
(let [comment-map (into {} (map #(vector (:id %) %) comments))
has-replies? #(some (fn [c] (= (:parent_comment_id c) %)) (vals comment-map))]
(->> comments
(map (fn [comment]
(let [decoded-comment (update comment :content decode-comment-content)]
(if (and (:is_deleted comment) (has-replies? (:id comment)))
;; Replace deleted comment content with empty JSON if it has replies
(assoc decoded-comment
:content {}
:creator nil)
decoded-comment))))
;; Remove deleted comments that have no replies
(remove #(and (:is_deleted %) (not (has-replies? (:id %))))))))
(defn- hydrate-reactions
"Add reactions to comments"
[comments current-user-id]
(if (seq comments)
(let [comment-ids (map :id comments)
reactions-map (m.comment-reaction/get-reactions-for-comments comment-ids current-user-id)]
(map (fn [comment]
(assoc comment :reactions (get reactions-map (:id comment) [])))
comments))
comments))
(api.macros/defendpoint :get "/"
"Get comments for an entity"
[_route-params
{:keys [target_type target_id]} :- [:map
[:target_type [:enum "document"]]
[:target_id ms/PositiveInt]]]
(api/check-403 (can-read-entity? target_type target_id))
(let [all-comments (t2/hydrate
(t2/select :model/Comment
{:where [:and
[:= :target_type target_type]
[:= :target_id target_id]]
:order-by [[:created_at :asc]]})
:creator)
processed-comments (process-deleted-comments all-comments)
comments-with-reactions (hydrate-reactions processed-comments api/*current-user-id*)]
{:comments comments-with-reactions}))
(api.macros/defendpoint :post "/"
"Create a new comment"
[_route-params
_query-params
{:keys [target_type target_id child_target_id parent_comment_id content]} :- m.comment/CreateComment]
(api/check-403 (can-read-entity? target_type target_id))
(api/check-400 (not (entity-archived? target_type target_id))
"Cannot comment on archived entities")
;; If this is a reply, validate the parent comment exists and belongs to same entity
(when parent_comment_id
(let [parent (api/check-404 (t2/select-one :model/Comment :id parent_comment_id))]
(api/check-400 (and (= (:target_type parent) target_type)
(= (:target_id parent) target_id))
"Parent comment doesn't belong to the same entity")))
(let [comment-id (t2/insert-returning-pk! :model/Comment
{:target_type target_type
:target_id target_id
:child_target_id child_target_id
:parent_comment_id parent_comment_id
:content (json/encode content)
:creator_id api/*current-user-id*})]
(let [new-comment (t2/hydrate (t2/select-one :model/Comment :id comment-id) :creator)
comment-with-content (update new-comment :content decode-comment-content)]
;; New comments always have empty reactions array
(assoc comment-with-content :reactions []))))
(api.macros/defendpoint :put "/:comment-id"
"Update a comment"
[{:keys [comment-id]} :- [:map [:comment-id ms/PositiveInt]]
_query-params
{:keys [content is_resolved]} :- m.comment/UpdateComment]
(let [comment (api/check-404 (t2/select-one :model/Comment :id comment-id))]
;; Check if target entity is archived - makes comments read-only
(api/check-400 (not (entity-archived? (:target_type comment) (:target_id comment)))
"Cannot edit comments on archived entities")
;; Check permissions based on what's being updated
(when content
;; Cannot edit content of deleted comments
(api/check-400 (not (:is_deleted comment))
"Cannot edit content of a deleted comment")
;; Only creator or admin can edit comment content
(api/check-403 (or (= (:creator_id comment) api/*current-user-id*)
(:is_superuser @api/*current-user*))))
(when (some? is_resolved)
;; Anyone with write permission to target entity can resolve/unresolve
(api/check-403 (or (can-write-entity? (:target_type comment) (:target_id comment))
(:is_superuser @api/*current-user*))))
(let [updates (cond-> {}
content (assoc :content (json/encode content))
(some? is_resolved) (assoc :is_resolved is_resolved))]
(when (seq updates)
(t2/update! :model/Comment comment-id updates)))
(let [updated-comment (t2/hydrate (t2/select-one :model/Comment :id comment-id) :creator)
comment-with-content (update updated-comment :content decode-comment-content)
;; Include current reactions in response
reactions-map (m.comment-reaction/get-reactions-for-comments [comment-id] api/*current-user-id*)]
(assoc comment-with-content :reactions (get reactions-map comment-id [])))))
(api.macros/defendpoint :delete "/:comment-id"
"Soft delete a comment"
[{:keys [comment-id]} :- [:map [:comment-id ms/PositiveInt]]
_query-params]
(let [comment (api/check-404 (t2/select-one :model/Comment :id comment-id))]
;; Check if target entity is archived - makes comments read-only
(api/check-400 (not (entity-archived? (:target_type comment) (:target_id comment)))
"Cannot delete comments on archived entities")
;; Only creator or admin can delete comments
(api/check-403 (or (= (:creator_id comment) api/*current-user-id*)
(:is_superuser @api/*current-user*)))
(when (:is_deleted comment)
(api/check-400 false "Comment is already deleted"))
;; Soft delete the comment
(t2/update! :model/Comment comment-id {:is_deleted true})
;; Return 204 No Content
api/generic-204-no-content))
(api.macros/defendpoint :post "/:comment-id/reaction"
"Set a reaction on a comment (replaces any existing reaction)"
[{:keys [comment-id]} :- [:map [:comment-id ms/PositiveInt]]
_query-params
{:keys [emoji]} :- [:map [:emoji [:string {:min 1 :max 10}]]]]
(let [comment (api/check-404 (t2/select-one :model/Comment :id comment-id))]
;; Check if user can read the target entity
(api/check-403 (can-read-entity? (:target_type comment) (:target_id comment)))
;; Cannot react to deleted comments
(api/check-400 (not (:is_deleted comment))
"Cannot react to deleted comments")
;; Cannot react to comments on archived entities
(api/check-400 (not (entity-archived? (:target_type comment) (:target_id comment)))
"Cannot react to comments on archived entities")
;; Set the reaction (replaces any existing)
(m.comment-reaction/set-reaction! comment-id api/*current-user-id* emoji)
api/generic-204-no-content))
(api.macros/defendpoint :delete "/:comment-id/reaction"
"Remove user's reaction from a comment"
[{:keys [comment-id]} :- [:map [:comment-id ms/PositiveInt]]
_query-params]
(let [comment (api/check-404 (t2/select-one :model/Comment :id comment-id))]
;; Check if user can read the target entity
(api/check-403 (can-read-entity? (:target_type comment) (:target_id comment)))
;; Remove the reaction (no error if it doesn't exist)
(m.comment-reaction/delete-reaction! comment-id api/*current-user-id*)
api/generic-204-no-content))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/comment/` routes."
(api.macros/ns-handler *ns* +auth))

View File

@@ -0,0 +1,3 @@
(ns metabase-enterprise.comments.init
(:require
[metabase-enterprise.comments.models.comment]))

View File

@@ -0,0 +1,49 @@
(ns metabase-enterprise.comments.models.comment
(:require
[metabase.models.interface :as mi]
[metabase.util.i18n :refer [deferred-tru]]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(methodical/defmethod t2/table-name :model/Comment [_model] :comment)
(doto :model/Comment
(derive :metabase/model)
(derive :hook/timestamped?)
(derive :hook/entity-id))
(def CommentContent
"Validation for comment content - expects JSON"
(mu/with-api-error-message
[:and
{:error/message "Comment content must be valid JSON"
:json-schema {:type "object"}}
[:map]]
(deferred-tru "Comment content must be valid JSON.")))
(def CreateComment
"Schema for creating a new comment"
[:map
[:target_type {:optional false} [:enum "document"]]
[:target_id {:optional false} ms/PositiveInt]
[:child_target_id {:optional true} [:maybe :string]]
[:parent_comment_id {:optional true} [:maybe ms/PositiveInt]]
[:content {:optional false} CommentContent]])
(def UpdateComment
"Schema for updating a comment"
[:map
[:content {:optional true} CommentContent]
[:is_resolved {:optional true} :boolean]])
(methodical/defmethod t2/batched-hydrate [:model/Comment :creator]
"Hydrate the creator (user) of a comment based on the creator_id."
[_model k comments]
(mi/instances-with-hydrated-data
comments k
#(-> (t2/select [:model/User :id :email :first_name :last_name] :id (keep :creator_id comments))
(map (juxt :id identity))
(into {}))
:creator_id {:default {}}))

View File

@@ -0,0 +1,97 @@
(ns metabase-enterprise.comments.models.comment-reaction
"Model for comment reactions (emoji reactions on comments)"
(:require
[metabase.models.interface :as mi]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(methodical/defmethod t2/table-name :model/CommentReaction [_model] :comment_reaction)
(doto :model/CommentReaction
(derive :metabase/model))
(mi/define-simple-hydration-method user
:user
"Hydrate the user who created this reaction"
[{:keys [user_id]}]
(t2/select-one [:model/User :id :email :first_name :last_name] :id user_id))
(defn get-user-reaction
"Get the current reaction for a user on a comment, returns nil if none"
[comment-id user-id]
(t2/select-one :model/CommentReaction
:comment_id comment-id
:user_id user-id))
(defn set-reaction!
"Set or replace a user's reaction on a comment"
[comment-id user-id emoji]
(let [existing (get-user-reaction comment-id user-id)]
(if existing
;; Update existing reaction if emoji is different
(when (not= (:emoji existing) emoji)
(t2/update! :model/CommentReaction (:id existing)
{:emoji emoji}))
;; Create new reaction
(t2/insert! :model/CommentReaction
{:comment_id comment-id
:user_id user-id
:emoji emoji}))))
(defn delete-reaction!
"Delete a user's reaction from a comment"
[comment-id user-id]
(t2/delete! :model/CommentReaction
:comment_id comment-id
:user_id user-id))
;; Removed toggle-reaction since we now have set-reaction! for POST and delete-reaction! for DELETE
(defn get-reactions-for-comments
"Get all reactions for a list of comment IDs, grouped and formatted for API response.
Returns a map of comment-id -> vector of reaction objects"
[comment-ids current-user-id]
(when (seq comment-ids)
(let [;; Get all reactions
reactions (t2/select :model/CommentReaction
{:where [:in :comment_id comment-ids]
:order-by [[:comment_id :asc] [:emoji :asc] [:created_at :desc]]})
;; Get unique user IDs and fetch user info
user-ids (distinct (map :user_id reactions))
users-map (when (seq user-ids)
(into {}
(map (juxt :id identity))
(t2/select [:model/User :id :first_name :last_name]
{:where [:in :id user-ids]})))
;; Group by comment_id and emoji with user info
grouped (reduce (fn [acc reaction]
(let [comment-id (:comment_id reaction)
emoji (:emoji reaction)
user-info (get users-map (:user_id reaction))
user {:id (:user_id reaction)
:name (if (or (:first_name user-info) (:last_name user-info))
(str (or (:first_name user-info) "")
(when (and (:first_name user-info) (:last_name user-info)) " ")
(or (:last_name user-info) ""))
"Unknown User")}]
(update-in acc [comment-id emoji] (fnil conj []) user)))
{}
reactions)]
;; Transform to final format with current user first if they reacted
(into {}
(for [[comment-id emoji-map] grouped]
[comment-id
(vec (for [[emoji users] emoji-map]
(let [;; Separate current user from other users
current-user-reaction (first (filter #(= (:id %) current-user-id) users))
other-users (remove #(= (:id %) current-user-id) users)
;; Combine with current user first, limit to 10 users
ordered-users (take 10 (if current-user-reaction
(cons current-user-reaction other-users)
other-users))]
{:emoji emoji
:count (count users)
:users (vec ordered-users)})))])))))

View File

@@ -0,0 +1,73 @@
import type {
Comment,
CommentId,
CreateCommentRequest,
ListCommentsRequest,
UpdateCommentRequest,
} from "metabase-types/api";
import { EnterpriseApi } from "./api";
import { idTag, invalidateTags, listTag, provideCommentListTags } from "./tags";
export const commentApi = EnterpriseApi.injectEndpoints({
endpoints: (builder) => ({
listComments: builder.query<
{ comments: Comment[] },
ListCommentsRequest | void
>({
query: (params) => ({
method: "GET",
url: "/api/ee/comment",
params,
}),
providesTags: (response) =>
response ? provideCommentListTags(response.comments) : [],
}),
createComment: builder.mutation<Comment, CreateCommentRequest>({
query: (body) => ({
method: "POST",
url: `/api/ee/comment`,
body,
}),
invalidatesTags: (_, error) =>
invalidateTags(error, [listTag("comment")]),
}),
updateComment: builder.mutation<Comment, UpdateCommentRequest>({
query: ({ id, ...body }) => ({
method: "PUT",
url: `/api/ee/comment/${id}`,
body,
}),
invalidatesTags: (_, error, { id }) =>
invalidateTags(error, [idTag("comment", id), listTag("comment")]),
}),
deleteComment: builder.mutation<void, CommentId>({
query: (id) => ({
method: "DELETE",
url: `/api/ee/comment/${id}`,
}),
invalidatesTags: (_, error, id) =>
invalidateTags(error, [idTag("comment", id), listTag("comment")]),
}),
getCommentHistory: builder.query<Comment[], CommentId>({
query: (id) => ({
method: "GET",
url: `/api/ee/comment/${id}/history`,
}),
providesTags: (response) =>
response && response.length > 0 ? provideCommentListTags(response) : [],
}),
}),
});
export const {
useListCommentsQuery,
useCreateCommentMutation,
useUpdateCommentMutation,
useDeleteCommentMutation,
useGetCommentHistoryQuery,
} = commentApi;

View File

@@ -1,13 +1,14 @@
export * from "./ai-entity-analysis";
export * from "./ai-sql-generation";
export * from "./audit-info";
export * from "./api";
export * from "./audit-info";
export * from "./billing-info";
export * from "./comment";
export * from "./content-translation";
export * from "./db-routing";
export * from "./document";
export * from "./gdrive";
export * from "./metabot";
export * from "./document";
export * from "./saml";
export * from "./scim";
export * from "./tags";

View File

@@ -1,7 +1,8 @@
import type { TagDescription } from "@reduxjs/toolkit/query";
import { TAG_TYPES } from "metabase/api/tags";
import { TAG_TYPES, provideUserTags } from "metabase/api/tags";
import type {
Comment,
Transform,
TransformJob,
TransformRun,
@@ -16,6 +17,7 @@ export const ENTERPRISE_TAG_TYPES = [
"metabot-prompt-suggestions",
"gsheets-status",
"document",
"comment",
"transform",
"transform-tag",
"transform-job",
@@ -103,3 +105,19 @@ export function provideTransformJobListTags(
): TagDescription<EnterpriseTagType>[] {
return [listTag("transform-job"), ...jobs.flatMap(provideTransformJobTags)];
}
export function provideCommentListTags(
comments: Comment[],
): TagDescription<EnterpriseTagType>[] {
return [listTag("comment"), ...comments.flatMap(provideCommentTags)];
}
export function provideCommentTags(
comment: Comment,
): TagDescription<EnterpriseTagType>[] {
if (comment.creator) {
return [idTag("comment", comment.id), ...provideUserTags(comment.creator)];
}
return [idTag("comment", comment.id)];
}

View File

@@ -0,0 +1,96 @@
.container {
/* background: var(--mb-color-bg-white, #fff); */
border: 1px solid var(--mb-color-border);
box-shadow: var(--mantine-shadow-xs);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-spacing-sm);
z-index: 1;
width: 100%;
&.readonly,
&:not(.active) {
border: none;
box-shadow: none;
padding: 0;
.content :global(.ProseMirror) {
padding-block: var(--mantine-spacing-xs);
}
.submitBtn {
visibility: hidden;
pointer-events: none;
}
}
&:not(.active):focus-within {
.submitBtn {
visibility: visible;
pointer-events: auto;
}
}
&:not(.readonly) {
.contentWrapper {
max-height: 12rem;
overflow: auto;
scrollbar-width: thin;
}
}
}
.contentWrapper {
width: 100%;
height: 100%;
}
/* ProseMirror area */
.content {
position: relative;
}
.content :global(.ProseMirror) {
display: flex;
flex-direction: column;
justify-content: center;
outline: none;
width: 100%;
height: 100%;
min-height: 1.25rem;
line-height: 1.5;
color: var(--mb-color-text-dark);
font-size: var(--mantine-font-size-md);
padding-block: var(--mantine-spacing-sm);
white-space: pre-wrap;
word-break: break-word;
&:focus-visible {
outline: none;
}
}
.content :global(.ProseMirror p) {
margin: 0;
}
/* Placeholder styling */
.content :global(.is-empty:first-child:before) {
color: var(--mb-color-text-light);
content: attr(data-placeholder);
height: 0;
pointer-events: none;
position: absolute;
}
.submitBtn {
width: 2rem;
flex-shrink: 0;
margin-top: auto;
margin-bottom: 0.25rem;
transition: color ease 200ms;
&.canSubmit,
&.canSubmit:hover {
color: var(--mb-color-brand);
}
}

View File

@@ -0,0 +1,168 @@
import Link from "@tiptap/extension-link";
import { Placeholder } from "@tiptap/extension-placeholder";
import {
type Editor,
EditorContent,
type Extension,
useEditor,
} from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import cx from "classnames";
import { useEffect, useMemo, useState } from "react";
import { t } from "ttag";
import CS from "metabase/css/core/index.css";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { getSetting } from "metabase/selectors/settings";
import { ActionIcon, Box, Flex, Icon } from "metabase/ui";
import { configureMentionExtension } from "metabase-enterprise/comments/mentions/extension";
import { EditorBubbleMenu } from "metabase-enterprise/documents/components/Editor/EditorBubbleMenu";
import { DisableMetabotSidebar } from "metabase-enterprise/documents/components/Editor/extensions/DisableMetabotSidebar";
import { SmartLink } from "metabase-enterprise/documents/components/Editor/extensions/SmartLink/SmartLinkNode";
import type { DocumentContent } from "metabase-types/api";
import S from "./CommentEditor.module.css";
import { trimTrailingEmptyParagraphsJSON } from "./editor.utils";
const BUBBLE_MENU_DISALLOWED_NODES: string[] = [SmartLink.name];
// TODO: Other formats require to update editor styling.
const ALLOWED_FORMATTING = {
bold: true,
italic: true,
strikethrough: true,
} as const;
interface Props {
active?: boolean;
autoFocus?: boolean;
initialContent?: DocumentContent | null;
placeholder?: string;
readonly?: boolean;
onBlur?: (content: DocumentContent, editor: Editor) => void;
onChange?: (content: DocumentContent) => void;
onSubmit?: (content: DocumentContent) => void;
}
export const CommentEditor = ({
active = true,
autoFocus = false,
initialContent,
placeholder = t`Reply…`,
readonly = false,
onBlur,
onChange,
onSubmit,
}: Props) => {
const siteUrl = useSelector((state) => getSetting(state, "site-url"));
const [content, setContent] = useState<string | null>(null);
const dispatch = useDispatch();
const extensions = useMemo(
() =>
[
StarterKit.configure({ link: false }),
SmartLink.configure({
HTMLAttributes: { class: "smart-link" },
siteUrl,
}),
Link.configure({
HTMLAttributes: { class: CS.link },
}),
configureMentionExtension({ dispatch }),
!readonly && Placeholder.configure({ placeholder }),
!readonly && DisableMetabotSidebar,
].filter(Boolean) as Extension[],
[siteUrl, dispatch, readonly, placeholder],
);
const editor = useEditor(
{
extensions,
content: initialContent || "",
autofocus: autoFocus,
editable: !readonly,
immediatelyRender: true,
onUpdate: ({ editor }) => {
const doc = editor.getText();
setContent(doc);
if (onChange) {
onChange(editor.getJSON() as DocumentContent);
}
},
onBlur: ({ editor }) => {
if (onBlur) {
onBlur(editor.getJSON() as DocumentContent, editor);
}
},
},
[readonly, autoFocus],
);
useEffect(() => {
if (editor && initialContent) {
editor.commands.setContent(initialContent);
}
}, [editor, initialContent]);
if (!editor) {
return null;
}
const submitDoc = () => {
const content = editor.getJSON() as DocumentContent;
const isEmpty = editor.isEmpty;
if (!isEmpty && onSubmit) {
onSubmit(trimTrailingEmptyParagraphsJSON(content));
editor.commands.clearContent(true);
}
};
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
if (readonly) {
return;
}
if (e.key === "Enter" && !e.shiftKey) {
// see CustomMentionExtension
const isMentionOpen = (editor.storage as any)?.mention
?.isMentionPopupOpen;
if (isMentionOpen) {
return;
}
e.preventDefault();
if (!onSubmit) {
return;
}
submitDoc();
}
};
return (
<Flex
className={cx(S.container, {
[S.readonly]: readonly,
[S.active]: active,
})}
onKeyDownCapture={handleKeyDown}
>
<Box className={S.contentWrapper}>
<EditorContent editor={editor} className={S.content} />
</Box>
{readonly ? null : (
<ActionIcon
variant="subtle"
className={cx(S.submitBtn, { [S.canSubmit]: !!content })}
onClick={submitDoc}
>
<Icon name="arrow_up" />
</ActionIcon>
)}
{!readonly && (
<EditorBubbleMenu
editor={editor}
disallowedNodes={BUBBLE_MENU_DISALLOWED_NODES}
allowedFormatting={ALLOWED_FORMATTING}
/>
)}
</Flex>
);
};

View File

@@ -0,0 +1,42 @@
import type { JSONContent } from "@tiptap/core";
import type { DocumentContent } from "metabase-types/api";
function isEmptyParagraphNode(node: JSONContent) {
if (node.type !== "paragraph") {
return false;
}
// Empty paragraph => no content or content has no text nodes with non-empty text
if (!node.content || node.content.length === 0) {
return true;
}
const hasNonEmptyText = node.content.some(
(child) =>
child.type === "text" &&
typeof child.text === "string" &&
child.text.trim().length > 0,
);
return !hasNonEmptyText;
}
export function trimTrailingEmptyParagraphsJSON(
doc: DocumentContent,
): DocumentContent {
if (!doc?.content || !Array.isArray(doc.content)) {
return doc;
}
let dropping = true; // drop until we hit a non-empty node
const trimmed = doc.content
.reduceRight<DocumentContent[]>((acc, node) => {
if (dropping && isEmptyParagraphNode(node)) {
return acc; // skip this trailing empty paragraph
}
dropping = false;
acc.push(node);
return acc;
}, [])
.reverse();
return { ...doc, content: trimmed };
}

View File

@@ -0,0 +1 @@
export * from "./CommentEditor";

View File

@@ -0,0 +1,88 @@
.spoilerControl {
height: auto;
z-index: 1;
color: var(--mb-color-text-secondary);
&:hover {
color: var(--mb-color-brand);
}
& + .spoilerContent {
overflow: hidden;
pointer-events: none;
}
}
.spoilerRoot[data-has-spoiler] .spoilerContent {
/* mask: linear-gradient(0deg, transparent 0, rgb(0, 0, 0) 60px),
linear-gradient(270deg, transparent 0, transparent 50px, rgb(0, 0, 0) 100px); */
mask: linear-gradient(
0deg,
transparent 0,
transparent 20px,
rgb(0, 0, 0) 20px
),
linear-gradient(270deg, transparent 0, transparent 48px, rgb(0, 0, 0) 96px);
}
.commentRoot {
position: relative;
margin-top: 1.25rem;
}
.target {
--padding-v: 16px;
--padding-h: 24px;
&:before {
content: "";
display: block;
position: absolute;
margin-left: -12px;
margin-top: -4px;
left: calc(-1 * var(--padding-h) / 2);
top: calc(-1 * var(--padding-v) / 2);
width: calc(100% + var(--padding-h) + 12.5px);
height: calc(100% + var(--padding-v) + 4px);
background-color: var(--mb-color-brand-alpha-04);
border: 1px solid var(--mb-color-brand) !important;
border-radius: 8px;
pointer-events: none;
}
}
.discussionRoot {
position: relative;
}
.actionPanel {
position: absolute;
top: 0;
right: 0;
z-index: 1;
/* Center the action panel vertically */
transform: translateY(-25%);
border: 1px solid var(--mb-color-border);
visibility: hidden;
}
.visible {
visibility: visible;
}
.visibleOnCommentHover {
.commentRoot:hover & {
visibility: visible;
}
}
.visibleOnDiscussionHover {
.discussionRoot:hover & {
visibility: visible;
}
}
.commentBulletDeleted {
background-color: var(--mb-color-background);
color: var(--mb-color-text-disabled);
}

View File

@@ -0,0 +1,107 @@
import type { StoryFn } from "@storybook/react";
import { Box, Flex } from "metabase/ui";
import { createMockDocumentContentParagraph } from "metabase-types/api/mocks";
import { Discussion, type DiscussionProps } from "./Discussion";
export default {
title: "Components/Comments/Discussion",
component: Discussion,
argTypes: {},
decorators: [
(Story: StoryFn) => (
<Flex w="100%" h="92vh" justify="center" align="center">
<Box w="320px">
<Story />
</Box>
</Flex>
),
],
};
function getDateRelativeToNow(secondsAgo: number) {
return new Date(Date.now() - secondsAgo * 1000).toISOString();
}
export const SingleComment = {
render: (args: DiscussionProps) => <Discussion {...args} />,
args: {
comments: [
{
id: "1",
content: createMockDocumentContentParagraph(
"The revenue trend line is helpful, but could we also add quarter-over-quarter growth as a percentage?",
),
created_at: getDateRelativeToNow(60 * 60 * 24 * 2),
creator: {
common_name: "John Doe",
},
},
],
},
};
export const LongComment = {
render: (args: DiscussionProps) => <Discussion {...args} />,
args: {
comments: [
{
id: "1",
content: createMockDocumentContentParagraph(
"I really appreciate the new version of the dashboard, especially the way the KPIs are surfaced at the top. That definitely helps me quickly align with our daily goals. However, I think there are a few areas where we could improve to make the dashboard even more useful for analysis. For example, the revenue trend chart is great for spotting long-term movements, but it would be even more insightful if we could overlay major campaign dates or product launches so we can see correlations between business initiatives and performance.",
),
created_at: getDateRelativeToNow(60 * 60 * 24 * 2),
creator: {
common_name: "Super long long long name that should wrap",
},
},
{
id: "2",
content: createMockDocumentContentParagraph("Yes, we can add that."),
created_at: getDateRelativeToNow(60),
creator: {
common_name: "Jane Doe",
},
},
],
},
};
export const CommentWithReplies = {
render: (args: DiscussionProps) => <Discussion {...args} />,
args: {
comments: [
{
id: "1",
content: createMockDocumentContentParagraph(
"The revenue dashboard looks good, but I think we're missing context on customer churn. Could we add a chart for that?",
),
created_at: getDateRelativeToNow(60 * 60 * 2),
creator: {
common_name: "Priya Sharma",
},
},
{
id: "2",
content: createMockDocumentContentParagraph(
"Good point. We already track churn in a different dataset — I can try to join it with the revenue data.",
),
created_at: getDateRelativeToNow(60),
creator: {
common_name: "Michael Chen",
},
},
{
id: "3",
content: createMockDocumentContentParagraph(
"Yes please, churn is critical for forecasting. If possible, let's show churn % next to revenue growth.",
),
created_at: getDateRelativeToNow(5),
creator: {
common_name: "Sara Lopez",
},
},
],
},
};

View File

@@ -0,0 +1,146 @@
import { useEffect, useState } from "react";
import { match } from "ts-pattern";
import { getCurrentUser } from "metabase/admin/datamodel/selectors";
import { useSelector } from "metabase/lib/redux";
import { Stack, Timeline, rem } from "metabase/ui";
import {
useCreateCommentMutation,
useDeleteCommentMutation,
useUpdateCommentMutation,
} from "metabase-enterprise/api";
import { getCommentNodeId } from "metabase-enterprise/comments/utils";
import type {
Comment,
CommentEntityType,
DocumentContent,
EntityId,
} from "metabase-types/api";
import { CommentEditor } from "../CommentEditor";
import S from "./Discussion.module.css";
import { DiscussionAvatar } from "./DiscussionAvatar";
import { DiscussionComment } from "./DiscussionComment";
export interface DiscussionProps {
childTargetId: EntityId | null;
comments: Comment[];
targetId: EntityId;
targetType: CommentEntityType;
}
/**
* TODO: implement me
* This component should not fetch any data (except version history) but it should use mutations.
*/
export const Discussion = ({
childTargetId,
comments,
targetId,
targetType,
}: DiscussionProps) => {
const currentUser = useSelector(getCurrentUser);
const [, setNewComment] = useState<DocumentContent>();
const [linkCopied, setLinkCopied] = useState(false);
const parentCommentId = comments[0].id;
const [createComment] = useCreateCommentMutation();
const [updateComment] = useUpdateCommentMutation();
const [deleteComment] = useDeleteCommentMutation();
const handleSubmit = (doc: DocumentContent) => {
createComment({
child_target_id: childTargetId,
target_id: targetId,
target_type: targetType,
content: doc,
parent_comment_id: parentCommentId,
});
};
const handleDeleteComment = (comment: Comment) => {
deleteComment(comment.id);
};
const handleResolveComment = (comment: Comment) => {
updateComment({ id: comment.id, is_resolved: true });
};
const handleReopenComment = (comment: Comment) => {
updateComment({ id: comment.id, is_resolved: false });
};
const handleEditComment = (comment: Comment, newContent: DocumentContent) => {
updateComment({ id: comment.id, content: newContent });
};
const handleCopyLink = (comment: Comment) => {
const url = getUrl({ childTargetId, targetId, targetType, comment });
navigator.clipboard.writeText(`${window.location.origin}${url}`);
setLinkCopied(true);
};
useEffect(() => {
if (linkCopied) {
const timeout = setTimeout(() => setLinkCopied(false), 2000);
return () => clearTimeout(timeout);
}
}, [linkCopied]);
return (
<Stack>
<Timeline bulletSize={rem(24)} lineWidth={1} className={S.discussionRoot}>
{comments.map((comment, index) => (
<DiscussionComment
key={comment.id}
comment={comment}
actionPanelVariant={index === 0 ? "discussion" : "comment"}
onDelete={handleDeleteComment}
onResolve={handleResolveComment}
onReopen={handleReopenComment}
onEdit={handleEditComment}
onCopyLink={handleCopyLink}
/>
))}
{!comments[0]?.is_resolved && (
<Timeline.Item
className={S.commentRoot}
bullet={<DiscussionAvatar user={currentUser} />}
>
<CommentEditor
active={false}
onChange={(document) => setNewComment(document)}
onSubmit={handleSubmit}
/>
</Timeline.Item>
)}
</Timeline>
</Stack>
);
};
function getUrl({
childTargetId,
targetId,
targetType,
comment,
}: {
childTargetId: EntityId | null;
targetId: EntityId;
targetType: CommentEntityType;
comment: Comment | undefined;
}) {
return match(targetType)
.with("document", () => {
const childTargetUrl = `/document/${targetId}/comments/${childTargetId}`;
if (comment) {
return `${childTargetUrl}#${getCommentNodeId(comment)}`;
}
return childTargetUrl;
})
.exhaustive();
}

View File

@@ -0,0 +1,146 @@
import cx from "classnames";
import { useState } from "react";
import { t } from "ttag";
import {
ActionIcon,
Button,
Group,
Icon,
Paper,
Popover,
Tooltip,
rem,
} from "metabase/ui";
import type { Comment } from "metabase-types/api";
import S from "./Discussion.module.css";
export type DiscussionActionPanelProps = {
variant?: "comment" | "discussion";
comment: Comment;
onResolve?: (comment: Comment) => unknown;
onReopen?: (comment: Comment) => unknown;
onReaction?: (comment: Comment, emoji: string) => unknown;
onDelete?: (comment: Comment) => unknown;
onEdit?: (comment: Comment) => unknown;
onCopyLink?: (comment: Comment) => unknown;
};
const ACTION_ICON_SIZE = "md";
const ACTION_BUTTON_STYLE_PROPS = {
w: "100%",
p: "0.25rem",
variant: "inverse",
size: "xs",
radius: "xs",
} as const;
export function DiscussionActionPanel({
variant = "comment",
comment,
onResolve,
onReopen,
onDelete,
onEdit,
onCopyLink,
}: DiscussionActionPanelProps) {
const [popoverOpened, setPopoverOpened] = useState(false);
const hasMoreActions = Boolean(onReopen || onDelete || onEdit || onCopyLink);
return (
<Paper
className={cx(S.actionPanel, {
// [S.visibleOnCommentHover]: variant === "comment",
// [S.visibleOnDiscussionHover]: variant === "discussion",
[S.visibleOnCommentHover]: true,
[S.visible]: popoverOpened,
})}
p="0.125rem"
>
<Group gap="0">
{/*<Tooltip label={t`Add reaction`}>
TODO: add emoji picker
<ActionIcon
size={ACTION_ICON_SIZE}
onClick={() => onReaction?.(comment, "👍")}
>
TODO: add reaction icon
<Icon name="bolt" />
</ActionIcon>
</Tooltip> */}
{variant === "discussion" && (
<Tooltip label={comment.is_resolved ? t`Re-open` : t`Resolve`}>
<ActionIcon
size={ACTION_ICON_SIZE}
onClick={() =>
comment.is_resolved ? onReopen?.(comment) : onResolve?.(comment)
}
>
<Icon name={comment.is_resolved ? "undo" : "check"} />
</ActionIcon>
</Tooltip>
)}
{hasMoreActions && (
<Popover
opened={popoverOpened}
onChange={setPopoverOpened}
width={rem(140)}
position="bottom-end"
>
<Popover.Target>
<Tooltip label={t`More actions`} disabled={popoverOpened}>
<ActionIcon
size={ACTION_ICON_SIZE}
onClick={() => setPopoverOpened((opened) => !opened)}
>
<Icon name="ellipsis" />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Paper p="0.25rem">
{onCopyLink && (
<Button
{...ACTION_BUTTON_STYLE_PROPS}
leftSection={<Icon name="link" c="text-primary" />}
onClick={() => {
onCopyLink?.(comment);
setPopoverOpened(false);
}}
>
{t`Copy link`}
</Button>
)}
{onEdit && (
<Button
{...ACTION_BUTTON_STYLE_PROPS}
leftSection={<Icon name="pencil" c="text-primary" />}
onClick={() => {
onEdit?.(comment);
setPopoverOpened(false);
}}
>
{t`Edit`}
</Button>
)}
{onDelete && (
<Button
{...ACTION_BUTTON_STYLE_PROPS}
leftSection={<Icon name="trash" c="text-primary" />}
onClick={() => {
onDelete?.(comment);
setPopoverOpened(false);
}}
>
{t`Delete`}
</Button>
)}
</Paper>
</Popover.Dropdown>
</Popover>
)}
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,32 @@
// TODO: move Avatar component to metabase/ui
/* eslint-disable no-restricted-imports */
import { Avatar, rem } from "@mantine/core";
import type { BaseUser } from "metabase-types/api";
type DiscussionAvatarProps = {
user: BaseUser;
};
const avatarColors = [
"var(--mb-base-color-lobster-40)",
"var(--mb-base-color-flamingo-40)",
"var(--mb-base-color-mango-40)",
"var(--mb-base-color-orion-40)",
"var(--mb-base-color-dubloon-40)",
"var(--mb-base-color-palm-40)",
"var(--mb-base-color-seafoam-40)",
"var(--mb-base-color-octopus-40)",
];
export function DiscussionAvatar({ user }: DiscussionAvatarProps) {
return (
<Avatar
variant="filled"
size={rem(24)}
name={user.common_name}
color="initials"
allowedInitialsColors={avatarColors}
/>
);
}

View File

@@ -0,0 +1,144 @@
import { useDisclosure } from "@mantine/hooks";
import cx from "classnames";
import dayjs from "dayjs";
import { useCallback } from "react";
import { useLocation } from "react-use";
import { t } from "ttag";
import { Box, Group, Icon, Text, Timeline, Tooltip } from "metabase/ui";
import { getCommentNodeId } from "metabase-enterprise/comments/utils";
import type { Comment, DocumentContent } from "metabase-types/api";
import { CommentEditor } from "../CommentEditor";
import S from "./Discussion.module.css";
import {
DiscussionActionPanel,
type DiscussionActionPanelProps,
} from "./DiscussionActionPanel";
import { DiscussionAvatar } from "./DiscussionAvatar";
type DiscussionCommentProps = {
comment: Comment;
actionPanelVariant?: DiscussionActionPanelProps["variant"];
onResolve?: (comment: Comment) => unknown;
onReopen?: (comment: Comment) => unknown;
onReaction?: (comment: Comment, emoji: string) => unknown;
onDelete?: (comment: Comment) => unknown;
onEdit?: (comment: Comment, newContent: DocumentContent) => unknown;
onCopyLink?: (comment: Comment) => unknown;
};
export function DiscussionComment({
comment,
actionPanelVariant,
onResolve,
onReopen,
onReaction,
onDelete,
onEdit,
onCopyLink,
}: DiscussionCommentProps) {
const [isEditing, editingHandler] = useDisclosure(false);
const location = useLocation();
const hash = location.hash?.substring(1);
const isTarget = hash === getCommentNodeId(comment);
const handleEditClick = useCallback(() => {
editingHandler.open();
}, [editingHandler]);
const handleEditingSubmit = (document: DocumentContent) => {
onEdit?.(comment, document);
editingHandler.close();
};
if (comment.is_deleted) {
return (
<Timeline.Item
classNames={{
item: cx(S.commentRoot, {
[S.target]: isTarget,
}),
itemBullet: S.commentBulletDeleted,
}}
id={getCommentNodeId(comment)}
mt="1.25rem"
bullet={<Icon name="trash" />}
>
<Text size="md" c="text-disabled" fs="italic">
{t`This comment was deleted.`}
</Text>
<DiscussionActionPanel
variant={actionPanelVariant}
comment={comment}
onResolve={onResolve}
onCopyLink={onCopyLink}
/>
</Timeline.Item>
);
}
return (
<Timeline.Item
className={cx(S.commentRoot, {
[S.target]: isTarget,
})}
bullet={<DiscussionAvatar user={comment.creator} />}
id={getCommentNodeId(comment)}
>
{!isEditing && (
<DiscussionActionPanel
variant={actionPanelVariant}
comment={comment}
onResolve={onResolve}
onReopen={onReopen}
onReaction={onReaction}
onDelete={onDelete}
onEdit={handleEditClick}
onCopyLink={onCopyLink}
/>
)}
<Group gap="sm" align="center" wrap="nowrap">
<Text fw={700} lh={1.3} truncate>
{comment.creator.common_name}
</Text>
<Tooltip
label={dayjs(comment.created_at).format("MMM D, YYYY, h:mm A")}
>
<Text
size="xs"
c="text-medium"
lh={1.1}
style={{ whiteSpace: "nowrap" }}
>
{formatCommentDate(comment.created_at)}
</Text>
</Tooltip>
</Group>
<Box mt={isEditing ? "sm" : 0}>
<CommentEditor
initialContent={comment.content}
onSubmit={handleEditingSubmit}
readonly={!isEditing}
/>
</Box>
</Timeline.Item>
);
}
function formatCommentDate(dateStr: string) {
const date = new Date(dateStr);
const now = new Date();
const oneDay = 24 * 60 * 60 * 1000;
const is24hAgo = now.getTime() - date.getTime() < oneDay;
if (is24hAgo) {
return dayjs(date).fromNow();
}
return dayjs(date).format("MMM D");
}

View File

@@ -0,0 +1 @@
export * from "./Discussion";

View File

@@ -0,0 +1,73 @@
import { Fragment, useMemo, useState } from "react";
import { t } from "ttag";
import { Box, Divider, Stack } from "metabase/ui";
import { useCreateCommentMutation } from "metabase-enterprise/api";
import { getCommentThreads } from "metabase-enterprise/comments/utils";
import type { Comment, DocumentContent } from "metabase-types/api";
import { CommentEditor } from "../CommentEditor";
import { Discussion } from "../Discussion";
export interface DiscussionProps {
childTargetId: Comment["child_target_id"];
comments: Comment[];
targetId: Comment["target_id"];
targetType: Comment["target_type"];
autoOpenNewComment?: boolean;
allowNewThreads?: boolean;
}
export const Discussions = ({
childTargetId,
comments,
targetId,
targetType,
autoOpenNewComment = false,
allowNewThreads = true,
}: DiscussionProps) => {
const [, setNewComment] = useState<DocumentContent>();
const [createComment] = useCreateCommentMutation();
const threads = useMemo(() => getCommentThreads(comments), [comments]);
const handleSubmit = (doc: DocumentContent) => {
createComment({
child_target_id: childTargetId,
target_id: targetId,
target_type: targetType,
content: doc,
parent_comment_id: null,
});
};
return (
<Stack gap="0">
{threads.map((thread) => (
<Fragment key={thread.id}>
<Box px="xl" mb="md">
<Discussion
childTargetId={childTargetId}
comments={thread.comments}
targetId={targetId}
targetType={targetType}
/>
</Box>
<Divider />
</Fragment>
))}
{allowNewThreads && (
<Box p="xl">
<CommentEditor
autoFocus={autoOpenNewComment}
placeholder={t`Add a comment…`}
onChange={(document) => setNewComment(document)}
onSubmit={handleSubmit}
/>
</Box>
)}
</Stack>
);
};

View File

@@ -0,0 +1 @@
export * from "./Discussions";

View File

@@ -0,0 +1,2 @@
export * from "./CommentEditor";
export * from "./Discussion";

View File

@@ -0,0 +1,148 @@
import { mergeAttributes } from "@tiptap/core";
import Mention, {
type MentionNodeAttrs,
type MentionOptions,
} from "@tiptap/extension-mention";
import cx from "classnames";
import S from "./mentions.module.css";
export interface CustomMentionAttributes extends MentionNodeAttrs {
id: string | null;
label: string;
model: "user" | "card" | "dashboard" | "dataset" | "metric";
mentionSuggestionChar?: string;
}
// Storage state for the Mention extension (accessed via editor.storage.mention)
export interface MentionStorage {
isMentionPopupOpen: boolean;
}
export const CustomMentionExtension = Mention.extend<
MentionOptions,
MentionStorage
>({
name: "mention",
addAttributes() {
return {
id: {
default: null,
parseHTML: (element: HTMLElement) =>
element.getAttribute("data-mention-id"),
renderHTML: (attributes: CustomMentionAttributes) => ({
"data-mention-id": attributes.id,
}),
},
label: {
default: null,
parseHTML: (element: HTMLElement) =>
element.getAttribute("data-mention-label"),
renderHTML: (attributes: CustomMentionAttributes) => ({
"data-mention-label": attributes.label,
}),
},
mentionSuggestionChar: {
default: "@",
parseHTML: (element: HTMLElement) =>
element.getAttribute("data-mention-char") || "@",
renderHTML: (attributes: CustomMentionAttributes) => ({
"data-mention-char": attributes.mentionSuggestionChar,
}),
},
model: {
default: "user",
renderHTML: (attributes: CustomMentionAttributes) => ({
"data-mention-model": attributes.model,
}),
parseHTML: (element: HTMLElement) =>
element.getAttribute("data-mention-model") || "user",
},
};
},
addStorage() {
return {
isMentionPopupOpen: false,
};
},
renderHTML({
node,
HTMLAttributes,
}: {
node: any;
HTMLAttributes: Record<string, any>;
}) {
const { id, model, label } = node.attrs as CustomMentionAttributes;
const baseAttrs = {
class: cx(S.mention, model !== "user" && S.mentionLink),
"data-mention-id": id,
"data-mention-model": model,
// Prevent navigation on click in editor
contenteditable: "false",
};
if (model === "user") {
return ["span", mergeAttributes(baseAttrs, HTMLAttributes), `@${label}`];
}
let href = "#";
switch (model) {
case "card":
href = `/question/${id}`;
break;
case "dashboard":
href = `/dashboard/${id}`;
break;
case "dataset":
href = `/model/${id}`;
break;
case "metric":
href = `/metric/${id}`;
break;
}
return [
"a",
mergeAttributes(
{
href,
...baseAttrs,
},
HTMLAttributes,
),
label,
];
},
renderText({ node }: { node: any }) {
const { label, id } = node.attrs as CustomMentionAttributes;
return `@${label ?? id}`;
},
parseHTML() {
return [
{
tag: "a[data-mention-id]",
getAttrs: (element: string | HTMLElement) => {
if (typeof element === "string") {
return null;
}
const id = element.getAttribute("data-mention-id");
const model = element.getAttribute("data-mention-model") || "user";
const label = element.textContent?.replace(/^@/, "") || "";
return {
id,
model,
label,
};
},
},
];
},
});

View File

@@ -0,0 +1,2 @@
export { CustomMentionExtension } from "./CustomMentionExtension";
export type { CustomMentionAttributes } from "./CustomMentionExtension";

View File

@@ -0,0 +1,24 @@
.mention {
background-color: var(--mb-color-brand-lighter);
border-radius: var(--mantine-radius-sm);
color: var(--mb-color-brand);
display: inline-block;
padding: 0 0.25rem;
text-decoration: none;
position: relative;
}
.mentionLink:hover {
text-decoration: underline;
}
/* In editor context, prevent link navigation */
.ProseMirror .mention {
pointer-events: none;
user-select: none;
}
/* Outside editor, allow interaction */
.mention-display .mention {
pointer-events: auto;
}

View File

@@ -0,0 +1,109 @@
/* Mentions dropdown */
.mentionList {
background: var(--mb-color-background);
border: 1px solid var(--mb-color-border);
border-radius: var(--mantine-radius-md);
box-shadow: var(--mantine-shadow-md);
padding: 0.25rem;
font-size: var(--mantine-font-size-sm);
max-height: 380px;
overflow-y: auto;
}
.mentionItem {
display: block;
width: 100%;
text-align: left;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
color: var(--mb-color-text-dark);
cursor: pointer;
border-radius: var(--mantine-radius-sm);
font-size: var(--mantine-font-size-sm);
transition: background-color 100ms ease;
}
.mentionItem:hover,
.mentionItemSelected {
background: var(--mb-color-brand-lighter);
color: var(--mb-color-brand);
}
.mentionEmpty {
padding: 0.5rem 0.75rem;
font-size: var(--mantine-font-size-sm);
color: var(--mb-color-text-medium);
}
/* Section headers */
.mentionSectionHeader {
padding: 0.25rem 0.75rem;
font-size: var(--mantine-font-size-xs);
font-weight: 600;
color: var(--mb-color-text-medium);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 0.25rem;
}
.mentionSectionHeader:first-child {
margin-top: 0;
}
/* Item content structure */
.mentionItemContent {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.mentionItemMain {
display: flex;
align-items: center;
gap: 0.5rem;
}
.mentionItemIcon {
color: var(--mb-color-text-medium);
flex-shrink: 0;
}
.mentionItemLabel {
font-weight: 500;
}
.mentionItemMeta {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: var(--mantine-font-size-xs);
color: var(--mb-color-text-light);
}
.mentionItemType {
color: var(--mb-color-text-medium);
}
.mentionItemSeparator {
color: var(--mb-color-text-light);
}
.mentionItemCollection {
color: var(--mb-color-text-light);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Adjust selected state for metadata */
.mentionItemSelected .mentionItemMeta,
.mentionItem:hover .mentionItemMeta {
color: var(--mb-color-brand);
opacity: 0.8;
}
.mentionItemSelected .mentionItemIcon,
.mentionItem:hover .mentionItemIcon {
color: var(--mb-color-brand);
}

View File

@@ -0,0 +1,177 @@
import cx from "classnames";
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from "react";
import { t } from "ttag";
import { getIcon } from "metabase/lib/icon";
import { Icon } from "metabase/ui";
import S from "./MentionList.module.css";
export interface MentionItem {
id: string;
entityId: string | number;
label: string;
type: "user" | "card" | "dashboard" | "dataset" | "metric";
collection?: string;
}
export interface MentionListProps {
items: MentionItem[];
command: (props: MentionItem) => void;
}
export interface MentionListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
const getItemTypeLabel = (type: MentionItem["type"]): string => {
switch (type) {
case "card":
return t`Question`;
case "dashboard":
return t`Dashboard`;
case "dataset":
return t`Model`;
case "metric":
return t`Metric`;
default:
return "";
}
};
const getItemIcon = (item: MentionItem) => {
if (item.type === "user") {
return "person";
}
return getIcon({ model: item.type }).name;
};
export const MentionList = forwardRef<MentionListRef, MentionListProps>(
function MentionList({ items: _items, command }, ref) {
const [selectedIndex, setSelectedIndex] = useState(0);
const { userItems, contentItems, items } = useMemo(() => {
const userItems = _items.filter((item) => item.type === "user");
const contentItems = _items.filter((item) => item.type !== "user");
const items = [...userItems, ...contentItems];
return { userItems, contentItems, items };
}, [_items]);
const handleSelectItem = (index: number) => {
const item = items[index];
if (item) {
command(item);
}
};
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === "ArrowUp") {
setSelectedIndex((prevIndex) =>
prevIndex > 0 ? prevIndex - 1 : items.length - 1,
);
return true;
}
if (event.key === "ArrowDown") {
setSelectedIndex((prevIndex) =>
prevIndex < items.length - 1 ? prevIndex + 1 : 0,
);
return true;
}
if (event.key === "Enter") {
event.stopPropagation();
handleSelectItem(selectedIndex);
return true;
}
return false;
},
}));
useEffect(() => {
setSelectedIndex(0);
}, [items]);
if (!items.length) {
return <div className={S.mentionEmpty}>{t`No results found`}</div>;
}
let currentIndex = 0;
return (
<div className={S.mentionList}>
{userItems.length > 0 && (
<>
<div className={S.mentionSectionHeader}>{t`Users`}</div>
{userItems.map((item) => {
const index = currentIndex++;
return (
<button
key={item.id}
onClick={() => handleSelectItem(index)}
className={cx(S.mentionItem, {
[S.mentionItemSelected]: index === selectedIndex,
})}
onMouseEnter={() => setSelectedIndex(index)}
>
<span className={S.mentionItemLabel}>{item.label}</span>
</button>
);
})}
</>
)}
{contentItems.length > 0 && (
<>
<div className={S.mentionSectionHeader}>{t`Content`}</div>
{contentItems.map((item) => {
const index = currentIndex++;
return (
<button
key={item.id}
onClick={() => handleSelectItem(index)}
className={cx(S.mentionItem, {
[S.mentionItemSelected]: index === selectedIndex,
})}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className={S.mentionItemContent}>
<div className={S.mentionItemMain}>
<Icon
name={getItemIcon(item)}
size={14}
className={S.mentionItemIcon}
/>
<span className={S.mentionItemLabel}>{item.label}</span>
</div>
<div className={S.mentionItemMeta}>
<span className={S.mentionItemType}>
{getItemTypeLabel(item.type)}
</span>
{item.collection && (
<>
<span className={S.mentionItemSeparator}>·</span>
<span className={S.mentionItemCollection}>
{item.collection}
</span>
</>
)}
</div>
</div>
</button>
);
})}
</>
)}
</div>
);
},
);

View File

@@ -0,0 +1,247 @@
import {
type VirtualElement,
autoUpdate,
computePosition,
flip,
shift,
size,
} from "@floating-ui/dom";
import { ReactRenderer } from "@tiptap/react";
import type {
SuggestionKeyDownProps,
SuggestionProps,
} from "@tiptap/suggestion";
import { searchApi, userApi } from "metabase/api";
import type { DispatchFn } from "metabase/lib/redux";
import { CustomMentionExtension } from "./CustomMentionExtension";
import {
MentionList,
type MentionListProps,
type MentionListRef,
} from "./MentionList";
type ExtensionProps = {
dispatch: DispatchFn;
};
export const configureMentionExtension = ({ dispatch }: ExtensionProps) =>
CustomMentionExtension.configure({
suggestion: {
char: "@",
allowSpaces: false,
allow: ({ state, range }) => {
const textAfter = state.doc.textBetween(range.from, range.to);
// allows adding @ symbol without showing mention list
return !textAfter.includes(" ");
},
items: async ({ query }) => {
const [userResult, searchResult] = await Promise.all([
dispatch(userApi.endpoints.listUsers.initiate({ query })),
dispatch(
searchApi.endpoints.search.initiate({
q: query,
filter_items_in_personal_collection: "exclude",
models: ["card", "dashboard", "dataset", "metric"],
limit: 10,
}),
),
]);
const items = [];
if (userResult.data) {
const users = userResult.data.data
.map((user) => ({
id: `user:${user.id}`,
entityId: user.id,
label: user.common_name,
type: "user" as const,
}))
.slice(0, 5);
items.push(...users);
}
if (searchResult.data) {
const searchItems = searchResult.data.data
.slice(0, 5)
.map((item) => ({
id: `${item.model}:${item.id}`,
entityId: item.id,
label: item.name,
type: item.model,
collection: item.collection?.name,
}));
items.push(...searchItems);
}
return items;
},
render: renderMentionList,
command: ({ editor, range, props }) => {
const item = props as any;
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "mention",
attrs: {
id: String(item.entityId),
label: item.label,
model: item.type,
},
})
.insertContent(" ")
.run();
},
},
});
const renderMentionList = () => {
let component: ReactRenderer<MentionListRef, MentionListProps>;
let popup: HTMLElement;
let cleanup: (() => void) | undefined;
return {
onStart: (props: SuggestionProps) => {
component = new ReactRenderer(MentionList, {
props,
editor: props.editor,
});
// Mark mention popup as open in extension storage
try {
const storage = (props.editor.storage as any).mention;
if (storage) {
storage.isMentionPopupOpen = true;
}
} catch {}
popup = document.createElement("div");
popup.style.position = "absolute";
popup.style.zIndex = "9999";
document.body.appendChild(popup);
if (component.element) {
popup.appendChild(component.element);
}
const virtualElement: VirtualElement = {
getBoundingClientRect: () => {
const { view, state } = props.editor;
const { from } = state.selection;
const coordinates = view.coordsAtPos(from);
return {
x: coordinates.left,
y: coordinates.top,
width: 0,
height: coordinates.bottom - coordinates.top,
top: coordinates.top,
right: coordinates.left,
bottom: coordinates.bottom,
left: coordinates.left,
};
},
};
cleanup = autoUpdate(virtualElement, popup, () => {
computePosition(virtualElement, popup, {
placement: "bottom-start",
middleware: [
flip(),
shift({ padding: 5 }),
size({
apply({ availableHeight }) {
Object.assign(popup.style, {
maxHeight: `${Math.min(400, availableHeight)}px`,
minWidth: "320px",
});
},
}),
],
}).then(({ x, y }) => {
Object.assign(popup.style, {
left: `${x}px`,
top: `${y}px`,
});
});
});
},
onUpdate: (props: SuggestionProps) => {
component.updateProps(props);
const virtualElement: VirtualElement = {
getBoundingClientRect: () => {
const { view, state } = props.editor;
const { from } = state.selection;
const coordinates = view.coordsAtPos(from);
return {
x: coordinates.left,
y: coordinates.top,
width: 0,
height: coordinates.bottom - coordinates.top,
top: coordinates.top,
right: coordinates.left,
bottom: coordinates.bottom,
left: coordinates.left,
};
},
};
computePosition(virtualElement, popup, {
placement: "bottom-start",
middleware: [
flip(),
shift({ padding: 5 }),
size({
apply({ availableHeight }) {
Object.assign(popup.style, {
maxHeight: `${Math.min(400, availableHeight)}px`,
minWidth: "320px",
});
},
}),
],
}).then(({ x, y }) => {
Object.assign(popup.style, {
left: `${x}px`,
top: `${y}px`,
});
});
},
onKeyDown: (props: SuggestionKeyDownProps) => {
if (props.event.key === "Escape") {
props.event.stopPropagation();
return true;
}
return component.ref?.onKeyDown(props) || false;
},
onExit: (props: SuggestionProps) => {
// Mark mention popup as closed in extension storage
try {
const storage = (props.editor.storage as any).mention;
if (storage) {
storage.isMentionPopupOpen = false;
}
} catch {}
if (cleanup) {
cleanup();
}
if (popup && popup.parentNode) {
popup.parentNode.removeChild(popup);
}
component.destroy();
},
};
};

View File

@@ -0,0 +1,6 @@
import type { Comment, CommentId } from "metabase-types/api";
export interface CommentThread {
id: CommentId;
comments: Comment[];
}

View File

@@ -0,0 +1,44 @@
import type { Comment } from "metabase-types/api";
import type { CommentThread } from "./types";
export function getCommentThreads(
comments: Comment[] | undefined,
): CommentThread[] {
if (!comments) {
return [];
}
const threadStartingComments = comments.filter(
(comment) => comment.parent_comment_id == null,
);
return threadStartingComments.map((parent) => ({
id: parent.id,
comments: [
parent,
...comments.filter((comment) => {
return comment.parent_comment_id === parent.id;
}),
],
}));
}
export function getTargetChildCommentThreads(
comments: Comment[] | undefined,
childTargetId: Comment["child_target_id"] | undefined,
): CommentThread[] {
if (!comments || !childTargetId) {
return [];
}
const targetComments = comments.filter(
(comment) => comment.child_target_id === childTargetId,
);
return getCommentThreads(targetComments);
}
export function getCommentNodeId(comment: Comment) {
return `comment-${comment.id}`;
}

View File

@@ -0,0 +1,29 @@
.body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
overflow: auto;
}
.content {
&:not([data-css-specificity-hack="🤷‍♀️"]) {
position: fixed;
height: 100dvh;
max-height: 100dvh;
inset-inline-end: 0;
border-radius: 0;
display: flex;
flex: 1;
flex-direction: column;
box-shadow:
-2px 0 4px -2px rgba(16, 24, 40, 0.06),
-4px 0 8px -2px rgba(16, 24, 40, 0.1);
}
}
.tabsList {
position: sticky;
top: 0;
z-index: 2;
background-color: var(--mb-color-bg-white);
}

View File

@@ -0,0 +1,182 @@
import cx from "classnames";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLatest, useLocation } from "react-use";
import { t } from "ttag";
import Animation from "metabase/css/core/animation.module.css";
import { useSelector } from "metabase/lib/redux";
import { Modal, Tabs, rem } from "metabase/ui";
import { useListCommentsQuery } from "metabase-enterprise/api";
import { Discussions } from "metabase-enterprise/comments/components/Discussions";
import { getCommentNodeId } from "metabase-enterprise/comments/utils";
import { useDocumentState } from "metabase-enterprise/documents/hooks/use-document-state";
import { getCurrentDocument } from "metabase-enterprise/documents/selectors";
import { getListCommentsQuery } from "metabase-enterprise/documents/utils/api";
import S from "./CommentsSidesheet.module.css";
type SidesheetTab = "open" | "resolved";
interface Props {
params?: {
childTargetId: string;
};
onClose: () => void;
}
export const CommentsSidesheet = ({ params, onClose }: Props) => {
const childTargetId = params?.childTargetId;
const location = useLocation();
const { openCommentSidebar, closeCommentSidebar } = useDocumentState();
const [activeTab, setActiveTab] = useState<SidesheetTab | null>("open");
const document = useSelector(getCurrentDocument);
const { data: commentsData } = useListCommentsQuery(
getListCommentsQuery(document),
);
const comments = commentsData?.comments;
// Check if we should auto-open the new comment form
const shouldAutoOpenNewComment = useMemo(() => {
const searchParams = new URLSearchParams(location.search);
return searchParams.get("new") === "true";
}, [location.search]);
const targetComments = useMemo(() => {
if (!comments) {
return [];
}
return comments.filter(
(comment) => comment.child_target_id === childTargetId,
);
}, [comments, childTargetId]);
const closeSidebar = useCallback(() => {
closeCommentSidebar();
onClose();
}, [closeCommentSidebar, onClose]);
useEffect(() => {
if (childTargetId == null) {
closeSidebar();
return;
}
openCommentSidebar();
}, [childTargetId, closeSidebar, openCommentSidebar]);
const resolvedComments = useMemo(
() => targetComments?.filter((comment) => comment.is_resolved) ?? [],
[targetComments],
);
const activeComments = useMemo(
() => targetComments?.filter((comment) => !comment.is_resolved) ?? [],
[targetComments],
);
const availableTabs = useMemo<SidesheetTab[]>(() => {
// Only show tabs if there are resolved comments
if (resolvedComments.length > 0) {
return ["open", "resolved"];
}
return [];
}, [resolvedComments.length]);
// Update active tab if current tab is not available
useEffect(() => {
if (
availableTabs.length > 0 &&
activeTab &&
!availableTabs.includes(activeTab)
) {
setActiveTab(availableTabs[0]);
}
}, [availableTabs, activeTab]);
const hash = location.hash?.substring(1);
const isHashCommentResolved = resolvedComments.some((comment) => {
return getCommentNodeId(comment) === hash;
});
const isHashCommentUnresolved = activeComments.some((comment) => {
return getCommentNodeId(comment) === hash;
});
const activeTabRef = useLatest(activeTab);
useEffect(() => {
const activeTab = activeTabRef.current;
if (activeTab === "open" && isHashCommentResolved) {
setActiveTab("resolved");
} else if (activeTab === "resolved" && isHashCommentUnresolved) {
setActiveTab("open");
}
}, [hash, activeTabRef, isHashCommentResolved, isHashCommentUnresolved]);
if (!childTargetId || !document) {
return null;
}
return (
<Modal.Root
h="100dvh"
lockScroll={false}
opened
variant="sidesheet"
onClose={closeSidebar}
>
<Modal.Content
classNames={{
content: cx(S.content, Animation.slideLeft),
}}
data-testid="sidesheet"
px="none"
transitionProps={{ duration: 0 }}
w={rem(400)}
>
<Modal.Header px="xl">
<Modal.Title>{t`Comments`}</Modal.Title>
<Modal.CloseButton onClick={closeSidebar} />
</Modal.Header>
<Modal.Body className={S.body} p={0}>
<Tabs
value={activeTab}
onChange={(value) => {
setActiveTab(value as SidesheetTab);
}}
>
{availableTabs.length > 0 && (
<Tabs.List px="1.625rem" className={S.tabsList}>
<Tabs.Tab value="open">{t`Open`}</Tabs.Tab>
<Tabs.Tab value="resolved">
{t`Resolved (${resolvedComments.length})`}
</Tabs.Tab>
</Tabs.List>
)}
<Tabs.Panel value="open">
<Discussions
childTargetId={childTargetId}
comments={activeComments}
targetId={document.id}
targetType="document"
autoOpenNewComment={shouldAutoOpenNewComment}
/>
</Tabs.Panel>
<Tabs.Panel value="resolved">
<Discussions
childTargetId={childTargetId}
comments={resolvedComments}
targetId={document.id}
targetType="document"
allowNewThreads={false}
/>
</Tabs.Panel>
</Tabs>
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
};

View File

@@ -0,0 +1 @@
export * from "./CommentsSidesheet";

View File

@@ -3,7 +3,7 @@ import type { JSONContent, Editor as TiptapEditor } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import dayjs from "dayjs";
import type { Location } from "history";
import { useCallback, useEffect, useState } from "react";
import { type ReactNode, useCallback, useEffect, useState } from "react";
import type { Route } from "react-router";
import { push, replace } from "react-router-redux";
import { usePrevious, useUnmount } from "react-use";
@@ -46,12 +46,16 @@ import {
clearDraftCards,
openVizSettingsSidebar,
resetDocuments,
setChildTargetId,
setCurrentDocument,
setHasUnsavedChanges,
} from "../documents.slice";
import { useDocumentState } from "../hooks/use-document-state";
import { useRegisterDocumentMetabotContext } from "../hooks/use-register-document-metabot-context";
import {
getCommentSidebarOpen,
getDraftCards,
getHasUnsavedChanges,
getSelectedEmbedIndex,
getSelectedQuestionId,
} from "../selectors";
@@ -63,24 +67,31 @@ import { Editor } from "./Editor";
import { EmbedQuestionSettingsSidebar } from "./EmbedQuestionSettingsSidebar";
export const DocumentPage = ({
params: { entityId },
params,
route,
location,
children,
}: {
params: { entityId?: string };
params: {
entityId?: string;
childTargetId?: string;
};
location: Location;
route: Route;
children?: ReactNode;
}) => {
const { entityId, childTargetId: paramsChildTargetId } = params;
const previousLocationKey = usePrevious(location.key);
const forceUpdate = useForceUpdate();
const dispatch = useDispatch();
const selectedQuestionId = useSelector(getSelectedQuestionId);
const selectedEmbedIndex = useSelector(getSelectedEmbedIndex);
const commentSidebarOpen = useSelector(getCommentSidebarOpen);
const draftCards = useSelector(getDraftCards);
const [editorInstance, setEditorInstance] = useState<TiptapEditor | null>(
null,
);
const [hasUnsavedEditorChanges, setHasUnsavedEditorChanges] = useState(false);
const hasUnsavedEditorChanges = useSelector(getHasUnsavedChanges);
const [createDocument, { isLoading: isCreating }] =
useCreateDocumentMutation();
const [updateDocument, { isLoading: isUpdating }] =
@@ -118,7 +129,8 @@ export const DocumentPage = ({
documentData = undefined;
}
const canWrite = isNewDocument ? true : documentData?.can_write;
const canWrite =
(isNewDocument || documentData?.can_write) && !commentSidebarOpen;
useEffect(() => {
if (error) {
@@ -148,7 +160,7 @@ export const DocumentPage = ({
// Reset state when document changes
useEffect(() => {
if (documentId !== previousDocumentId) {
setHasUnsavedEditorChanges(false);
dispatch(setHasUnsavedChanges(false));
if (isNewDocument && previousDocumentId !== "new") {
setDocumentTitle("");
setDocumentContent(null);
@@ -168,7 +180,7 @@ export const DocumentPage = ({
const resetDocument = useCallback(() => {
setDocumentTitle("");
setDocumentContent(null);
setHasUnsavedEditorChanges(false);
dispatch(setHasUnsavedChanges(false));
editorInstance?.commands.clearContent();
editorInstance?.commands.focus();
dispatch(resetDocuments());
@@ -177,9 +189,9 @@ export const DocumentPage = ({
// Reset dirty state when document content loads from API
useEffect(() => {
if (documentContent && !isNewDocument) {
setHasUnsavedEditorChanges(false);
dispatch(setHasUnsavedChanges(false));
}
}, [documentContent, isNewDocument]);
}, [dispatch, documentContent, isNewDocument]);
useEffect(() => {
// Set current document when document loads (includes collection_id and all other data)
@@ -190,6 +202,10 @@ export const DocumentPage = ({
}
}, [documentData, documentId, dispatch, isNewDocument]);
useEffect(() => {
dispatch(setChildTargetId(paramsChildTargetId));
}, [dispatch, paramsChildTargetId]);
const hasUnsavedChanges = useCallback(() => {
const currentTitle = documentTitle.trim();
const originalTitle = documentData?.name || "";
@@ -223,7 +239,9 @@ export const DocumentPage = ({
// For new documents, any content means changes
if (isNewDocument) {
// when navigating to `/new`, handleChange is fired but the editor instance hasn't been set yet
setHasUnsavedEditorChanges(!!editorInstance && !editorInstance.isEmpty);
dispatch(
setHasUnsavedChanges(!!editorInstance && !editorInstance.isEmpty),
);
return;
}
@@ -233,9 +251,9 @@ export const DocumentPage = ({
// For existing documents, compare with original content
const hasChanges = !_.isEqual(currentContent, originalContent);
setHasUnsavedEditorChanges(hasChanges);
dispatch(setHasUnsavedChanges(hasChanges));
},
[editorInstance, documentContent, isNewDocument],
[dispatch, editorInstance, documentContent, isNewDocument],
);
const handleToggleBookmark = useCallback(() => {
@@ -314,7 +332,7 @@ export const DocumentPage = ({
});
dispatch(clearDraftCards());
// Mark document as clean
setHasUnsavedEditorChanges(false);
dispatch(setHasUnsavedChanges(false));
} else if (result.error) {
throw result.error;
}
@@ -390,90 +408,98 @@ export const DocumentPage = ({
);
return (
<Box className={styles.documentPage}>
<SetTitle title={documentData?.name || t`New document`} />
{documentData?.archived && <DocumentArchivedEntityBanner />}
<Box className={styles.contentArea}>
<Box className={styles.mainContent}>
<Box className={styles.documentContainer}>
<DocumentHeader
document={documentData}
documentTitle={documentTitle}
isNewDocument={isNewDocument}
canWrite={canWrite ?? false}
showSaveButton={showSaveButton ?? false}
isBookmarked={isBookmarked}
onTitleChange={setDocumentTitle}
onSave={() => {
isNewDocument ? setCollectionPickerMode("save") : handleSave();
}}
onMove={() => setCollectionPickerMode("move")}
onToggleBookmark={handleToggleBookmark}
onArchive={() => handleUpdate({ archived: true })}
/>
<Editor
onEditorReady={setEditorInstance}
onCardEmbedsChange={updateCardEmbeds}
onQuestionSelect={handleQuestionSelect}
initialContent={documentContent}
onChange={handleChange}
editable={canWrite}
isLoading={isDocumentLoading}
/>
</Box>
</Box>
{selectedQuestionId &&
selectedEmbedIndex !== null &&
editorInstance && (
<Box className={styles.sidebar} data-testid="document-card-sidebar">
<EmbedQuestionSettingsSidebar
cardId={selectedQuestionId}
editorInstance={editorInstance}
<>
<Box className={styles.documentPage}>
<SetTitle title={documentData?.name || t`New document`} />
{documentData?.archived && <DocumentArchivedEntityBanner />}
<Box className={styles.contentArea}>
<Box className={styles.mainContent}>
<Box className={styles.documentContainer}>
<DocumentHeader
document={documentData}
documentTitle={documentTitle}
isNewDocument={isNewDocument}
canWrite={canWrite ?? false}
showSaveButton={showSaveButton ?? false}
isBookmarked={isBookmarked}
onTitleChange={setDocumentTitle}
onSave={() => {
isNewDocument
? setCollectionPickerMode("save")
: handleSave();
}}
onMove={() => setCollectionPickerMode("move")}
onToggleBookmark={handleToggleBookmark}
onArchive={() => handleUpdate({ archived: true })}
/>
<Editor
onEditorReady={setEditorInstance}
onCardEmbedsChange={updateCardEmbeds}
onQuestionSelect={handleQuestionSelect}
initialContent={documentContent}
onChange={handleChange}
editable={canWrite}
isLoading={isDocumentLoading}
/>
</Box>
</Box>
{selectedQuestionId &&
selectedEmbedIndex !== null &&
editorInstance && (
<Box
className={styles.sidebar}
data-testid="document-card-sidebar"
>
<EmbedQuestionSettingsSidebar
cardId={selectedQuestionId}
editorInstance={editorInstance}
/>
</Box>
)}
{collectionPickerMode && (
<CollectionPickerModal
title={t`Where should we save this document?`}
onClose={() => setCollectionPickerMode(null)}
value={{ id: "root", model: "collection" }}
options={{
showPersonalCollections: true,
showRootCollection: true,
}}
onChange={async (collection) => {
if (collectionPickerMode === "save") {
handleSave(canonicalCollectionId(collection.id));
setCollectionPickerMode(null);
} else if (collectionPickerMode === "move") {
handleUpdate({
collection_id: canonicalCollectionId(collection.id),
});
}
}}
/>
)}
{collectionPickerMode && (
<CollectionPickerModal
title={t`Where should we save this document?`}
onClose={() => setCollectionPickerMode(null)}
value={{ id: "root", model: "collection" }}
options={{
showPersonalCollections: true,
showRootCollection: true,
}}
onChange={async (collection) => {
if (collectionPickerMode === "save") {
handleSave(canonicalCollectionId(collection.id));
setCollectionPickerMode(null);
} else if (collectionPickerMode === "move") {
handleUpdate({
collection_id: canonicalCollectionId(collection.id),
});
}
}}
<LeaveRouteConfirmModal
// `key` remounts this modal when navigating between different documents or to a new document.
// The `route` doesn't change in that scenario which prevents the modal from closing when you confirm you want to discard your changes.
key={location.key}
isEnabled={hasUnsavedChanges() && !isNavigationScheduled}
route={route}
/>
)}
<LeaveRouteConfirmModal
// `key` remounts this modal when navigating between different documents or to a new document.
// The `route` doesn't change in that scenario which prevents the modal from closing when you confirm you want to discard your changes.
key={location.key}
isEnabled={hasUnsavedChanges() && !isNavigationScheduled}
route={route}
/>
<LeaveConfirmModal
// only applies when going from /new -> /new
opened={
hasUnsavedChanges() &&
isNewDocument &&
location.key !== previousLocationKey
}
onConfirm={resetDocument}
onClose={() => forceUpdate()}
/>
<LeaveConfirmModal
// only applies when going from /new -> /new
opened={
hasUnsavedChanges() &&
isNewDocument &&
location.key !== previousLocationKey
}
onConfirm={resetDocument}
onClose={() => forceUpdate()}
/>
</Box>
</Box>
</Box>
{children}
</>
);
};

View File

@@ -0,0 +1,13 @@
.commentsMenu {
opacity: 0;
user-select: none;
&:hover,
&:focus-within {
opacity: 1;
}
}
.visible {
opacity: 1;
}

View File

@@ -0,0 +1,79 @@
import cx from "classnames";
import { type CSSProperties, forwardRef, useMemo } from "react";
import { createPortal } from "react-dom";
import { Link } from "react-router";
import { t } from "ttag";
// our Portal in metabase/ui does not work here, so we're using the originnal Mantine one
import { Box, Button, Icon, rem } from "metabase/ui";
import type { CommentThread } from "metabase-enterprise/comments/types";
import S from "./CommentsMenu.module.css";
interface Props {
active: boolean;
disabled: boolean;
href: string;
show: boolean;
style: CSSProperties;
threads: CommentThread[];
}
export const CommentsMenu = forwardRef<HTMLDivElement, Props>(
function CommentsMenu(
{ active, disabled, href, show, style, threads }: Props,
ref,
) {
const unresolvedCommentsCount = useMemo(
() =>
threads
.filter((thread) => !thread.comments[0]?.is_resolved)
.flatMap((thread) => thread.comments).length,
[threads],
);
const hasUnresolvedComments = unresolvedCommentsCount > 0;
return createPortal(
<Box
className={cx(S.commentsMenu, {
[S.visible]: disabled
? hasUnresolvedComments
: show || hasUnresolvedComments,
})}
contentEditable={false}
draggable={false}
mt={rem(-2)}
pl="lg"
ref={ref}
style={style}
>
{disabled ? (
<Button
disabled
leftSection={<Icon name="message" />}
px="sm"
size="xs"
aria-label={t`Comments`}
variant={active ? "filled" : "default"}
>
{unresolvedCommentsCount > 0 ? unresolvedCommentsCount : null}
</Button>
) : (
<Button
component={Link}
// If no existing comments, add query param to auto-open new comment form
to={threads.length === 0 ? `${href}?new=true` : href}
leftSection={<Icon name="message" />}
px="sm"
size="xs"
aria-label={t`Comments`}
variant={active ? "filled" : "default"}
>
{unresolvedCommentsCount > 0 ? unresolvedCommentsCount : null}
</Button>
)}
</Box>,
document.body,
);
},
);

View File

@@ -0,0 +1 @@
export * from "./CommentsMenu";

View File

@@ -34,7 +34,6 @@ import { SmartLink } from "./extensions/SmartLink/SmartLinkNode";
import { createSuggestionRenderer } from "./extensions/suggestionRenderer";
import { useCardEmbedsTracking, useQuestionSelection } from "./hooks";
import type { CardEmbedRef } from "./types";
const BUBBLE_MENU_DISALLOWED_NODES: string[] = [
CardEmbed.name,
MetabotNode.name,

View File

@@ -13,14 +13,30 @@ import { Flex } from "metabase/ui";
import S from "./EditorBubbleMenu.module.css";
import { FormatButton } from "./FormatButton";
const ALLOWED_FORMATTING = {
bold: true,
italic: true,
strikethrough: true,
h1: true,
h2: true,
h3: true,
list: true,
ordered_list: true,
quote: true,
inline_code: true,
code_block: true,
} as const;
interface EditorBubbleMenuProps {
editor: TiptapEditor;
disallowedNodes: string[];
allowedFormatting?: Partial<typeof ALLOWED_FORMATTING>;
}
export const EditorBubbleMenu: React.FC<EditorBubbleMenuProps> = ({
editor,
disallowedNodes,
allowedFormatting = ALLOWED_FORMATTING,
}) => {
const forceUpdate = useForceUpdate();
@@ -81,78 +97,100 @@ export const EditorBubbleMenu: React.FC<EditorBubbleMenuProps> = ({
className={S.bubbleMenu}
data-testid="document-formatting-menu"
>
<FormatButton
isActive={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
tooltip={t`Bold`}
icon="text_bold"
/>
<FormatButton
isActive={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
tooltip={t`Italic`}
icon="text_italic"
/>
<FormatButton
isActive={editor.isActive("strike")}
onClick={() => editor.chain().focus().toggleStrike().run()}
tooltip={t`Strikethrough`}
icon="text_strike"
/>
<FormatButton
isActive={editor.isActive("code")}
onClick={() => editor.chain().focus().toggleCode().run()}
tooltip={t`Inline code`}
icon="format_code"
/>
<FormatButton
isActive={editor.isActive("heading", { level: 1 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
tooltip={t`Heading 1`}
text="H1"
/>
<FormatButton
isActive={editor.isActive("heading", { level: 2 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
tooltip={t`Heading 2`}
text="H2"
/>
<FormatButton
isActive={editor.isActive("heading", { level: 3 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
tooltip={t`Heading 3`}
text="H3"
/>
<FormatButton
isActive={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
tooltip={t`Bullet list`}
icon="list"
/>
<FormatButton
isActive={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
tooltip={t`Numbered list`}
icon="ordered_list"
/>
<FormatButton
isActive={editor.isActive("blockquote")}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
tooltip={t`Quote`}
icon="quote"
/>
<FormatButton
isActive={editor.isActive("codeBlock")}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
tooltip={t`Code block`}
icon="code_block"
/>
{allowedFormatting.bold && (
<FormatButton
isActive={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
tooltip={t`Bold`}
icon="text_bold"
/>
)}
{allowedFormatting.italic && (
<FormatButton
isActive={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
tooltip={t`Italic`}
icon="text_italic"
/>
)}
{allowedFormatting.strikethrough && (
<FormatButton
isActive={editor.isActive("strike")}
onClick={() => editor.chain().focus().toggleStrike().run()}
tooltip={t`Strikethrough`}
icon="text_strike"
/>
)}
{allowedFormatting.inline_code && (
<FormatButton
isActive={editor.isActive("code")}
onClick={() => editor.chain().focus().toggleCode().run()}
tooltip={t`Inline code`}
icon="format_code"
/>
)}
{allowedFormatting.h1 && (
<FormatButton
isActive={editor.isActive("heading", { level: 1 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
tooltip={t`Heading 1`}
text="H1"
/>
)}
{allowedFormatting.h2 && (
<FormatButton
isActive={editor.isActive("heading", { level: 2 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
tooltip={t`Heading 2`}
text="H2"
/>
)}
{allowedFormatting.h3 && (
<FormatButton
isActive={editor.isActive("heading", { level: 3 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
tooltip={t`Heading 3`}
text="H3"
/>
)}
{allowedFormatting.list && (
<FormatButton
isActive={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
tooltip={t`Bullet list`}
icon="list"
/>
)}
{allowedFormatting.ordered_list && (
<FormatButton
isActive={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
tooltip={t`Numbered list`}
icon="ordered_list"
/>
)}
{allowedFormatting.quote && (
<FormatButton
isActive={editor.isActive("blockquote")}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
tooltip={t`Quote`}
icon="quote"
/>
)}
{allowedFormatting.code_block && (
<FormatButton
isActive={editor.isActive("codeBlock")}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
tooltip={t`Code block`}
icon="code_block"
/>
)}
</Flex>
</BubbleMenu>
);

View File

@@ -0,0 +1,21 @@
.blockquote {
position: relative;
}
.open {
--padding: 24px;
&:before {
content: "";
display: block;
position: absolute;
left: calc(-1 * var(--padding) / 2);
top: calc(-1 * var(--padding) / 2);
width: calc(100% + var(--padding));
height: calc(100% + var(--padding));
background-color: var(--mb-color-brand-alpha-04);
border: 1px solid var(--mb-color-brand);
border-radius: 8px;
pointer-events: none;
}
}

View File

@@ -0,0 +1,102 @@
import { autoUpdate, useFloating } from "@floating-ui/react";
import type { NodeViewProps } from "@tiptap/core";
import { Blockquote } from "@tiptap/extension-blockquote";
import {
NodeViewContent,
NodeViewWrapper,
ReactNodeViewRenderer,
} from "@tiptap/react";
import cx from "classnames";
import { useEffect, useMemo, useState } from "react";
import { useSelector } from "metabase/lib/redux";
import { useListCommentsQuery } from "metabase-enterprise/api";
import { getTargetChildCommentThreads } from "metabase-enterprise/comments/utils";
import {
getChildTargetId,
getCurrentDocument,
getHasUnsavedChanges,
} from "metabase-enterprise/documents/selectors";
import { getListCommentsQuery } from "metabase-enterprise/documents/utils/api";
import { CommentsMenu } from "../../CommentsMenu";
import { createIdAttribute, createProseMirrorPlugin } from "../NodeIds";
import S from "./Blockquote.module.css";
export const CustomBlockquote = Blockquote.extend({
addAttributes() {
return {
...createIdAttribute(),
};
},
addKeyboardShortcuts() {
return {};
},
addNodeView() {
return ReactNodeViewRenderer(BlockquoteNodeView);
},
addProseMirrorPlugins() {
return [createProseMirrorPlugin(Blockquote.name)];
},
});
export const BlockquoteNodeView = ({ node }: NodeViewProps) => {
const childTargetId = useSelector(getChildTargetId);
const document = useSelector(getCurrentDocument);
const { data: commentsData } = useListCommentsQuery(
getListCommentsQuery(document),
);
const comments = commentsData?.comments;
const hasUnsavedChanges = useSelector(getHasUnsavedChanges);
const [hovered, setHovered] = useState(false);
const [rendered, setRendered] = useState(false); // floating ui wrongly positions things without this
const { _id } = node.attrs;
const isOpen = childTargetId === _id;
const threads = useMemo(
() => getTargetChildCommentThreads(comments, _id),
[comments, _id],
);
const { refs, floatingStyles } = useFloating({
placement: "right-start",
whileElementsMounted: autoUpdate,
strategy: "fixed",
open: rendered,
});
useEffect(() => {
if (!rendered) {
setRendered(true);
}
}, [rendered]);
return (
<>
<NodeViewWrapper
className={cx(S.blockquote, {
[S.open]: isOpen,
})}
ref={refs.setReference}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
>
<NodeViewContent<"blockquote"> as="blockquote" />
</NodeViewWrapper>
{document && rendered && (
<CommentsMenu
active={isOpen}
disabled={hasUnsavedChanges}
href={`/document/${document.id}/comments/${_id}`}
ref={refs.setFloating}
show={isOpen || hovered}
style={floatingStyles}
threads={threads}
/>
)}
</>
);
};

View File

@@ -0,0 +1 @@
export * from "./Blockquote";

View File

@@ -0,0 +1,21 @@
.bulletList {
position: relative;
}
.open {
--padding: 24px;
&:before {
content: "";
display: block;
position: absolute;
left: calc(-1 * var(--padding) / 2);
top: calc(-1 * var(--padding) / 2);
width: calc(100% + var(--padding));
height: calc(100% + var(--padding));
background-color: var(--mb-color-brand-alpha-04);
border: 1px solid var(--mb-color-brand);
border-radius: 8px;
pointer-events: none;
}
}

View File

@@ -0,0 +1,100 @@
import { autoUpdate, useFloating } from "@floating-ui/react";
import type { NodeViewProps } from "@tiptap/core";
import { BulletList } from "@tiptap/extension-bullet-list";
import {
NodeViewContent,
NodeViewWrapper,
ReactNodeViewRenderer,
} from "@tiptap/react";
import cx from "classnames";
import { useEffect, useMemo, useState } from "react";
import { useSelector } from "metabase/lib/redux";
import { useListCommentsQuery } from "metabase-enterprise/api";
import { getTargetChildCommentThreads } from "metabase-enterprise/comments/utils";
import {
getChildTargetId,
getCurrentDocument,
getHasUnsavedChanges,
} from "metabase-enterprise/documents/selectors";
import { getListCommentsQuery } from "metabase-enterprise/documents/utils/api";
import { isTopLevel } from "metabase-enterprise/documents/utils/editorNodeUtils";
import { CommentsMenu } from "../../CommentsMenu";
import { createIdAttribute, createProseMirrorPlugin } from "../NodeIds";
import S from "./BulletList.module.css";
export const CustomBulletList = BulletList.extend({
addAttributes() {
return {
...createIdAttribute(),
};
},
addNodeView() {
return ReactNodeViewRenderer(BulletListNodeView);
},
addProseMirrorPlugins() {
return [createProseMirrorPlugin(BulletList.name)];
},
});
export const BulletListNodeView = ({ node, editor, getPos }: NodeViewProps) => {
const childTargetId = useSelector(getChildTargetId);
const document = useSelector(getCurrentDocument);
const { data: commentsData } = useListCommentsQuery(
getListCommentsQuery(document),
);
const comments = commentsData?.comments;
const hasUnsavedChanges = useSelector(getHasUnsavedChanges);
const [hovered, setHovered] = useState(false);
const [rendered, setRendered] = useState(false); // floating ui wrongly positions things without this
const { _id } = node.attrs;
const isOpen = childTargetId === _id;
const threads = useMemo(
() => getTargetChildCommentThreads(comments, _id),
[comments, _id],
);
const { refs, floatingStyles } = useFloating({
placement: "right-start",
whileElementsMounted: autoUpdate,
strategy: "fixed",
open: rendered,
});
useEffect(() => {
if (!rendered) {
setRendered(true);
}
}, [rendered]);
return (
<>
<NodeViewWrapper
className={cx(S.bulletList, {
[S.open]: isOpen,
})}
ref={refs.setReference}
// onMouseEnter/onMouseLeave do not work on list elements living in contentEditable
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
>
<NodeViewContent<"ul"> as="ul" />
</NodeViewWrapper>
{document && rendered && isTopLevel({ editor, getPos }) && (
<CommentsMenu
active={isOpen}
disabled={hasUnsavedChanges}
href={`/document/${document.id}/comments/${_id}`}
ref={refs.setFloating}
show={isOpen || hovered}
threads={threads}
style={floatingStyles}
/>
)}
</>
);
};

View File

@@ -0,0 +1 @@
export * from "./BulletList";

View File

@@ -1,8 +1,27 @@
.embedWrapper {
position: relative;
display: block;
width: 100%;
}
.open {
--padding: 24px;
&:before {
content: "";
display: block;
position: absolute;
left: calc(-1 * var(--padding) / 2);
top: calc(-1 * var(--padding) / 2);
width: calc(100% + var(--padding));
height: calc(100% + var(--padding));
background-color: var(--mb-color-brand-alpha-04);
border: 1px solid var(--mb-color-brand);
border-radius: 8px;
pointer-events: none;
}
}
.cardEmbed {
border: 1px solid var(--mb-color-border);
border-radius: var(--mantine-radius-md);

View File

@@ -1,3 +1,4 @@
import { autoUpdate, useFloating } from "@floating-ui/react";
import { Node, mergeAttributes } from "@tiptap/core";
import {
type NodeViewProps,
@@ -5,7 +6,7 @@ import {
ReactNodeViewRenderer,
} from "@tiptap/react";
import cx from "classnames";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { t } from "ttag";
import { QuestionPickerModal } from "metabase/common/components/Pickers/QuestionPicker/components/QuestionPickerModal";
@@ -17,9 +18,16 @@ import Visualization from "metabase/visualizations/components/Visualization";
import { ErrorView } from "metabase/visualizations/components/Visualization/ErrorView/ErrorView";
import ChartSkeleton from "metabase/visualizations/components/skeletons/ChartSkeleton";
import { getGenericErrorMessage } from "metabase/visualizations/lib/errors";
import { useListCommentsQuery } from "metabase-enterprise/api";
import { getTargetChildCommentThreads } from "metabase-enterprise/comments/utils";
import { navigateToCardFromDocument } from "metabase-enterprise/documents/actions";
import { trackDocumentReplaceCard } from "metabase-enterprise/documents/analytics";
import { getCurrentDocument } from "metabase-enterprise/documents/selectors";
import {
getChildTargetId,
getCurrentDocument,
getHasUnsavedChanges,
} from "metabase-enterprise/documents/selectors";
import { getListCommentsQuery } from "metabase-enterprise/documents/utils/api";
import Question from "metabase-lib/v1/Question";
import { getUrl } from "metabase-lib/v1/urls";
import type { Card, CardDisplayType, Dataset } from "metabase-types/api";
@@ -29,7 +37,9 @@ import {
openVizSettingsSidebar,
} from "../../../../documents.slice";
import { useCardData } from "../../../../hooks/use-card-data";
import { CommentsMenu } from "../../CommentsMenu";
import { EDITOR_STYLE_BOUNDARY_CLASS } from "../../constants";
import { createIdAttribute, createProseMirrorPlugin } from "../NodeIds";
import styles from "./CardEmbedNode.module.css";
import { ModifyQuestionModal } from "./ModifyQuestionModal";
@@ -82,6 +92,7 @@ export const CardEmbed: Node<{
default: null,
parseHTML: (element) => element.getAttribute("data-name"),
},
...createIdAttribute(),
};
},
@@ -109,6 +120,10 @@ export const CardEmbed: Node<{
];
},
addProseMirrorPlugins() {
return [createProseMirrorPlugin("cardEmbed")];
},
renderText({ node }) {
return formatCardEmbed(node.attrs as CardEmbedAttributes);
},
@@ -120,6 +135,26 @@ export const CardEmbed: Node<{
export const CardEmbedComponent = memo(
({ node, updateAttributes, selected, editor, getPos }: NodeViewProps) => {
const childTargetId = useSelector(getChildTargetId);
const document = useSelector(getCurrentDocument);
const { data: commentsData } = useListCommentsQuery(
getListCommentsQuery(document),
);
const comments = commentsData?.comments;
const hasUnsavedChanges = useSelector(getHasUnsavedChanges);
const [hovered, setHovered] = useState(false);
const { _id } = node.attrs;
const isOpen = childTargetId === _id;
const threads = useMemo(
() => getTargetChildCommentThreads(comments, _id),
[comments, _id],
);
const { refs, floatingStyles } = useFloating({
placement: "right-start",
whileElementsMounted: autoUpdate,
strategy: "fixed",
});
const { id, name } = node.attrs;
const dispatch = useDispatch();
const canWrite = editor.options.editable;
@@ -146,7 +181,6 @@ export const CardEmbedComponent = memo(
const { card, dataset, isLoading, series, error } = useCardData({ id });
const metadata = useSelector(getMetadata);
const document = useSelector(getCurrentDocument);
const datasetError = dataset && getDatasetError(dataset);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editedTitle, setEditedTitle] = useState(name || "");
@@ -325,187 +359,206 @@ export const CardEmbedComponent = memo(
}
return (
<NodeViewWrapper
className={styles.embedWrapper}
data-testid="document-card-embed"
>
<Box
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
[styles.selected]: selected,
<>
<NodeViewWrapper
className={cx(styles.embedWrapper, {
[styles.open]: isOpen,
})}
data-testid="document-card-embed"
ref={refs.setReference}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
>
{card && (
<Box className={styles.questionHeader}>
<Flex align="center" justify="space-between" gap="0.5rem">
{isEditingTitle ? (
<TextInput
ref={titleInputRef}
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
onBlur={handleTitleSave}
onKeyDown={handleTitleKeyDown}
size="md"
flex={1}
styles={{
input: {
fontWeight: 700,
fontSize: "1rem",
border: "1px solid transparent",
padding: 0,
height: "auto",
minHeight: "auto",
lineHeight: 1.55,
backgroundColor: "transparent",
"&:focus": {
border: "1px solid var(--mb-color-border)",
backgroundColor: "var(--mb-color-bg-white)",
padding: "0 0.25rem",
},
},
}}
/>
) : (
<Box className={styles.titleContainer}>
<Text
<Box
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
[styles.selected]: selected,
})}
>
{card && (
<Box className={styles.questionHeader}>
<Flex align="center" justify="space-between" gap="0.5rem">
{isEditingTitle ? (
<TextInput
ref={titleInputRef}
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
onBlur={handleTitleSave}
onKeyDown={handleTitleKeyDown}
size="md"
color="text-dark"
fw={700}
onClick={handleTitleClick}
c="pointer"
>
{displayName}
</Text>
{canWrite && (
<Icon
name="pencil"
size={14}
color="var(--mb-color-text-medium)"
className={styles.titleEditIcon}
flex={1}
styles={{
input: {
fontWeight: 700,
fontSize: "1rem",
border: "1px solid transparent",
padding: 0,
height: "auto",
minHeight: "auto",
lineHeight: 1.55,
backgroundColor: "transparent",
"&:focus": {
border: "1px solid var(--mb-color-border)",
backgroundColor: "var(--mb-color-bg-white)",
padding: "0 0.25rem",
},
},
}}
/>
) : (
<Box className={styles.titleContainer}>
<Text
size="md"
color="text-dark"
fw={700}
onClick={handleTitleClick}
c="pointer"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setEditedTitle(displayName);
setIsEditingTitle(true);
}}
/>
)}
</Box>
)}
{!isEditingTitle && (
<Menu withinPortal position="bottom-end" data-hide-on-print>
<Menu.Target>
<Flex
component="button"
bg="transparent"
c="pointer"
p="0.25rem"
align="center"
justify="center"
className={styles.menuButton}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
{displayName}
</Text>
{canWrite && (
<Icon
name="ellipsis"
size={16}
name="pencil"
size={14}
color="var(--mb-color-text-medium)"
className={styles.titleEditIcon}
c="pointer"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setEditedTitle(displayName);
setIsEditingTitle(true);
}}
/>
</Flex>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={handleEditVisualizationSettings}
disabled={!canWrite}
leftSection={<Icon name="palette" size={14} />}
>
{t`Edit Visualization`}
</Menu.Item>
<Menu.Item
onClick={() => setIsModifyModalOpen(true)}
disabled={!canWrite}
leftSection={
)}
</Box>
)}
{!isEditingTitle && (
<Menu withinPortal position="bottom-end" data-hide-on-print>
<Menu.Target>
<Flex
component="button"
bg="transparent"
c="pointer"
p="0.25rem"
align="center"
justify="center"
className={styles.menuButton}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Icon
name={isNativeQuestion ? "sql" : "notebook"}
size={14}
name="ellipsis"
size={16}
color="var(--mb-color-text-medium)"
/>
}
>
{t`Edit Query`}
</Menu.Item>
<Menu.Item
onClick={handleReplaceQuestion}
disabled={!canWrite}
leftSection={<Icon name="refresh" size={14} />}
>
{t`Replace`}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Flex>
</Box>
)}
{series ? (
<>
</Flex>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={handleEditVisualizationSettings}
disabled={!canWrite}
leftSection={<Icon name="palette" size={14} />}
>
{t`Edit Visualization`}
</Menu.Item>
<Menu.Item
onClick={() => setIsModifyModalOpen(true)}
disabled={!canWrite}
leftSection={
<Icon
name={isNativeQuestion ? "sql" : "notebook"}
size={14}
/>
}
>
{t`Edit Query`}
</Menu.Item>
<Menu.Item
onClick={handleReplaceQuestion}
disabled={!canWrite}
leftSection={<Icon name="refresh" size={14} />}
>
{t`Replace`}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Flex>
</Box>
)}
{series ? (
<>
<Box className={styles.questionResults}>
<Visualization
rawSeries={series}
metadata={metadata}
onChangeCardAndRun={handleChangeCardAndRun}
getExtraDataForClick={() => ({})}
isEditing={false}
isDashboard={false}
isDocument={true}
showTitle={false}
error={datasetError?.message}
errorIcon={datasetError?.icon}
/>
</Box>
</>
) : (
<Box className={styles.questionResults}>
<Visualization
rawSeries={series}
metadata={metadata}
onChangeCardAndRun={handleChangeCardAndRun}
getExtraDataForClick={() => ({})}
isEditing={false}
isDashboard={false}
isDocument={true}
showTitle={false}
error={datasetError?.message}
errorIcon={datasetError?.icon}
<ChartSkeleton
display={(card?.display as CardDisplayType) || "table"}
/>
</Box>
</>
) : (
<Box className={styles.questionResults}>
<ChartSkeleton
display={(card?.display as CardDisplayType) || "table"}
)}
</Box>
{isModifyModalOpen &&
card &&
(isNativeQuestion ? (
<NativeQueryModal
card={card}
isOpen={isModifyModalOpen}
onClose={() => setIsModifyModalOpen(false)}
initialDataset={dataset}
onSave={(result) => {
updateAttributes({
id: result.card_id,
name: null,
});
setIsModifyModalOpen(false);
}}
/>
</Box>
) : (
<ModifyQuestionModal
card={card}
isOpen={isModifyModalOpen}
onClose={() => setIsModifyModalOpen(false)}
onSave={(result) => {
updateAttributes({
id: result.card_id,
name: null,
});
setIsModifyModalOpen(false);
}}
/>
))}
{isReplaceModalOpen && (
<QuestionPickerModal
onChange={handleReplaceModalSelect}
onClose={() => setIsReplaceModalOpen(false)}
/>
)}
</Box>
{isModifyModalOpen &&
card &&
(isNativeQuestion ? (
<NativeQueryModal
card={card}
isOpen={isModifyModalOpen}
onClose={() => setIsModifyModalOpen(false)}
initialDataset={dataset}
onSave={(result) => {
updateAttributes({
id: result.card_id,
name: null,
});
setIsModifyModalOpen(false);
}}
/>
) : (
<ModifyQuestionModal
card={card}
isOpen={isModifyModalOpen}
onClose={() => setIsModifyModalOpen(false)}
onSave={(result) => {
updateAttributes({
id: result.card_id,
name: null,
});
setIsModifyModalOpen(false);
}}
/>
))}
{isReplaceModalOpen && (
<QuestionPickerModal
onChange={handleReplaceModalSelect}
onClose={() => setIsReplaceModalOpen(false)}
</NodeViewWrapper>
{document && (
<CommentsMenu
active={isOpen}
disabled={hasUnsavedChanges}
href={`/document/${document.id}/comments/${_id}`}
ref={refs.setFloating}
show={isOpen || hovered}
threads={threads}
style={floatingStyles}
/>
)}
</NodeViewWrapper>
</>
);
},
(prevProps, nextProps) => {

View File

@@ -0,0 +1,21 @@
.codeBlock {
position: relative;
}
.open {
--padding: 24px;
&:before {
content: "";
display: block;
position: absolute;
left: calc(-1 * var(--padding) / 2);
top: calc(-1 * var(--padding) / 2);
width: calc(100% + var(--padding));
height: calc(100% + var(--padding));
background-color: var(--mb-color-brand-alpha-04);
border: 1px solid var(--mb-color-brand);
border-radius: 8px;
pointer-events: none;
}
}

View File

@@ -0,0 +1,184 @@
import { autoUpdate, useFloating } from "@floating-ui/react";
import type { NodeViewProps } from "@tiptap/core";
import { CodeBlock } from "@tiptap/extension-code-block";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import {
NodeViewContent,
NodeViewWrapper,
ReactNodeViewRenderer,
} from "@tiptap/react";
import cx from "classnames";
import { useEffect, useMemo, useState } from "react";
import { useSelector } from "metabase/lib/redux";
import { useListCommentsQuery } from "metabase-enterprise/api";
import { getTargetChildCommentThreads } from "metabase-enterprise/comments/utils";
import {
getChildTargetId,
getCurrentDocument,
getHasUnsavedChanges,
} from "metabase-enterprise/documents/selectors";
import { getListCommentsQuery } from "metabase-enterprise/documents/utils/api";
import { CommentsMenu } from "../../CommentsMenu";
import { createIdAttribute, createProseMirrorPlugin } from "../NodeIds";
import S from "./CodeBlock.module.css";
const languageClassPrefix = "language-";
export const CustomCodeBlock = CodeBlock.extend({
addAttributes() {
return {
language: {
default: this.options.defaultLanguage,
parseHTML: (element: HTMLElement) => {
const { languageClassPrefix } = this.options;
const classNames = [...(element.firstElementChild?.classList || [])];
const languages = classNames
.filter((className) => className.startsWith(languageClassPrefix))
.map((className) => className.replace(languageClassPrefix, ""));
const language = languages[0];
if (!language) {
return null;
}
return language;
},
rendered: false,
},
...createIdAttribute(),
};
},
addNodeView() {
return ReactNodeViewRenderer(CodeBlockNodeView);
},
addProseMirrorPlugins() {
return [
createProseMirrorPlugin(CodeBlock.name), // this plugin creates a code block for pasted content from VS Code
// we can also detect the copied code language
new Plugin({
key: new PluginKey("codeBlockVSCodeHandler"),
props: {
handlePaste: (view, event) => {
if (!event.clipboardData) {
return false;
}
// don’t create a new code block within code blocks
if (this.editor.isActive(this.type.name)) {
return false;
}
const text = event.clipboardData.getData("text/plain");
const vscode = event.clipboardData.getData("vscode-editor-data");
const vscodeData = vscode ? JSON.parse(vscode) : undefined;
const language = vscodeData?.mode;
if (!text || !language) {
return false;
}
const { tr, schema } = view.state;
// prepare a text node
// strip carriage return chars from text pasted as code
// see: https://github.com/ProseMirror/prosemirror-view/commit/a50a6bcceb4ce52ac8fcc6162488d8875613aacd
const textNode = schema.text(text.replace(/\r\n?/g, "\n"));
// create a code block with the text node
// replace selection with the code block
tr.replaceSelectionWith(this.type.create({ language }, textNode));
if (tr.selection.$from.parent.type !== this.type) {
// put cursor inside the newly created code block
tr.setSelection(
TextSelection.near(
tr.doc.resolve(Math.max(0, tr.selection.from - 2)),
),
);
}
// store meta information
// this is useful for other plugins that depends on the paste event
// like the paste rule plugin
tr.setMeta("paste", true);
view.dispatch(tr);
return true;
},
},
}),
];
},
});
export const CodeBlockNodeView = ({ node }: NodeViewProps) => {
const childTargetId = useSelector(getChildTargetId);
const document = useSelector(getCurrentDocument);
const { data: commentsData } = useListCommentsQuery(
getListCommentsQuery(document),
);
const comments = commentsData?.comments;
const hasUnsavedChanges = useSelector(getHasUnsavedChanges);
const [hovered, setHovered] = useState(false);
const [rendered, setRendered] = useState(false); // floating ui wrongly positions things without this
const { _id } = node.attrs;
const isOpen = childTargetId === _id;
const threads = useMemo(
() => getTargetChildCommentThreads(comments, _id),
[comments, _id],
);
const { refs, floatingStyles } = useFloating({
placement: "right-start",
whileElementsMounted: autoUpdate,
strategy: "fixed",
open: rendered,
});
useEffect(() => {
if (!rendered) {
setRendered(true);
}
}, [rendered]);
return (
<>
<NodeViewWrapper
className={cx(S.codeBlock, {
[S.open]: isOpen,
})}
ref={refs.setReference}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
>
<pre>
<NodeViewContent<"code">
as="code"
className={
node.attrs.language
? languageClassPrefix + node.attrs.language
: undefined
}
/>
</pre>
</NodeViewWrapper>
{document && rendered && (
<CommentsMenu
active={isOpen}
disabled={hasUnsavedChanges}
href={`/document/${document.id}/comments/${_id}`}
ref={refs.setFloating}
show={isOpen || hovered}
threads={threads}
style={floatingStyles}
/>
)}
</>
);
};

View File

@@ -0,0 +1 @@
export * from "./CodeBlock";

View File

@@ -1,13 +1,19 @@
import type { AnyExtension } from "@tiptap/core";
import { Blockquote } from "@tiptap/extension-blockquote";
import { Bold } from "@tiptap/extension-bold";
import { BulletList } from "@tiptap/extension-bullet-list";
import { CodeBlock } from "@tiptap/extension-code-block";
import { Heading } from "@tiptap/extension-heading";
import { OrderedList } from "@tiptap/extension-ordered-list";
import { Paragraph } from "@tiptap/extension-paragraph";
import { StarterKit, type StarterKitOptions } from "@tiptap/starter-kit";
const CustomBlockquote = Blockquote.extend({
addKeyboardShortcuts() {
return {};
},
});
import { CustomBlockquote } from "../Blockquote";
import { CustomBulletList } from "../BulletList";
import { CustomCodeBlock } from "../CodeBlock";
import { CustomHeading } from "../Heading";
import { CustomOrderedList } from "../OrderedList";
import { CustomParagraph } from "../Paragraph";
const CustomBold = Bold.extend({
addKeyboardShortcuts() {
@@ -49,6 +55,46 @@ export const CustomStarterKit = StarterKit.extend<StarterKitOptions>({
);
}
if (this.options.bulletList !== false) {
extensions = replaceExtension(
extensions,
BulletList,
CustomBulletList.configure(this.options.bulletList),
);
}
if (this.options.orderedList !== false) {
extensions = replaceExtension(
extensions,
OrderedList,
CustomOrderedList.configure(this.options.orderedList),
);
}
if (this.options.heading !== false) {
extensions = replaceExtension(
extensions,
Heading,
CustomHeading.configure(this.options.heading),
);
}
if (this.options.paragraph !== false) {
extensions = replaceExtension(
extensions,
Paragraph,
CustomParagraph.configure(this.options.paragraph),
);
}
if (this.options.codeBlock !== false) {
extensions = replaceExtension(
extensions,
CodeBlock,
CustomCodeBlock.configure(this.options.codeBlock),
);
}
if (this.options.bold !== false) {
extensions = replaceExtension(
extensions,

View File

@@ -0,0 +1,21 @@
.heading {
position: relative;
}
.open {
--padding: 24px;
&:before {
content: "";
display: block;
position: absolute;
left: calc(-1 * var(--padding) / 2);
top: calc(-1 * var(--padding) / 2);
width: calc(100% + var(--padding));
height: calc(100% + var(--padding));
background-color: var(--mb-color-brand-alpha-04);
border: 1px solid var(--mb-color-brand);
border-radius: 8px;
pointer-events: none;
}
}

View File

@@ -0,0 +1,117 @@
import { autoUpdate, useFloating } from "@floating-ui/react";
import type { NodeViewProps } from "@tiptap/core";
import { Heading } from "@tiptap/extension-heading";
import {
NodeViewContent,
NodeViewWrapper,
ReactNodeViewRenderer,
} from "@tiptap/react";
import cx from "classnames";
import { useEffect, useMemo, useState } from "react";
import { useSelector } from "metabase/lib/redux";
import { useListCommentsQuery } from "metabase-enterprise/api";
import { getTargetChildCommentThreads } from "metabase-enterprise/comments/utils";
import {
getChildTargetId,
getCurrentDocument,
getHasUnsavedChanges,
} from "metabase-enterprise/documents/selectors";
import { getListCommentsQuery } from "metabase-enterprise/documents/utils/api";
import { CommentsMenu } from "../../CommentsMenu";
import { createIdAttribute, createProseMirrorPlugin } from "../NodeIds";
import S from "./Heading.module.css";
export const CustomHeading = Heading.extend({
addAttributes() {
return {
level: {
default: 1,
rendered: false,
},
...createIdAttribute(),
};
},
addNodeView() {
return ReactNodeViewRenderer(HeadingNodeView);
},
addProseMirrorPlugins() {
return [createProseMirrorPlugin(Heading.name)];
},
});
type Level = 1 | 2 | 3 | 4 | 5 | 6;
type ElementType = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
const levelNodeMap: Record<Level, ElementType> = {
1: "h1",
2: "h2",
3: "h3",
4: "h4",
5: "h5",
6: "h6",
};
export const HeadingNodeView = ({ node }: NodeViewProps) => {
const childTargetId = useSelector(getChildTargetId);
const document = useSelector(getCurrentDocument);
const { data: commentsData } = useListCommentsQuery(
getListCommentsQuery(document),
);
const comments = commentsData?.comments;
const hasUnsavedChanges = useSelector(getHasUnsavedChanges);
const [hovered, setHovered] = useState(false);
const [rendered, setRendered] = useState(false); // floating ui wrongly positions things without this
const { _id, level } = node.attrs;
const isOpen = childTargetId === _id;
const threads = useMemo(
() => getTargetChildCommentThreads(comments, _id),
[comments, _id],
);
const { refs, floatingStyles } = useFloating({
placement: "right-start",
whileElementsMounted: autoUpdate,
strategy: "fixed",
open: rendered,
});
useEffect(() => {
if (!rendered) {
setRendered(true);
}
}, [rendered]);
return (
<>
<NodeViewWrapper
className={cx(S.heading, {
[S.open]: isOpen,
})}
ref={refs.setReference}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
>
<NodeViewContent<ElementType>
as={levelNodeMap[level as Level] ?? "h1"}
/>
</NodeViewWrapper>
{document && rendered && (
<CommentsMenu
active={isOpen}
disabled={hasUnsavedChanges}
href={`/document/${document.id}/comments/${_id}`}
ref={refs.setFloating}
show={isOpen || hovered}
threads={threads}
style={floatingStyles}
/>
)}
</>
);
};

View File

@@ -0,0 +1 @@
export * from "./Heading";

View File

@@ -0,0 +1 @@
export const ID_ATTRIBUTE_NAME = "_id";

View File

@@ -0,0 +1,2 @@
export * from "./constants";
export * from "./utils";

View File

@@ -0,0 +1,55 @@
import type { Attribute } from "@tiptap/core";
import { Plugin } from "prosemirror-state";
import { uuid } from "metabase/lib/uuid";
import { ID_ATTRIBUTE_NAME } from "./constants";
type Attributes = {
[key: string]: Attribute;
};
export function createIdAttribute() {
return {
[ID_ATTRIBUTE_NAME]: {
default: () => uuid(),
parseHTML: (el: HTMLElement) =>
el.getAttribute(ID_ATTRIBUTE_NAME) || null,
renderHTML: (attrs: Attributes) =>
attrs[ID_ATTRIBUTE_NAME]
? { [ID_ATTRIBUTE_NAME]: attrs[ID_ATTRIBUTE_NAME] }
: {},
},
};
}
// needed to upgrade documents without ids and to fix duplicated ids when splitting nodes
export function createProseMirrorPlugin(nodeName: string) {
return new Plugin({
appendTransaction: (_trs, _old, state) => {
const { doc, tr } = state;
let updated = false;
// Check if any nodes were split (have the same _id)
const seenIds = new Set();
doc.descendants((node, pos) => {
const isRightNode = node.type.name === nodeName;
const _id = node.attrs[ID_ATTRIBUTE_NAME];
const hasNoId = _id == null;
const isDuplicate = seenIds.has(_id);
if (_id) {
seenIds.add(_id);
}
if ((isRightNode && hasNoId) || isDuplicate) {
tr.setNodeAttribute(pos, ID_ATTRIBUTE_NAME, uuid());
updated = true;
}
});
return updated ? tr : null;
},
});
}

View File

@@ -0,0 +1,21 @@
.orderedList {
position: relative;
}
.open {
--padding: 24px;
&:before {
content: "";
display: block;
position: absolute;
left: calc(-1 * var(--padding) / 2);
top: calc(-1 * var(--padding) / 2);
width: calc(100% + var(--padding));
height: calc(100% + var(--padding));
background-color: var(--mb-color-brand-alpha-04);
border: 1px solid var(--mb-color-brand);
border-radius: 8px;
pointer-events: none;
}
}

View File

@@ -0,0 +1,116 @@
import { autoUpdate, useFloating } from "@floating-ui/react";
import type { NodeViewProps } from "@tiptap/core";
import { OrderedList } from "@tiptap/extension-ordered-list";
import {
NodeViewContent,
NodeViewWrapper,
ReactNodeViewRenderer,
} from "@tiptap/react";
import cx from "classnames";
import { useEffect, useMemo, useState } from "react";
import { useSelector } from "metabase/lib/redux";
import { useListCommentsQuery } from "metabase-enterprise/api";
import { getTargetChildCommentThreads } from "metabase-enterprise/comments/utils";
import {
getChildTargetId,
getCurrentDocument,
getHasUnsavedChanges,
} from "metabase-enterprise/documents/selectors";
import { getListCommentsQuery } from "metabase-enterprise/documents/utils/api";
import { isTopLevel } from "metabase-enterprise/documents/utils/editorNodeUtils";
import { CommentsMenu } from "../../CommentsMenu";
import { createIdAttribute, createProseMirrorPlugin } from "../NodeIds";
import S from "./OrderedList.module.css";
export const CustomOrderedList = OrderedList.extend({
addAttributes() {
return {
start: {
default: 1,
parseHTML: (element: HTMLElement) => {
return element.hasAttribute("start")
? parseInt(element.getAttribute("start") || "", 10)
: 1;
},
},
type: {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute("type"),
},
...createIdAttribute(),
};
},
addNodeView() {
return ReactNodeViewRenderer(OrderedListNodeView);
},
addProseMirrorPlugins() {
return [createProseMirrorPlugin(OrderedList.name)];
},
});
export const OrderedListNodeView = ({
node,
editor,
getPos,
}: NodeViewProps) => {
const childTargetId = useSelector(getChildTargetId);
const document = useSelector(getCurrentDocument);
const { data: commentsData } = useListCommentsQuery(
getListCommentsQuery(document),
);
const comments = commentsData?.comments;
const hasUnsavedChanges = useSelector(getHasUnsavedChanges);
const [hovered, setHovered] = useState(false);
const [rendered, setRendered] = useState(false); // floating ui wrongly positions things without this
const { _id } = node.attrs;
const isOpen = childTargetId === _id;
const threads = useMemo(
() => getTargetChildCommentThreads(comments, _id),
[comments, _id],
);
const { refs, floatingStyles } = useFloating({
placement: "right-start",
whileElementsMounted: autoUpdate,
strategy: "fixed",
open: rendered,
});
useEffect(() => {
if (!rendered) {
setRendered(true);
}
}, [rendered]);
return (
<>
<NodeViewWrapper
className={cx(S.orderedList, {
[S.open]: isOpen,
})}
ref={refs.setReference}
// onMouseEnter/onMouseLeave do not work on list elements living in contentEditable
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
>
<NodeViewContent<"ol"> as="ol" />
</NodeViewWrapper>
{document && rendered && isTopLevel({ editor, getPos }) && (
<CommentsMenu
active={isOpen}
disabled={hasUnsavedChanges}
href={`/document/${document.id}/comments/${_id}`}
ref={refs.setFloating}
show={isOpen || hovered}
threads={threads}
style={floatingStyles}
/>
)}
</>
);
};

View File

@@ -0,0 +1 @@
export * from "./OrderedList";

View File

@@ -0,0 +1,21 @@
.paragraph {
position: relative;
}
.open {
--padding: 24px;
&:before {
content: "";
display: block;
position: absolute;
left: calc(-1 * var(--padding) / 2);
top: calc(-1 * var(--padding) / 2);
width: calc(100% + var(--padding));
height: calc(100% + var(--padding));
background-color: var(--mb-color-brand-alpha-04);
border: 1px solid var(--mb-color-brand);
border-radius: 8px;
pointer-events: none;
}
}

View File

@@ -0,0 +1,99 @@
import { autoUpdate, useFloating } from "@floating-ui/react";
import type { NodeViewProps } from "@tiptap/core";
import { Paragraph } from "@tiptap/extension-paragraph";
import {
NodeViewContent,
NodeViewWrapper,
ReactNodeViewRenderer,
} from "@tiptap/react";
import cx from "classnames";
import { useEffect, useMemo, useState } from "react";
import { useSelector } from "metabase/lib/redux";
import { useListCommentsQuery } from "metabase-enterprise/api";
import { getTargetChildCommentThreads } from "metabase-enterprise/comments/utils";
import {
getChildTargetId,
getCurrentDocument,
getHasUnsavedChanges,
} from "metabase-enterprise/documents/selectors";
import { getListCommentsQuery } from "metabase-enterprise/documents/utils/api";
import { isTopLevel } from "metabase-enterprise/documents/utils/editorNodeUtils";
import { CommentsMenu } from "../../CommentsMenu";
import { createIdAttribute, createProseMirrorPlugin } from "../NodeIds";
import S from "./Paragraph.module.css";
export const CustomParagraph = Paragraph.extend({
addAttributes() {
return {
...createIdAttribute(),
};
},
addNodeView() {
return ReactNodeViewRenderer(ParagraphNodeView);
},
addProseMirrorPlugins() {
return [createProseMirrorPlugin(Paragraph.name)];
},
});
export const ParagraphNodeView = ({ node, editor, getPos }: NodeViewProps) => {
const childTargetId = useSelector(getChildTargetId);
const document = useSelector(getCurrentDocument);
const { data: commentsData } = useListCommentsQuery(
getListCommentsQuery(document),
);
const comments = commentsData?.comments;
const hasUnsavedChanges = useSelector(getHasUnsavedChanges);
const [hovered, setHovered] = useState(false);
const [rendered, setRendered] = useState(false); // floating ui wrongly positions things without this
const { _id } = node.attrs;
const isOpen = childTargetId === _id;
const threads = useMemo(
() => getTargetChildCommentThreads(comments, _id),
[comments, _id],
);
const { refs, floatingStyles } = useFloating({
placement: "right-start",
whileElementsMounted: autoUpdate,
strategy: "fixed",
open: rendered,
});
useEffect(() => {
if (!rendered) {
setRendered(true);
}
}, [rendered]);
return (
<>
<NodeViewWrapper
className={cx(S.paragraph, {
[S.open]: isOpen,
})}
ref={refs.setReference}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
>
<NodeViewContent<"p"> as="p" />
</NodeViewWrapper>
{document && rendered && isTopLevel({ editor, getPos }) && (
<CommentsMenu
active={isOpen}
disabled={hasUnsavedChanges}
href={`/document/${document.id}/comments/${_id}`}
ref={refs.setFloating}
show={isOpen || hovered}
threads={threads}
style={floatingStyles}
/>
)}
</>
);
};

View File

@@ -0,0 +1 @@
export * from "./Paragraph";

View File

@@ -38,6 +38,9 @@ export interface DocumentsState {
currentDocument: Document | null;
draftCards: Record<number, Card>;
mentionsCache: Record<string, MentionCacheItem>;
isCommentSidebarOpen: boolean;
childTargetId: string | undefined;
hasUnsavedChanges: boolean;
}
export const initialState: DocumentsState = {
@@ -46,6 +49,9 @@ export const initialState: DocumentsState = {
currentDocument: null,
draftCards: {},
mentionsCache: {},
isCommentSidebarOpen: false,
childTargetId: undefined,
hasUnsavedChanges: false,
};
const documentsSlice = createSlice({
@@ -118,6 +124,15 @@ const documentsSlice = createSlice({
) => {
state.mentionsCache[getMentionsCacheKey(payload)] = payload;
},
setIsCommentSidebarOpen: (state, action: PayloadAction<boolean>) => {
state.isCommentSidebarOpen = action.payload;
},
setChildTargetId: (state, action: PayloadAction<string | undefined>) => {
state.childTargetId = action.payload;
},
setHasUnsavedChanges: (state, action: PayloadAction<boolean>) => {
state.hasUnsavedChanges = action.payload;
},
},
});
@@ -132,6 +147,9 @@ export const {
createDraftCard,
clearDraftCards,
updateMentionsCache,
setIsCommentSidebarOpen,
setChildTargetId,
setHasUnsavedChanges,
} = documentsSlice.actions;
export const generateDraftCardId = (): number => {

View File

@@ -5,7 +5,7 @@ import { useDispatch } from "metabase/lib/redux";
import type { DocumentContent } from "metabase-types/api";
import type { CardEmbedRef } from "../components/Editor/types";
import { setCardEmbeds } from "../documents.slice";
import { setCardEmbeds, setIsCommentSidebarOpen } from "../documents.slice";
export function useDocumentState(documentData?: {
name: string;
@@ -47,11 +47,21 @@ export function useDocumentState(documentData?: {
[dispatch],
);
const openCommentSidebar = useCallback(() => {
dispatch(setIsCommentSidebarOpen(true));
}, [dispatch]);
const closeCommentSidebar = useCallback(() => {
dispatch(setIsCommentSidebarOpen(false));
}, [dispatch]);
return {
documentTitle,
setDocumentTitle,
documentContent,
setDocumentContent,
updateCardEmbeds,
openCommentSidebar,
closeCommentSidebar,
};
}

View File

@@ -1,7 +1,19 @@
import { ModalRoute } from "metabase/hoc/ModalRoute";
import { Route } from "metabase/hoc/Title";
import { CommentsSidesheet } from "./components/CommentsSidesheet";
import { DocumentPage } from "./components/DocumentPage";
export const getRoutes = () => (
<Route path="document/:entityId" component={DocumentPage} />
<Route path="document/:entityId" component={DocumentPage}>
<ModalRoute
path="comments/:childTargetId"
modal={CommentsSidesheet}
noWrap
modalProps={{
enableTransition: false,
closeOnClickOutside: false,
}}
/>
</Route>
);

View File

@@ -22,6 +22,9 @@ export const getSelectedQuestionId = createSelector(
export const getSidebarOpen = (state: DocumentsStoreState) =>
state.plugins?.documents?.selectedEmbedIndex !== null;
export const getCommentSidebarOpen = (state: DocumentsStoreState) =>
state.plugins?.documents?.isCommentSidebarOpen;
export const getCardEmbeds = createSelector(
getDocumentsState,
(documents) => documents?.cardEmbeds ?? [],
@@ -68,3 +71,13 @@ export const getMentionsCache = createSelector(
getDocumentsState,
(documents) => documents.mentionsCache,
);
export const getChildTargetId = createSelector(
getDocumentsState,
(documents) => documents.childTargetId,
);
export const getHasUnsavedChanges = createSelector(
getDocumentsState,
(documents) => documents.hasUnsavedChanges,
);

View File

@@ -0,0 +1,17 @@
import type { SkipToken } from "@reduxjs/toolkit/query";
import { skipToken } from "metabase/api";
import type { Document, ListCommentsRequest } from "metabase-types/api";
export function getListCommentsQuery(
document: Document | null,
): ListCommentsRequest | SkipToken {
if (!document) {
return skipToken;
}
return {
target_type: "document",
target_id: document.id,
};
}

View File

@@ -1,5 +1,5 @@
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import type { Editor } from "@tiptap/react";
import type { Editor, NodeViewProps } from "@tiptap/react";
/**
* Updates a card embed node's ID in the ProseMirror editor
@@ -39,3 +39,21 @@ export function updateCardEmbedNodeId(
editorInstance.view.dispatch(tr);
}
}
export function isTopLevel({
editor,
getPos,
}: Pick<NodeViewProps, "editor" | "getPos">) {
if (!editor || !getPos) {
return true;
}
const pos = getPos();
if (pos === null || pos === undefined) {
return true;
}
const resolvedPos = editor.state.doc.resolve(pos);
return resolvedPos.depth === 0;
}

View File

@@ -0,0 +1,50 @@
import type { DocumentContent } from "./document";
import type { BaseUser } from "./user";
export type CommentId = number;
export type CommentEntityType = "document";
export type EntityId = string | number;
export interface Comment {
id: CommentId;
parent_comment_id: CommentId | null;
target_type: CommentEntityType;
target_id: EntityId;
child_target_id: EntityId | null;
creator: BaseUser;
content: DocumentContent;
is_deleted: boolean;
is_resolved: boolean;
created_at: string;
updated_at: string;
version: number;
}
/** request types below */
export interface ListCommentsRequest {
target_type: CommentEntityType;
target_id: EntityId;
}
export interface CreateCommentRequest {
target_id: EntityId;
target_type: CommentEntityType;
child_target_id: EntityId | null;
parent_comment_id: CommentId | null;
content: DocumentContent;
}
export interface UpdateCommentRequest {
id: CommentId;
content?: DocumentContent;
is_deleted?: boolean;
is_resolved?: boolean;
}

View File

@@ -12,6 +12,7 @@ export * from "./bug-report";
export * from "./card";
export * from "./click-behavior";
export * from "./collection";
export * from "./comments";
export * from "./content-translation";
export * from "./dashboard";
export * from "./database";

View File

@@ -0,0 +1,30 @@
import { getNextId } from "__support__/utils";
import type { Comment } from "../comments";
import { createMockDocumentContent } from "./document";
import { createMockUser } from "./user";
export function createMockComment(comment?: Partial<Comment>): Comment {
return {
...comment,
id: getNextId(),
parent_comment_id: null,
target_type: "document",
target_id: 1,
child_target_id: 1,
creator: createMockUser(),
content: createMockDocumentContent(),
is_deleted: false,
is_resolved: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
version: 1,
};
}

View File

@@ -1,4 +1,5 @@
import type { Document } from "metabase-types/api";
import { uuid } from "metabase/lib/uuid";
import type { Document, DocumentContent } from "metabase-types/api";
import { createMockUser } from "./user";
@@ -7,7 +8,7 @@ export const createMockDocument = (opts?: Partial<Document>): Document => ({
name: "Test Document",
creator: createMockUser(),
creator_id: 1,
document: { type: "doc", content: [] },
document: createMockDocumentContent(),
version: 1,
collection_id: null,
created_at: "2024-01-01T10:30:00Z",
@@ -18,3 +19,21 @@ export const createMockDocument = (opts?: Partial<Document>): Document => ({
can_write: true,
...opts,
});
export const createMockDocumentContent = (
opts?: Partial<DocumentContent>,
): DocumentContent => ({
type: "doc",
content: [],
...opts,
});
export const createMockDocumentContentParagraph = (
text: string,
): DocumentContent => ({
type: "paragraph",
attrs: {
_id: uuid(),
},
content: [{ type: "text", text }],
});

View File

@@ -0,0 +1,18 @@
import { Fragment } from "react";
import { Canvas, Story, Meta } from "@storybook/blocks";
import { Box, Card, Stack, Text } from "metabase/ui";
import * as SpoilerStories from "./Spoiler.stories";
<Meta of={SpoilerStories} />
# Spoiler
Our themed wrapper around [Mantine Spoiler](https://mantine.dev/core/spoiler/).
## Docs
- [Mantine Card Docs](https://mantine.dev/core/spoiler/)
## Examples
<Canvas of={SpoilerStories.Default} />

View File

@@ -0,0 +1,41 @@
import { Spoiler, type SpoilerProps } from "metabase/ui";
import { Box, Text } from "metabase/ui";
const args = {
showLabel: "Show",
hideLabel: "Hide",
maxHeight: 48,
};
const argTypes = {
showLabel: {
control: { type: "text" },
},
hideLabel: {
control: { type: "text" },
},
maxHeight: {
control: { type: "number" },
},
};
export default {
title: "Components/Data display/Spoiler",
component: Spoiler,
args,
argTypes,
};
export const Default = {
render: (args: SpoilerProps) => (
<Box maw="20rem">
<Spoiler {...args}>
<Text>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce ac
ligula sit amet urna placerat placerat. Quisque suscipit rutrum est et
malesuada. Suspendisse potenti. Integer nec tortor tortor.
</Text>
</Spoiler>
</Box>
),
};

View File

@@ -0,0 +1,2 @@
export { Spoiler } from "@mantine/core";
export type { SpoilerProps } from "@mantine/core";

View File

@@ -0,0 +1,12 @@
import { Timeline } from "@mantine/core";
import TimelineStyles from "./Timeline.module.css";
export const timelineOverrides = {
Timeline: Timeline.extend({
classNames: {
item: TimelineStyles.item,
itemBullet: TimelineStyles.itemBullet,
},
}),
};

View File

@@ -0,0 +1,18 @@
import { Fragment } from "react";
import { Canvas, Story, Meta } from "@storybook/blocks";
import { Box, Card, Stack, Text } from "metabase/ui";
import * as TimelineStories from "./Timeline.stories";
<Meta of={TimelineStories} />
# Timeline
Our themed wrapper around [Mantine Timeline](https://mantine.dev/core/timeline/).
## Docs
- [Mantine Card Docs](https://mantine.dev/core/timeline/)
## Examples
<Canvas of={TimelineStories.Default} />

View File

@@ -0,0 +1,19 @@
/**
* Non-active items border color is pretty much hardcoded in Mantine v8.
* Therefore we need to override it for non-active items.
*/
.item {
--item-border-color: var(--mb-color-border);
&[data-line-active] {
--item-border-color: var(--tl-color);
}
}
.itemBullet {
border-color: var(--mb-color-border);
&[data-active] {
border-color: var(--tl-color);
}
}

View File

@@ -0,0 +1,59 @@
import { Timeline, type TimelineProps } from "metabase/ui";
import { Box, Text } from "metabase/ui";
const args = {
active: 1,
align: "left",
bulletSize: 20,
lineWidth: 2,
color: "var(--mb-color-brand)",
};
const argTypes = {
active: {
control: { type: "number" },
},
align: {
options: ["left", "right"],
control: { type: "inline-radio" },
},
bulletSize: {
control: { type: "number" },
},
lineWidth: {
control: { type: "number" },
},
color: {
control: { type: "color" },
},
};
export default {
title: "Components/Data display/Timeline",
component: Timeline,
args,
argTypes,
};
export const Default = {
render: (args: TimelineProps) => (
<Box maw="20rem">
<Timeline {...args}>
<Timeline.Item>
<Text fw="bold">New branch</Text>
<Text>You have created new branch fix-notifications from master</Text>
</Timeline.Item>
<Timeline.Item>
<Text fw="bold">Commits</Text>
<Text>You have pushed 23 commits to fix-notifications branch</Text>
</Timeline.Item>
<Timeline.Item>
<Text fw="bold">Merge</Text>
<Text>
You have merged fix-notifications branch into master branch
</Text>
</Timeline.Item>
</Timeline>
</Box>
),
};

View File

@@ -0,0 +1,3 @@
export { Timeline } from "@mantine/core";
export type { TimelineProps } from "@mantine/core";
export { timelineOverrides } from "./Timeline.config";

View File

@@ -4,3 +4,5 @@ export * from "./Card";
export * from "./Image";
export * from "./Indicator";
export * from "./Kbd";
export * from "./Spoiler";
export * from "./Timeline";

View File

@@ -258,6 +258,8 @@ import mail_component from "./mail.svg?component";
import mail_source from "./mail.svg?source";
import mail_filled_component from "./mail_filled.svg?component";
import mail_filled_source from "./mail_filled.svg?source";
import message_component from "./message.svg?component";
import message_source from "./message.svg?source";
import metabot_component from "./metabot.svg?component";
import metabot_source from "./metabot.svg?source";
import metabot_sad_component from "./metabot_sad.svg?component";
@@ -977,6 +979,10 @@ export const Icons: Record<IconName, { component: React.VFC; source: string }> =
component: mail_filled_component,
source: mail_filled_source,
},
message: {
component: message_component,
source: message_source,
},
metabot: {
component: metabot_component,
source: metabot_source,
@@ -1542,6 +1548,7 @@ export type IconName =
| "lock_filled"
| "mail"
| "mail_filled"
| "message"
| "metabot"
| "metabot_sad"
| "metric"

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 15C21 15.5304 20.7893 16.0391 20.4142 16.4142C20.0391 16.7893 19.5304 17 19 17H7L3 21V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V15Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 427 B

View File

@@ -51,6 +51,7 @@ import {
textOverrides,
textareaOverrides,
timeInputOverrides,
timelineOverrides,
titleOverrides,
tooltipOverrides,
} from "./components";
@@ -179,6 +180,7 @@ export const getThemeOverrides = (): MantineThemeOverride => ({
...textInputOverrides,
...textOverrides,
...timeInputOverrides,
...timelineOverrides,
...titleOverrides,
...tooltipOverrides,
...hoverCardOverrides,

View File

@@ -39,6 +39,7 @@
"@tiptap/core": "^3.2.0",
"@tiptap/extension-bubble-menu": "^3.2.0",
"@tiptap/extension-image": "^3.2.0",
"@tiptap/extension-mention": "^3.3.0",
"@tiptap/extension-placeholder": "^3.2.0",
"@tiptap/pm": "^3.2.0",
"@tiptap/react": "^3.2.0",

View File

@@ -1083,6 +1083,225 @@ databaseChangeLog:
- customChange:
class: "metabase.app_db.custom_migrations.MigrateClickHouseDetailsToMultiDB"
- changeSet:
id: v57.2025-08-24T10:00:00
author: kulyk
comment: Create comment table for documents feature
preConditions:
- onFail: MARK_RAN
- not:
- tableExists:
tableName: comment
changes:
- createTable:
tableName: comment
remarks: Comments on various entities (documents, dashboards, etc.)
columns:
- column:
name: id
type: int
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: parent_comment_id
type: int
remarks: ID of the parent comment for threading (null for root comments)
constraints:
nullable: true
- column:
name: target_type
type: varchar(50)
remarks: Type of entity being commented on
defaultValue: "document"
constraints:
nullable: false
- column:
name: target_id
type: int
remarks: ID of the entity being commented on
constraints:
nullable: false
- column:
name: child_target_id
type: ${text.type}
remarks: Sub-entity ID (e.g., document block node ID)
constraints:
nullable: true
- column:
name: creator_id
type: int
remarks: User who created this comment
constraints:
nullable: false
- column:
name: content
type: ${text.type}
remarks: The comment content
constraints:
nullable: false
- column:
name: is_deleted
type: ${boolean.type}
remarks: Soft delete flag
defaultValueBoolean: false
constraints:
nullable: false
- column:
name: is_resolved
type: ${boolean.type}
remarks: Whether this comment thread is resolved
defaultValueBoolean: false
constraints:
nullable: false
- column:
name: created_at
type: ${timestamp_type}
defaultValueComputed: current_timestamp
constraints:
nullable: false
- column:
name: updated_at
type: ${timestamp_type}
defaultValueComputed: current_timestamp
constraints:
nullable: false
- column:
name: entity_id
type: char(21)
remarks: Random NanoID tag for unique identity
constraints:
nullable: true
unique: true
- addForeignKeyConstraint:
baseTableName: comment
baseColumnNames: creator_id
referencedTableName: core_user
referencedColumnNames: id
constraintName: fk_comment_creator_id
onDelete: CASCADE
- addForeignKeyConstraint:
baseTableName: comment
baseColumnNames: parent_comment_id
referencedTableName: comment
referencedColumnNames: id
constraintName: fk_comment_parent_comment_id
onDelete: CASCADE
- createIndex:
tableName: comment
indexName: idx_comment_entity
columns:
- column:
name: target_type
- column:
name: target_id
- createIndex:
tableName: comment
indexName: idx_comment_creator_id
columns:
- column:
name: creator_id
- createIndex:
tableName: comment
indexName: idx_comment_parent_comment_id
columns:
- column:
name: parent_comment_id
- changeSet:
id: v57.2025-08-24T10:01:00
author: anton
comment: Convert comment content from text to JSON format
changes:
- sql:
sql: >-
UPDATE comment
SET content = CONCAT('{"text":"', REPLACE(REPLACE(IFNULL(content, ''), '"', '\\"'), CHAR(10), '\\n'), '"}')
WHERE content IS NOT NULL;
dbms: "mysql"
- sql:
sql: >-
UPDATE comment
SET content = '{"text":"' || REPLACE(REPLACE(COALESCE(content, ''), '"', '\"'), CHR(10), '\n') || '"}'
WHERE content IS NOT NULL;
dbms: "postgresql"
- sql:
sql: >-
UPDATE comment
SET content = '{"text":"' || REPLACE(REPLACE(IFNULL(content, ''), '"', '""'), CHAR(10), '\n') || '"}'
WHERE content IS NOT NULL;
dbms: "h2"
- changeSet:
id: v57.2025-09-01T17:15:00
author: anton
comment: Create comment_reaction table for comment reactions feature
preConditions:
- onFail: MARK_RAN
- not:
- tableExists:
tableName: comment_reaction
changes:
- createTable:
tableName: comment_reaction
remarks: Store reactions (emojis) on comments
columns:
- column:
name: id
type: int
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: comment_id
type: int
remarks: ID of the comment being reacted to
constraints:
nullable: false
- column:
name: user_id
type: int
remarks: User who added this reaction
constraints:
nullable: false
- column:
name: emoji
type: varchar(10)
remarks: Unicode emoji (supports compound emojis)
constraints:
nullable: false
- column:
name: created_at
type: ${timestamp_type}
defaultValueComputed: current_timestamp
constraints:
nullable: false
- addForeignKeyConstraint:
baseTableName: comment_reaction
baseColumnNames: comment_id
referencedTableName: comment
referencedColumnNames: id
constraintName: fk_comment_reaction_comment_id
onDelete: CASCADE
- addForeignKeyConstraint:
baseTableName: comment_reaction
baseColumnNames: user_id
referencedTableName: core_user
referencedColumnNames: id
constraintName: fk_comment_reaction_user_id
onDelete: CASCADE
- addUniqueConstraint:
tableName: comment_reaction
columnNames: comment_id, user_id
constraintName: unique_user_comment_reaction
- createIndex:
tableName: comment_reaction
indexName: idx_comment_reaction_comment_id
columns:
- column:
name: comment_id
# >>>>>>>>>> DO NOT ADD NEW MIGRATIONS BELOW THIS LINE! ADD THEM ABOVE <<<<<<<<<<
########################################################################################################################

View File

@@ -236,11 +236,12 @@
[:query {:optional true} [:maybe :string]]
[:group_id {:optional true} [:maybe ms/PositiveInt]]
[:include_deactivated {:default false} [:maybe ms/BooleanValue]]]]
(or
api/*is-superuser?*
(if group_id
(perms/check-manager-of-group group_id)
(perms/check-group-manager)))
;; Disabled for comments @mention suggestions prototype, we'll need to figure out a proper way to do this
;; (or
;; api/*is-superuser?*
;; (if group_id
;; (perms/check-manager-of-group group_id)
;; (perms/check-group-manager)))
(let [include_deactivated include_deactivated
group-id-clause (when group_id [group_id])
clauses (user-clauses status query group-id-clause include_deactivated)]

View File

@@ -4144,6 +4144,11 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-list/-/extension-list-3.2.0.tgz#cd9251891120a0213f69edfb95fa3b2fbd1aa494"
integrity sha512-/3ZL9MpgDnEOv8tPwFwW3ct/TBbxZg3r3rN9noLHOR+YgiZKwbpMt9pDSJGu8y++rzT3vIHVmRRp0xFOXBvptQ==
"@tiptap/extension-mention@^3.3.0":
version "3.3.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-mention/-/extension-mention-3.3.0.tgz#737c8d4a2950d9c76bd9590ce56829017fad941c"
integrity sha512-hi+VlerHqev3FoYlHRLaG9frm9wk0lWGVfO9wzMDIvJ+EAn+TqUEYWBXZ17upnJglQusSVYwnyni4ka4FBidIQ==
"@tiptap/extension-ordered-list@^3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-3.2.0.tgz#9d8e466154f42b27fa71afc4561cf73f38c4c930"