Compare commits
132 Commits
python-run
...
document-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d57464389a | ||
|
|
2674295fc2 | ||
|
|
e11f8ef516 | ||
|
|
cf1407a380 | ||
|
|
6e617c2e17 | ||
|
|
74b4a33b57 | ||
|
|
9277413dfa | ||
|
|
d36766decc | ||
|
|
783b361cf6 | ||
|
|
92b7e56853 | ||
|
|
7c72e8a096 | ||
|
|
7f7f505629 | ||
|
|
97496b814c | ||
|
|
29c84db68e | ||
|
|
7c3b16f768 | ||
|
|
3411eb0727 | ||
|
|
2279d9258d | ||
|
|
5a5b3e60ae | ||
|
|
21c3fa2db8 | ||
|
|
96bb488dee | ||
|
|
6fb4b8232e | ||
|
|
1159fa92c8 | ||
|
|
d850f60187 | ||
|
|
ce08b86de1 | ||
|
|
8a0cd6771d | ||
|
|
9d83e67d69 | ||
|
|
adf1523ef2 | ||
|
|
15340da18d | ||
|
|
d4e6ff9bcd | ||
|
|
4664d5a19a | ||
|
|
d59245492f | ||
|
|
145d4d2911 | ||
|
|
67542849a8 | ||
|
|
46cf040d96 | ||
|
|
9c0daa39f9 | ||
|
|
0f2b8a4e3f | ||
|
|
fe2c3419b6 | ||
|
|
a4fac1699c | ||
|
|
3bb90b3182 | ||
|
|
3a04219e62 | ||
|
|
e4350ac32c | ||
|
|
8607fdd2ea | ||
|
|
2eed3b4f9f | ||
|
|
9bf63b1a74 | ||
|
|
6ffbb5cabf | ||
|
|
f165333b11 | ||
|
|
c0c3704574 | ||
|
|
dd654d4bb7 | ||
|
|
925cf2c188 | ||
|
|
f162dd29ac | ||
|
|
555fe79f41 | ||
|
|
5e9e0c5eb3 | ||
|
|
5a44ccec2d | ||
|
|
af0d5e48e9 | ||
|
|
8e2afe3673 | ||
|
|
fb977619fe | ||
|
|
9029d61a48 | ||
|
|
3ff896ae13 | ||
|
|
ec5fe9180d | ||
|
|
0190353203 | ||
|
|
f4621294dd | ||
|
|
e5c5d49bba | ||
|
|
b5c10b6a88 | ||
|
|
1bb049ea6a | ||
|
|
e655cffc6c | ||
|
|
d05ea6feff | ||
|
|
cce46bd803 | ||
|
|
5a7eca960c | ||
|
|
4678d6130f | ||
|
|
1f9aade4fa | ||
|
|
34818ea2dc | ||
|
|
3c9f24969f | ||
|
|
fa90522058 | ||
|
|
6c261b9b7f | ||
|
|
7642ba7f2c | ||
|
|
366626a8d0 | ||
|
|
0f92b2c43a | ||
|
|
9620f6c43d | ||
|
|
e2152f6897 | ||
|
|
9ca9c49ef1 | ||
|
|
1f0c6095ed | ||
|
|
f2ac627e4b | ||
|
|
2055c80e79 | ||
|
|
7a0a795ee3 | ||
|
|
9d4e431c26 | ||
|
|
2ba4b3f0c0 | ||
|
|
7491705b76 | ||
|
|
ee608a26e6 | ||
|
|
bc2884322b | ||
|
|
76666fb4d9 | ||
|
|
3e91c101de | ||
|
|
5096e06549 | ||
|
|
a7ba10c7ab | ||
|
|
db8f7001b0 | ||
|
|
e426eae847 | ||
|
|
084113dd5f | ||
|
|
0d3084c3ef | ||
|
|
edfe1b1da4 | ||
|
|
d54174dc2c | ||
|
|
bf50302ea9 | ||
|
|
6d5cd89e68 | ||
|
|
54e075d2de | ||
|
|
b2c982d306 | ||
|
|
e85e11abac | ||
|
|
0c76e419d2 | ||
|
|
ed428209ac | ||
|
|
8902d7744e | ||
|
|
e1ea632e83 | ||
|
|
1cefdeeaff | ||
|
|
5912f09719 | ||
|
|
22ec7c1576 | ||
|
|
f9c3551ce0 | ||
|
|
7c9ad0ea75 | ||
|
|
a668557fa7 | ||
|
|
cd4eab939f | ||
|
|
4528b2faf2 | ||
|
|
4cd8c87b05 | ||
|
|
2324a5674b | ||
|
|
0669dbc8cf | ||
|
|
021ccaea75 | ||
|
|
21ff2b47dd | ||
|
|
77f59a06c7 | ||
|
|
d3d2c902cc | ||
|
|
048ea6a2bf | ||
|
|
d3a4f3baf0 | ||
|
|
36d783047b | ||
|
|
a8beeef2f5 | ||
|
|
47742c6bef | ||
|
|
2415187611 | ||
|
|
e6f2c5d042 | ||
|
|
c14c60fd05 | ||
|
|
a70de7f860 |
@@ -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)
|
||||
|
||||
10
enterprise/backend/src/metabase_enterprise/comments/api.clj
Normal file
10
enterprise/backend/src/metabase_enterprise/comments/api.clj
Normal 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))
|
||||
@@ -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))
|
||||
@@ -0,0 +1,3 @@
|
||||
(ns metabase-enterprise.comments.init
|
||||
(:require
|
||||
[metabase-enterprise.comments.models.comment]))
|
||||
@@ -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 {}}))
|
||||
@@ -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)})))])))))
|
||||
73
enterprise/frontend/src/metabase-enterprise/api/comment.ts
Normal file
73
enterprise/frontend/src/metabase-enterprise/api/comment.ts
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./CommentEditor";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Discussion";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Discussions";
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./CommentEditor";
|
||||
export * from "./Discussion";
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CustomMentionExtension } from "./CustomMentionExtension";
|
||||
export type { CustomMentionAttributes } from "./CustomMentionExtension";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Comment, CommentId } from "metabase-types/api";
|
||||
|
||||
export interface CommentThread {
|
||||
id: CommentId;
|
||||
comments: Comment[];
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./CommentsSidesheet";
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.commentsMenu {
|
||||
opacity: 0;
|
||||
user-select: none;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./CommentsMenu";
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Blockquote";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./BulletList";
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./CodeBlock";
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Heading";
|
||||
@@ -0,0 +1 @@
|
||||
export const ID_ATTRIBUTE_NAME = "_id";
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./constants";
|
||||
export * from "./utils";
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./OrderedList";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Paragraph";
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
50
frontend/src/metabase-types/api/comments.ts
Normal file
50
frontend/src/metabase-types/api/comments.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
30
frontend/src/metabase-types/api/mocks/comment.ts
Normal file
30
frontend/src/metabase-types/api/mocks/comment.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 }],
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Spoiler } from "@mantine/core";
|
||||
export type { SpoilerProps } from "@mantine/core";
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -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} />
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { Timeline } from "@mantine/core";
|
||||
export type { TimelineProps } from "@mantine/core";
|
||||
export { timelineOverrides } from "./Timeline.config";
|
||||
@@ -4,3 +4,5 @@ export * from "./Card";
|
||||
export * from "./Image";
|
||||
export * from "./Indicator";
|
||||
export * from "./Kbd";
|
||||
export * from "./Spoiler";
|
||||
export * from "./Timeline";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 |
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <<<<<<<<<<
|
||||
|
||||
########################################################################################################################
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user