mirror of https://github.com/grafana/grafana
Comments: support live comments in dashboards and annotations (#44980)
parent
67c1a359d1
commit
28c30a34ad
@ -0,0 +1,49 @@ |
|||||||
|
package api |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
"net/http" |
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/api/response" |
||||||
|
"github.com/grafana/grafana/pkg/models" |
||||||
|
"github.com/grafana/grafana/pkg/services/comments" |
||||||
|
"github.com/grafana/grafana/pkg/util" |
||||||
|
"github.com/grafana/grafana/pkg/web" |
||||||
|
) |
||||||
|
|
||||||
|
func (hs *HTTPServer) commentsGet(c *models.ReqContext) response.Response { |
||||||
|
cmd := comments.GetCmd{} |
||||||
|
if err := web.Bind(c.Req, &cmd); err != nil { |
||||||
|
return response.Error(http.StatusBadRequest, "bad request data", err) |
||||||
|
} |
||||||
|
items, err := hs.commentsService.Get(c.Req.Context(), c.OrgId, c.SignedInUser, cmd) |
||||||
|
if err != nil { |
||||||
|
if errors.Is(err, comments.ErrPermissionDenied) { |
||||||
|
return response.Error(http.StatusForbidden, "permission denied", err) |
||||||
|
} |
||||||
|
return response.Error(http.StatusInternalServerError, "internal error", err) |
||||||
|
} |
||||||
|
return response.JSON(200, util.DynMap{ |
||||||
|
"comments": items, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
func (hs *HTTPServer) commentsCreate(c *models.ReqContext) response.Response { |
||||||
|
cmd := comments.CreateCmd{} |
||||||
|
if err := web.Bind(c.Req, &cmd); err != nil { |
||||||
|
return response.Error(http.StatusBadRequest, "bad request data", err) |
||||||
|
} |
||||||
|
if c.SignedInUser.UserId == 0 && !c.SignedInUser.HasRole(models.ROLE_ADMIN) { |
||||||
|
return response.Error(http.StatusForbidden, "admin role required", nil) |
||||||
|
} |
||||||
|
comment, err := hs.commentsService.Create(c.Req.Context(), c.OrgId, c.SignedInUser, cmd) |
||||||
|
if err != nil { |
||||||
|
if errors.Is(err, comments.ErrPermissionDenied) { |
||||||
|
return response.Error(http.StatusForbidden, "permission denied", err) |
||||||
|
} |
||||||
|
return response.Error(http.StatusInternalServerError, "internal error", err) |
||||||
|
} |
||||||
|
return response.JSON(200, util.DynMap{ |
||||||
|
"comment": comment, |
||||||
|
}) |
||||||
|
} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
package commentmodel |
||||||
|
|
||||||
|
type EventType string |
||||||
|
|
||||||
|
const ( |
||||||
|
EventCommentCreated EventType = "commentCreated" |
||||||
|
) |
||||||
|
|
||||||
|
// Event represents comment event structure.
|
||||||
|
type Event struct { |
||||||
|
Event EventType `json:"event"` |
||||||
|
CommentCreated *CommentDto `json:"commentCreated"` |
||||||
|
} |
||||||
@ -0,0 +1,105 @@ |
|||||||
|
package commentmodel |
||||||
|
|
||||||
|
import ( |
||||||
|
"database/sql" |
||||||
|
"database/sql/driver" |
||||||
|
"encoding/json" |
||||||
|
"fmt" |
||||||
|
) |
||||||
|
|
||||||
|
const ( |
||||||
|
// ObjectTypeOrg is reserved for future use for per-org comments.
|
||||||
|
ObjectTypeOrg = "org" |
||||||
|
// ObjectTypeDashboard used for dashboard-wide comments.
|
||||||
|
ObjectTypeDashboard = "dashboard" |
||||||
|
// ObjectTypeAnnotation used for annotation comments.
|
||||||
|
ObjectTypeAnnotation = "annotation" |
||||||
|
) |
||||||
|
|
||||||
|
var RegisteredObjectTypes = map[string]struct{}{ |
||||||
|
ObjectTypeOrg: {}, |
||||||
|
ObjectTypeDashboard: {}, |
||||||
|
ObjectTypeAnnotation: {}, |
||||||
|
} |
||||||
|
|
||||||
|
type CommentGroup struct { |
||||||
|
Id int64 |
||||||
|
OrgId int64 |
||||||
|
ObjectType string |
||||||
|
ObjectId string |
||||||
|
Settings Settings |
||||||
|
|
||||||
|
Created int64 |
||||||
|
Updated int64 |
||||||
|
} |
||||||
|
|
||||||
|
func (i CommentGroup) TableName() string { |
||||||
|
return "comment_group" |
||||||
|
} |
||||||
|
|
||||||
|
type Settings struct { |
||||||
|
} |
||||||
|
|
||||||
|
var ( |
||||||
|
_ driver.Valuer = Settings{} |
||||||
|
_ sql.Scanner = &Settings{} |
||||||
|
) |
||||||
|
|
||||||
|
func (s Settings) Value() (driver.Value, error) { |
||||||
|
d, err := json.Marshal(s) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
return string(d), nil |
||||||
|
} |
||||||
|
|
||||||
|
func (s *Settings) Scan(value interface{}) error { |
||||||
|
switch v := value.(type) { |
||||||
|
case string: |
||||||
|
return json.Unmarshal([]byte(v), &s) |
||||||
|
case []uint8: |
||||||
|
return json.Unmarshal(v, &s) |
||||||
|
default: |
||||||
|
return fmt.Errorf("type assertion on scan failed: got %T", value) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
type Comment struct { |
||||||
|
Id int64 |
||||||
|
GroupId int64 |
||||||
|
UserId int64 |
||||||
|
Content string |
||||||
|
|
||||||
|
Created int64 |
||||||
|
Updated int64 |
||||||
|
} |
||||||
|
|
||||||
|
type CommentUser struct { |
||||||
|
Id int64 `json:"id"` |
||||||
|
Name string `json:"name"` |
||||||
|
Login string `json:"login"` |
||||||
|
Email string `json:"email"` |
||||||
|
AvatarUrl string `json:"avatarUrl"` |
||||||
|
} |
||||||
|
|
||||||
|
type CommentDto struct { |
||||||
|
Id int64 `json:"id"` |
||||||
|
UserId int64 `json:"userId"` |
||||||
|
Content string `json:"content"` |
||||||
|
Created int64 `json:"created"` |
||||||
|
User *CommentUser `json:"user,omitempty"` |
||||||
|
} |
||||||
|
|
||||||
|
func (i Comment) ToDTO(user *CommentUser) *CommentDto { |
||||||
|
return &CommentDto{ |
||||||
|
Id: i.Id, |
||||||
|
UserId: i.UserId, |
||||||
|
Content: i.Content, |
||||||
|
Created: i.Created, |
||||||
|
User: user, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func (i Comment) TableName() string { |
||||||
|
return "comment" |
||||||
|
} |
||||||
@ -0,0 +1,132 @@ |
|||||||
|
package commentmodel |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"strconv" |
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/services/sqlstore" |
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/models" |
||||||
|
"github.com/grafana/grafana/pkg/services/annotations" |
||||||
|
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||||
|
"github.com/grafana/grafana/pkg/services/guardian" |
||||||
|
) |
||||||
|
|
||||||
|
type PermissionChecker struct { |
||||||
|
sqlStore *sqlstore.SQLStore |
||||||
|
features featuremgmt.FeatureToggles |
||||||
|
} |
||||||
|
|
||||||
|
func NewPermissionChecker(sqlStore *sqlstore.SQLStore, features featuremgmt.FeatureToggles) *PermissionChecker { |
||||||
|
return &PermissionChecker{sqlStore: sqlStore, features: features} |
||||||
|
} |
||||||
|
|
||||||
|
func (c *PermissionChecker) getDashboardByUid(ctx context.Context, orgID int64, uid string) (*models.Dashboard, error) { |
||||||
|
query := models.GetDashboardQuery{Uid: uid, OrgId: orgID} |
||||||
|
if err := c.sqlStore.GetDashboard(ctx, &query); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
return query.Result, nil |
||||||
|
} |
||||||
|
|
||||||
|
func (c *PermissionChecker) getDashboardById(ctx context.Context, orgID int64, id int64) (*models.Dashboard, error) { |
||||||
|
query := models.GetDashboardQuery{Id: id, OrgId: orgID} |
||||||
|
if err := c.sqlStore.GetDashboard(ctx, &query); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
return query.Result, nil |
||||||
|
} |
||||||
|
|
||||||
|
func (c *PermissionChecker) CheckReadPermissions(ctx context.Context, orgId int64, signedInUser *models.SignedInUser, objectType string, objectID string) (bool, error) { |
||||||
|
switch objectType { |
||||||
|
case ObjectTypeOrg: |
||||||
|
return false, nil |
||||||
|
case ObjectTypeDashboard: |
||||||
|
if !c.features.IsEnabled(featuremgmt.FlagDashboardComments) { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
dash, err := c.getDashboardByUid(ctx, orgId, objectID) |
||||||
|
if err != nil { |
||||||
|
return false, err |
||||||
|
} |
||||||
|
guard := guardian.New(ctx, dash.Id, orgId, signedInUser) |
||||||
|
if ok, err := guard.CanView(); err != nil || !ok { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
case ObjectTypeAnnotation: |
||||||
|
if !c.features.IsEnabled(featuremgmt.FlagAnnotationComments) { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
repo := annotations.GetRepository() |
||||||
|
annotationID, err := strconv.ParseInt(objectID, 10, 64) |
||||||
|
if err != nil { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
items, err := repo.Find(&annotations.ItemQuery{AnnotationId: annotationID, OrgId: orgId}) |
||||||
|
if err != nil || len(items) != 1 { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
dashboardID := items[0].DashboardId |
||||||
|
if dashboardID == 0 { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
dash, err := c.getDashboardById(ctx, orgId, dashboardID) |
||||||
|
if err != nil { |
||||||
|
return false, err |
||||||
|
} |
||||||
|
guard := guardian.New(ctx, dash.Id, orgId, signedInUser) |
||||||
|
if ok, err := guard.CanView(); err != nil || !ok { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
default: |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
return true, nil |
||||||
|
} |
||||||
|
|
||||||
|
func (c *PermissionChecker) CheckWritePermissions(ctx context.Context, orgId int64, signedInUser *models.SignedInUser, objectType string, objectID string) (bool, error) { |
||||||
|
switch objectType { |
||||||
|
case ObjectTypeOrg: |
||||||
|
return false, nil |
||||||
|
case ObjectTypeDashboard: |
||||||
|
if !c.features.IsEnabled(featuremgmt.FlagDashboardComments) { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
dash, err := c.getDashboardByUid(ctx, orgId, objectID) |
||||||
|
if err != nil { |
||||||
|
return false, err |
||||||
|
} |
||||||
|
guard := guardian.New(ctx, dash.Id, orgId, signedInUser) |
||||||
|
if ok, err := guard.CanEdit(); err != nil || !ok { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
case ObjectTypeAnnotation: |
||||||
|
if !c.features.IsEnabled(featuremgmt.FlagAnnotationComments) { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
repo := annotations.GetRepository() |
||||||
|
annotationID, err := strconv.ParseInt(objectID, 10, 64) |
||||||
|
if err != nil { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
items, err := repo.Find(&annotations.ItemQuery{AnnotationId: annotationID, OrgId: orgId}) |
||||||
|
if err != nil || len(items) != 1 { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
dashboardID := items[0].DashboardId |
||||||
|
if dashboardID == 0 { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
dash, err := c.getDashboardById(ctx, orgId, dashboardID) |
||||||
|
if err != nil { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
guard := guardian.New(ctx, dash.Id, orgId, signedInUser) |
||||||
|
if ok, err := guard.CanEdit(); err != nil || !ok { |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
default: |
||||||
|
return false, nil |
||||||
|
} |
||||||
|
return true, nil |
||||||
|
} |
||||||
@ -0,0 +1,164 @@ |
|||||||
|
package comments |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"encoding/json" |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
"sort" |
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/api/dtos" |
||||||
|
"github.com/grafana/grafana/pkg/models" |
||||||
|
"github.com/grafana/grafana/pkg/services/comments/commentmodel" |
||||||
|
) |
||||||
|
|
||||||
|
func commentsToDto(items []*commentmodel.Comment, userMap map[int64]*commentmodel.CommentUser) []*commentmodel.CommentDto { |
||||||
|
result := make([]*commentmodel.CommentDto, 0, len(items)) |
||||||
|
for _, m := range items { |
||||||
|
result = append(result, commentToDto(m, userMap)) |
||||||
|
} |
||||||
|
return result |
||||||
|
} |
||||||
|
|
||||||
|
func commentToDto(comment *commentmodel.Comment, userMap map[int64]*commentmodel.CommentUser) *commentmodel.CommentDto { |
||||||
|
var u *commentmodel.CommentUser |
||||||
|
if comment.UserId > 0 { |
||||||
|
var ok bool |
||||||
|
u, ok = userMap[comment.UserId] |
||||||
|
if !ok { |
||||||
|
// TODO: handle this gracefully?
|
||||||
|
u = &commentmodel.CommentUser{ |
||||||
|
Id: comment.UserId, |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return comment.ToDTO(u) |
||||||
|
} |
||||||
|
|
||||||
|
func searchUserToCommentUser(searchUser *models.UserSearchHitDTO) *commentmodel.CommentUser { |
||||||
|
if searchUser == nil { |
||||||
|
return nil |
||||||
|
} |
||||||
|
return &commentmodel.CommentUser{ |
||||||
|
Id: searchUser.Id, |
||||||
|
Name: searchUser.Name, |
||||||
|
Login: searchUser.Login, |
||||||
|
Email: searchUser.Email, |
||||||
|
AvatarUrl: dtos.GetGravatarUrl(searchUser.Email), |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
type UserIDFilter struct { |
||||||
|
userIDs []int64 |
||||||
|
} |
||||||
|
|
||||||
|
func NewIDFilter(userIDs []int64) models.Filter { |
||||||
|
return &UserIDFilter{userIDs: userIDs} |
||||||
|
} |
||||||
|
|
||||||
|
func (a *UserIDFilter) WhereCondition() *models.WhereCondition { |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
func (a *UserIDFilter) JoinCondition() *models.JoinCondition { |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
func (a *UserIDFilter) InCondition() *models.InCondition { |
||||||
|
return &models.InCondition{ |
||||||
|
Condition: "u.id", |
||||||
|
Params: a.userIDs, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
type GetCmd struct { |
||||||
|
ObjectType string `json:"objectType"` |
||||||
|
ObjectID string `json:"objectId"` |
||||||
|
Limit uint `json:"limit"` |
||||||
|
BeforeId int64 `json:"beforeId"` |
||||||
|
} |
||||||
|
|
||||||
|
type CreateCmd struct { |
||||||
|
ObjectType string `json:"objectType"` |
||||||
|
ObjectID string `json:"objectId"` |
||||||
|
Content string `json:"content"` |
||||||
|
} |
||||||
|
|
||||||
|
var ErrPermissionDenied = errors.New("permission denied") |
||||||
|
|
||||||
|
func (s *Service) Create(ctx context.Context, orgID int64, signedInUser *models.SignedInUser, cmd CreateCmd) (*commentmodel.CommentDto, error) { |
||||||
|
ok, err := s.permissions.CheckWritePermissions(ctx, orgID, signedInUser, cmd.ObjectType, cmd.ObjectID) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
if !ok { |
||||||
|
return nil, ErrPermissionDenied |
||||||
|
} |
||||||
|
|
||||||
|
userMap := make(map[int64]*commentmodel.CommentUser, 1) |
||||||
|
if signedInUser.UserId > 0 { |
||||||
|
userMap[signedInUser.UserId] = &commentmodel.CommentUser{ |
||||||
|
Id: signedInUser.UserId, |
||||||
|
Name: signedInUser.Name, |
||||||
|
Login: signedInUser.Login, |
||||||
|
Email: signedInUser.Email, |
||||||
|
AvatarUrl: dtos.GetGravatarUrl(signedInUser.Email), |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
m, err := s.storage.Create(ctx, orgID, cmd.ObjectType, cmd.ObjectID, signedInUser.UserId, cmd.Content) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
mDto := commentToDto(m, userMap) |
||||||
|
e := commentmodel.Event{ |
||||||
|
Event: commentmodel.EventCommentCreated, |
||||||
|
CommentCreated: mDto, |
||||||
|
} |
||||||
|
eventJSON, _ := json.Marshal(e) |
||||||
|
_ = s.live.Publish(orgID, fmt.Sprintf("grafana/comment/%s/%s", cmd.ObjectType, cmd.ObjectID), eventJSON) |
||||||
|
return mDto, nil |
||||||
|
} |
||||||
|
|
||||||
|
func (s *Service) Get(ctx context.Context, orgID int64, signedInUser *models.SignedInUser, cmd GetCmd) ([]*commentmodel.CommentDto, error) { |
||||||
|
ok, err := s.permissions.CheckReadPermissions(ctx, orgID, signedInUser, cmd.ObjectType, cmd.ObjectID) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
if !ok { |
||||||
|
return nil, ErrPermissionDenied |
||||||
|
} |
||||||
|
|
||||||
|
messages, err := s.storage.Get(ctx, orgID, cmd.ObjectType, cmd.ObjectID, GetFilter{ |
||||||
|
Limit: cmd.Limit, |
||||||
|
BeforeID: cmd.BeforeId, |
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
userIds := make([]int64, 0, len(messages)) |
||||||
|
for _, m := range messages { |
||||||
|
if m.UserId <= 0 { |
||||||
|
continue |
||||||
|
} |
||||||
|
userIds = append(userIds, m.UserId) |
||||||
|
} |
||||||
|
|
||||||
|
// NOTE: probably replace with comment and user table join.
|
||||||
|
query := &models.SearchUsersQuery{Query: "", Filters: []models.Filter{NewIDFilter(userIds)}, Page: 0, Limit: len(userIds)} |
||||||
|
if err := s.sqlStore.SearchUsers(ctx, query); err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
|
||||||
|
userMap := make(map[int64]*commentmodel.CommentUser, len(query.Result.Users)) |
||||||
|
for _, v := range query.Result.Users { |
||||||
|
userMap[v.Id] = searchUserToCommentUser(v) |
||||||
|
} |
||||||
|
|
||||||
|
result := commentsToDto(messages, userMap) |
||||||
|
sort.Slice(result, func(i, j int) bool { |
||||||
|
return result[i].Id < result[j].Id |
||||||
|
}) |
||||||
|
return result, nil |
||||||
|
} |
||||||
@ -0,0 +1,38 @@ |
|||||||
|
package comments |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/services/comments/commentmodel" |
||||||
|
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||||
|
"github.com/grafana/grafana/pkg/services/live" |
||||||
|
"github.com/grafana/grafana/pkg/services/sqlstore" |
||||||
|
"github.com/grafana/grafana/pkg/setting" |
||||||
|
) |
||||||
|
|
||||||
|
type Service struct { |
||||||
|
cfg *setting.Cfg |
||||||
|
live *live.GrafanaLive |
||||||
|
sqlStore *sqlstore.SQLStore |
||||||
|
storage Storage |
||||||
|
permissions *commentmodel.PermissionChecker |
||||||
|
} |
||||||
|
|
||||||
|
func ProvideService(cfg *setting.Cfg, store *sqlstore.SQLStore, live *live.GrafanaLive, features featuremgmt.FeatureToggles) *Service { |
||||||
|
s := &Service{ |
||||||
|
cfg: cfg, |
||||||
|
live: live, |
||||||
|
sqlStore: store, |
||||||
|
storage: &sqlStorage{ |
||||||
|
sql: store, |
||||||
|
}, |
||||||
|
permissions: commentmodel.NewPermissionChecker(store, features), |
||||||
|
} |
||||||
|
return s |
||||||
|
} |
||||||
|
|
||||||
|
// Run Service.
|
||||||
|
func (s *Service) Run(ctx context.Context) error { |
||||||
|
<-ctx.Done() |
||||||
|
return ctx.Err() |
||||||
|
} |
||||||
@ -0,0 +1,116 @@ |
|||||||
|
package comments |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/services/comments/commentmodel" |
||||||
|
"github.com/grafana/grafana/pkg/services/sqlstore" |
||||||
|
) |
||||||
|
|
||||||
|
type sqlStorage struct { |
||||||
|
sql *sqlstore.SQLStore |
||||||
|
} |
||||||
|
|
||||||
|
func checkObjectType(contentType string) bool { |
||||||
|
_, ok := commentmodel.RegisteredObjectTypes[contentType] |
||||||
|
return ok |
||||||
|
} |
||||||
|
|
||||||
|
func checkObjectID(objectID string) bool { |
||||||
|
return objectID != "" |
||||||
|
} |
||||||
|
|
||||||
|
func (s *sqlStorage) Create(ctx context.Context, orgID int64, objectType string, objectID string, userID int64, content string) (*commentmodel.Comment, error) { |
||||||
|
if !checkObjectType(objectType) { |
||||||
|
return nil, errUnknownObjectType |
||||||
|
} |
||||||
|
if !checkObjectID(objectID) { |
||||||
|
return nil, errEmptyObjectID |
||||||
|
} |
||||||
|
if content == "" { |
||||||
|
return nil, errEmptyContent |
||||||
|
} |
||||||
|
|
||||||
|
var result *commentmodel.Comment |
||||||
|
|
||||||
|
return result, s.sql.WithTransactionalDbSession(ctx, func(dbSession *sqlstore.DBSession) error { |
||||||
|
group := commentmodel.CommentGroup{ |
||||||
|
OrgId: orgID, |
||||||
|
ObjectType: objectType, |
||||||
|
ObjectId: objectID, |
||||||
|
} |
||||||
|
has, err := dbSession.Get(&group) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
nowUnix := time.Now().Unix() |
||||||
|
|
||||||
|
groupID := group.Id |
||||||
|
if !has { |
||||||
|
group.Created = nowUnix |
||||||
|
group.Updated = nowUnix |
||||||
|
group.Settings = commentmodel.Settings{} |
||||||
|
_, err = dbSession.Insert(&group) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
groupID = group.Id |
||||||
|
} |
||||||
|
message := commentmodel.Comment{ |
||||||
|
GroupId: groupID, |
||||||
|
UserId: userID, |
||||||
|
Content: content, |
||||||
|
Created: nowUnix, |
||||||
|
Updated: nowUnix, |
||||||
|
} |
||||||
|
_, err = dbSession.Insert(&message) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
result = &message |
||||||
|
return nil |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const maxLimit = 300 |
||||||
|
|
||||||
|
func (s *sqlStorage) Get(ctx context.Context, orgID int64, objectType string, objectID string, filter GetFilter) ([]*commentmodel.Comment, error) { |
||||||
|
if !checkObjectType(objectType) { |
||||||
|
return nil, errUnknownObjectType |
||||||
|
} |
||||||
|
if !checkObjectID(objectID) { |
||||||
|
return nil, errEmptyObjectID |
||||||
|
} |
||||||
|
|
||||||
|
var result []*commentmodel.Comment |
||||||
|
|
||||||
|
limit := 100 |
||||||
|
if filter.Limit > 0 { |
||||||
|
limit = int(filter.Limit) |
||||||
|
if limit > maxLimit { |
||||||
|
limit = maxLimit |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return result, s.sql.WithTransactionalDbSession(ctx, func(dbSession *sqlstore.DBSession) error { |
||||||
|
group := commentmodel.CommentGroup{ |
||||||
|
OrgId: orgID, |
||||||
|
ObjectType: objectType, |
||||||
|
ObjectId: objectID, |
||||||
|
} |
||||||
|
has, err := dbSession.Get(&group) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
if !has { |
||||||
|
return nil |
||||||
|
} |
||||||
|
clause := dbSession.Where("group_id=?", group.Id) |
||||||
|
if filter.BeforeID > 0 { |
||||||
|
clause.Where("id < ?", filter.BeforeID) |
||||||
|
} |
||||||
|
return clause.OrderBy("id desc").Limit(limit).Find(&result) |
||||||
|
}) |
||||||
|
} |
||||||
@ -0,0 +1,76 @@ |
|||||||
|
package comments |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"strconv" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/services/comments/commentmodel" |
||||||
|
"github.com/grafana/grafana/pkg/services/sqlstore" |
||||||
|
|
||||||
|
"github.com/stretchr/testify/require" |
||||||
|
) |
||||||
|
|
||||||
|
func createSqlStorage(t *testing.T) Storage { |
||||||
|
t.Helper() |
||||||
|
sqlStore := sqlstore.InitTestDB(t) |
||||||
|
return &sqlStorage{ |
||||||
|
sql: sqlStore, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestSqlStorage(t *testing.T) { |
||||||
|
s := createSqlStorage(t) |
||||||
|
ctx := context.Background() |
||||||
|
items, err := s.Get(ctx, 1, commentmodel.ObjectTypeOrg, "2", GetFilter{}) |
||||||
|
require.NoError(t, err) |
||||||
|
require.Len(t, items, 0) |
||||||
|
|
||||||
|
numComments := 10 |
||||||
|
|
||||||
|
for i := 0; i < numComments; i++ { |
||||||
|
comment, err := s.Create(ctx, 1, commentmodel.ObjectTypeOrg, "2", 1, "test"+strconv.Itoa(i)) |
||||||
|
require.NoError(t, err) |
||||||
|
require.NotNil(t, comment) |
||||||
|
require.True(t, comment.Id > 0) |
||||||
|
} |
||||||
|
|
||||||
|
items, err = s.Get(ctx, 1, commentmodel.ObjectTypeOrg, "2", GetFilter{}) |
||||||
|
require.NoError(t, err) |
||||||
|
require.Len(t, items, 10) |
||||||
|
require.Equal(t, "test9", items[0].Content) |
||||||
|
require.Equal(t, "test0", items[9].Content) |
||||||
|
require.Equal(t, int64(1), items[0].UserId) |
||||||
|
require.NotZero(t, items[0].Created) |
||||||
|
require.NotZero(t, items[0].Updated) |
||||||
|
|
||||||
|
// Same object, but another content type.
|
||||||
|
items, err = s.Get(ctx, 1, commentmodel.ObjectTypeDashboard, "2", GetFilter{}) |
||||||
|
require.NoError(t, err) |
||||||
|
require.Len(t, items, 0) |
||||||
|
|
||||||
|
// Now test filtering.
|
||||||
|
items, err = s.Get(ctx, 1, commentmodel.ObjectTypeOrg, "2", GetFilter{ |
||||||
|
Limit: 5, |
||||||
|
}) |
||||||
|
require.NoError(t, err) |
||||||
|
require.Len(t, items, 5) |
||||||
|
require.Equal(t, "test9", items[0].Content) |
||||||
|
require.Equal(t, "test5", items[4].Content) |
||||||
|
|
||||||
|
items, err = s.Get(ctx, 1, commentmodel.ObjectTypeOrg, "2", GetFilter{ |
||||||
|
Limit: 5, |
||||||
|
BeforeID: items[4].Id, |
||||||
|
}) |
||||||
|
require.NoError(t, err) |
||||||
|
require.Len(t, items, 5) |
||||||
|
require.Equal(t, "test4", items[0].Content) |
||||||
|
require.Equal(t, "test0", items[4].Content) |
||||||
|
|
||||||
|
items, err = s.Get(ctx, 1, commentmodel.ObjectTypeOrg, "2", GetFilter{ |
||||||
|
Limit: 5, |
||||||
|
BeforeID: items[4].Id, |
||||||
|
}) |
||||||
|
require.NoError(t, err) |
||||||
|
require.Len(t, items, 0) |
||||||
|
} |
||||||
@ -0,0 +1,24 @@ |
|||||||
|
package comments |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"errors" |
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/services/comments/commentmodel" |
||||||
|
) |
||||||
|
|
||||||
|
type GetFilter struct { |
||||||
|
Limit uint |
||||||
|
BeforeID int64 |
||||||
|
} |
||||||
|
|
||||||
|
var ( |
||||||
|
errUnknownObjectType = errors.New("unknown object type") |
||||||
|
errEmptyObjectID = errors.New("empty object id") |
||||||
|
errEmptyContent = errors.New("empty comment content") |
||||||
|
) |
||||||
|
|
||||||
|
type Storage interface { |
||||||
|
Get(ctx context.Context, orgID int64, objectType string, objectID string, filter GetFilter) ([]*commentmodel.Comment, error) |
||||||
|
Create(ctx context.Context, orgID int64, objectType string, objectID string, userID int64, content string) (*commentmodel.Comment, error) |
||||||
|
} |
||||||
@ -0,0 +1,48 @@ |
|||||||
|
package features |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"strings" |
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/models" |
||||||
|
"github.com/grafana/grafana/pkg/services/comments/commentmodel" |
||||||
|
|
||||||
|
"github.com/grafana/grafana-plugin-sdk-go/backend" |
||||||
|
) |
||||||
|
|
||||||
|
// CommentHandler manages all the `grafana/comment/*` channels.
|
||||||
|
type CommentHandler struct { |
||||||
|
permissionChecker *commentmodel.PermissionChecker |
||||||
|
} |
||||||
|
|
||||||
|
func NewCommentHandler(permissionChecker *commentmodel.PermissionChecker) *CommentHandler { |
||||||
|
return &CommentHandler{permissionChecker: permissionChecker} |
||||||
|
} |
||||||
|
|
||||||
|
// GetHandlerForPath called on init.
|
||||||
|
func (h *CommentHandler) GetHandlerForPath(_ string) (models.ChannelHandler, error) { |
||||||
|
return h, nil // all chats share the same handler
|
||||||
|
} |
||||||
|
|
||||||
|
// OnSubscribe handles subscription to comment group channel.
|
||||||
|
func (h *CommentHandler) OnSubscribe(ctx context.Context, user *models.SignedInUser, e models.SubscribeEvent) (models.SubscribeReply, backend.SubscribeStreamStatus, error) { |
||||||
|
parts := strings.Split(e.Path, "/") |
||||||
|
if len(parts) != 2 { |
||||||
|
return models.SubscribeReply{}, backend.SubscribeStreamStatusNotFound, nil |
||||||
|
} |
||||||
|
objectType := parts[0] |
||||||
|
objectID := parts[1] |
||||||
|
ok, err := h.permissionChecker.CheckReadPermissions(ctx, user.OrgId, user, objectType, objectID) |
||||||
|
if err != nil { |
||||||
|
return models.SubscribeReply{}, 0, err |
||||||
|
} |
||||||
|
if !ok { |
||||||
|
return models.SubscribeReply{}, backend.SubscribeStreamStatusPermissionDenied, nil |
||||||
|
} |
||||||
|
return models.SubscribeReply{}, backend.SubscribeStreamStatusOK, nil |
||||||
|
} |
||||||
|
|
||||||
|
// OnPublish is not used for comments.
|
||||||
|
func (h *CommentHandler) OnPublish(_ context.Context, _ *models.SignedInUser, _ models.PublishEvent) (models.PublishReply, backend.PublishStreamStatus, error) { |
||||||
|
return models.PublishReply{}, backend.PublishStreamStatusPermissionDenied, nil |
||||||
|
} |
||||||
@ -0,0 +1,46 @@ |
|||||||
|
package migrations |
||||||
|
|
||||||
|
import ( |
||||||
|
. "github.com/grafana/grafana/pkg/services/sqlstore/migrator" |
||||||
|
) |
||||||
|
|
||||||
|
func addCommentGroupMigrations(mg *Migrator) { |
||||||
|
commentGroupTable := Table{ |
||||||
|
Name: "comment_group", |
||||||
|
Columns: []*Column{ |
||||||
|
{Name: "id", Type: DB_BigInt, Nullable: false, IsPrimaryKey: true, IsAutoIncrement: true}, |
||||||
|
{Name: "org_id", Type: DB_BigInt, Nullable: false}, |
||||||
|
{Name: "object_type", Type: DB_NVarchar, Length: 10, Nullable: false}, |
||||||
|
{Name: "object_id", Type: DB_NVarchar, Length: 128, Nullable: false}, |
||||||
|
{Name: "settings", Type: DB_MediumText, Nullable: false}, |
||||||
|
{Name: "created", Type: DB_Int, Nullable: false}, |
||||||
|
{Name: "updated", Type: DB_Int, Nullable: false}, |
||||||
|
}, |
||||||
|
Indices: []*Index{ |
||||||
|
{Cols: []string{"org_id", "object_type", "object_id"}, Type: UniqueIndex}, |
||||||
|
}, |
||||||
|
} |
||||||
|
mg.AddMigration("create comment group table", NewAddTableMigration(commentGroupTable)) |
||||||
|
mg.AddMigration("add index comment_group.org_id_object_type_object_id", NewAddIndexMigration(commentGroupTable, commentGroupTable.Indices[0])) |
||||||
|
} |
||||||
|
|
||||||
|
func addCommentMigrations(mg *Migrator) { |
||||||
|
commentTable := Table{ |
||||||
|
Name: "comment", |
||||||
|
Columns: []*Column{ |
||||||
|
{Name: "id", Type: DB_BigInt, Nullable: false, IsPrimaryKey: true, IsAutoIncrement: true}, |
||||||
|
{Name: "group_id", Type: DB_BigInt, Nullable: false}, |
||||||
|
{Name: "user_id", Type: DB_BigInt, Nullable: false}, |
||||||
|
{Name: "content", Type: DB_MediumText, Nullable: false}, |
||||||
|
{Name: "created", Type: DB_Int, Nullable: false}, |
||||||
|
{Name: "updated", Type: DB_Int, Nullable: false}, |
||||||
|
}, |
||||||
|
Indices: []*Index{ |
||||||
|
{Cols: []string{"group_id"}, Type: IndexType}, |
||||||
|
{Cols: []string{"created"}, Type: IndexType}, |
||||||
|
}, |
||||||
|
} |
||||||
|
mg.AddMigration("create comment table", NewAddTableMigration(commentTable)) |
||||||
|
mg.AddMigration("add index comment.group_id", NewAddIndexMigration(commentTable, commentTable.Indices[0])) |
||||||
|
mg.AddMigration("add index comment.created", NewAddIndexMigration(commentTable, commentTable.Indices[1])) |
||||||
|
} |
||||||
@ -0,0 +1,97 @@ |
|||||||
|
import React from 'react'; |
||||||
|
import { GrafanaTheme2, renderMarkdown } from '@grafana/data'; |
||||||
|
import { css } from '@emotion/css'; |
||||||
|
import { useStyles2 } from '@grafana/ui'; |
||||||
|
import DangerouslySetHtmlContent from 'dangerously-set-html-content'; |
||||||
|
|
||||||
|
import { Message } from './types'; |
||||||
|
|
||||||
|
type Props = { |
||||||
|
message: Message; |
||||||
|
}; |
||||||
|
|
||||||
|
export const Comment = ({ message }: Props) => { |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
|
||||||
|
let senderColor = '#34BA18'; |
||||||
|
let senderName = 'System'; |
||||||
|
let avatarUrl = '/public/img/grafana_icon.svg'; |
||||||
|
if (message.userId > 0) { |
||||||
|
senderColor = '#19a2e7'; |
||||||
|
senderName = message.user.login; |
||||||
|
avatarUrl = message.user.avatarUrl; |
||||||
|
} |
||||||
|
const timeColor = '#898989'; |
||||||
|
const timeFormatted = new Date(message.created * 1000).toLocaleTimeString(); |
||||||
|
const markdownContent = renderMarkdown(message.content, { breaks: true }); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={styles.comment}> |
||||||
|
<div className={styles.avatarContainer}> |
||||||
|
<img src={avatarUrl} alt="User avatar" className={styles.avatar} /> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<div> |
||||||
|
<span style={{ color: senderColor }}>{senderName}</span> |
||||||
|
|
||||||
|
<span style={{ color: timeColor }}>{timeFormatted}</span> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<DangerouslySetHtmlContent html={markdownContent} className={styles.content} /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({ |
||||||
|
comment: css` |
||||||
|
margin-bottom: 10px; |
||||||
|
padding-top: 3px; |
||||||
|
padding-bottom: 3px; |
||||||
|
word-break: break-word; |
||||||
|
display: flex; |
||||||
|
flex-direction: row; |
||||||
|
align-items: top; |
||||||
|
|
||||||
|
:hover { |
||||||
|
background-color: #1e1f24; |
||||||
|
} |
||||||
|
|
||||||
|
blockquote { |
||||||
|
padding: 0 0 0 10px; |
||||||
|
margin: 0 0 10px; |
||||||
|
} |
||||||
|
`,
|
||||||
|
avatarContainer: css` |
||||||
|
align-self: left; |
||||||
|
margin-top: 6px; |
||||||
|
margin-right: 10px; |
||||||
|
`,
|
||||||
|
avatar: css` |
||||||
|
width: 30px; |
||||||
|
height: 30px; |
||||||
|
`,
|
||||||
|
content: css` |
||||||
|
display: block; |
||||||
|
overflow: hidden; |
||||||
|
|
||||||
|
p { |
||||||
|
margin: 0; |
||||||
|
padding: 0; |
||||||
|
} |
||||||
|
|
||||||
|
blockquote p { |
||||||
|
font-size: 14px; |
||||||
|
padding-top: 4px; |
||||||
|
} |
||||||
|
|
||||||
|
a { |
||||||
|
color: #43c57e; |
||||||
|
} |
||||||
|
|
||||||
|
a:hover { |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
`,
|
||||||
|
}); |
||||||
@ -0,0 +1,109 @@ |
|||||||
|
import React, { PureComponent } from 'react'; |
||||||
|
import { isLiveChannelMessageEvent, LiveChannelScope } from '@grafana/data'; |
||||||
|
import { getBackendSrv, getGrafanaLiveSrv } from '@grafana/runtime'; |
||||||
|
import { Unsubscribable } from 'rxjs'; |
||||||
|
|
||||||
|
import { CommentView } from './CommentView'; |
||||||
|
import { Message, MessagePacket } from './types'; |
||||||
|
|
||||||
|
export interface Props { |
||||||
|
objectType: string; |
||||||
|
objectId: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface State { |
||||||
|
messages: Message[]; |
||||||
|
value: string; |
||||||
|
} |
||||||
|
|
||||||
|
export class CommentManager extends PureComponent<Props, State> { |
||||||
|
subscription?: Unsubscribable; |
||||||
|
packetCounter = 0; |
||||||
|
|
||||||
|
constructor(props: Props) { |
||||||
|
super(props); |
||||||
|
|
||||||
|
this.state = { |
||||||
|
messages: [], |
||||||
|
value: '', |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
async componentDidMount() { |
||||||
|
const resp = await getBackendSrv().post('/api/comments/get', { |
||||||
|
objectType: this.props.objectType, |
||||||
|
objectId: this.props.objectId, |
||||||
|
}); |
||||||
|
this.packetCounter++; |
||||||
|
this.setState({ |
||||||
|
messages: resp.comments, |
||||||
|
}); |
||||||
|
this.updateSubscription(); |
||||||
|
} |
||||||
|
|
||||||
|
getLiveChannel = () => { |
||||||
|
const live = getGrafanaLiveSrv(); |
||||||
|
if (!live) { |
||||||
|
console.error('Grafana live not running, enable "live" feature toggle'); |
||||||
|
return undefined; |
||||||
|
} |
||||||
|
|
||||||
|
const address = this.getLiveAddress(); |
||||||
|
if (!address) { |
||||||
|
return undefined; |
||||||
|
} |
||||||
|
|
||||||
|
return live.getStream<MessagePacket>(address); |
||||||
|
}; |
||||||
|
|
||||||
|
getLiveAddress = () => { |
||||||
|
return { |
||||||
|
scope: LiveChannelScope.Grafana, |
||||||
|
namespace: 'comment', |
||||||
|
path: `${this.props.objectType}/${this.props.objectId}`, |
||||||
|
}; |
||||||
|
}; |
||||||
|
|
||||||
|
updateSubscription = () => { |
||||||
|
if (this.subscription) { |
||||||
|
this.subscription.unsubscribe(); |
||||||
|
this.subscription = undefined; |
||||||
|
} |
||||||
|
|
||||||
|
const channel = this.getLiveChannel(); |
||||||
|
if (channel) { |
||||||
|
this.subscription = channel.subscribe({ |
||||||
|
next: (msg) => { |
||||||
|
if (isLiveChannelMessageEvent(msg)) { |
||||||
|
const { commentCreated } = msg.message; |
||||||
|
if (commentCreated) { |
||||||
|
this.setState((prevState) => ({ |
||||||
|
messages: [...prevState.messages, commentCreated], |
||||||
|
})); |
||||||
|
this.packetCounter++; |
||||||
|
} |
||||||
|
} |
||||||
|
}, |
||||||
|
}); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
addComment = async (comment: string): Promise<boolean> => { |
||||||
|
const response = await getBackendSrv().post('/api/comments/create', { |
||||||
|
objectType: this.props.objectType, |
||||||
|
objectId: this.props.objectId, |
||||||
|
content: comment, |
||||||
|
}); |
||||||
|
|
||||||
|
// TODO: set up error handling
|
||||||
|
console.log(response); |
||||||
|
|
||||||
|
return true; |
||||||
|
}; |
||||||
|
|
||||||
|
render() { |
||||||
|
return ( |
||||||
|
<CommentView comments={this.state.messages} packetCounter={this.packetCounter} addComment={this.addComment} /> |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,70 @@ |
|||||||
|
import React, { FormEvent, useLayoutEffect, useRef, useState } from 'react'; |
||||||
|
import { css } from '@emotion/css'; |
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { CustomScrollbar, TextArea, useStyles2 } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { Comment } from './Comment'; |
||||||
|
import { Message } from './types'; |
||||||
|
|
||||||
|
type Props = { |
||||||
|
comments: Message[]; |
||||||
|
packetCounter: number; |
||||||
|
addComment: (comment: string) => Promise<boolean>; |
||||||
|
}; |
||||||
|
|
||||||
|
export const CommentView = ({ comments, packetCounter, addComment }: Props) => { |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
|
||||||
|
const [comment, setComment] = useState(''); |
||||||
|
const [scrollTop, setScrollTop] = useState(0); |
||||||
|
const commentViewContainer = useRef<HTMLDivElement>(null); |
||||||
|
|
||||||
|
useLayoutEffect(() => { |
||||||
|
if (commentViewContainer.current) { |
||||||
|
setScrollTop(commentViewContainer.current.offsetHeight); |
||||||
|
} else { |
||||||
|
setScrollTop(0); |
||||||
|
} |
||||||
|
}, [packetCounter]); |
||||||
|
|
||||||
|
const onUpdateComment = (event: FormEvent<HTMLTextAreaElement>) => { |
||||||
|
const element = event.target as HTMLInputElement; |
||||||
|
setComment(element.value); |
||||||
|
}; |
||||||
|
|
||||||
|
const onKeyPress = async (event: React.KeyboardEvent<HTMLTextAreaElement>) => { |
||||||
|
if (event?.key === 'Enter' && !event?.shiftKey) { |
||||||
|
event.preventDefault(); |
||||||
|
|
||||||
|
if (comment.length > 0) { |
||||||
|
const result = await addComment(comment); |
||||||
|
if (result) { |
||||||
|
setComment(''); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<CustomScrollbar scrollTop={scrollTop}> |
||||||
|
<div ref={commentViewContainer} className={styles.commentViewContainer}> |
||||||
|
{comments.map((msg) => ( |
||||||
|
<Comment key={msg.id} message={msg} /> |
||||||
|
))} |
||||||
|
<TextArea |
||||||
|
placeholder="Write a comment" |
||||||
|
value={comment} |
||||||
|
onChange={onUpdateComment} |
||||||
|
onKeyPress={onKeyPress} |
||||||
|
autoFocus={true} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</CustomScrollbar> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({ |
||||||
|
commentViewContainer: css` |
||||||
|
margin: 5px; |
||||||
|
`,
|
||||||
|
}); |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
export interface MessagePacket { |
||||||
|
event: string; |
||||||
|
commentCreated: Message; |
||||||
|
} |
||||||
|
|
||||||
|
export interface Message { |
||||||
|
id: number; |
||||||
|
content: string; |
||||||
|
created: number; |
||||||
|
userId: number; |
||||||
|
user: User; |
||||||
|
} |
||||||
|
|
||||||
|
// TODO: Interface may exist elsewhere
|
||||||
|
export interface User { |
||||||
|
id: number; |
||||||
|
name: string; |
||||||
|
login: string; |
||||||
|
email: string; |
||||||
|
avatarUrl: string; |
||||||
|
} |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
import React from 'react'; |
||||||
|
import { css } from '@emotion/css'; |
||||||
|
import { Modal, useStyles2 } from '@grafana/ui'; |
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
|
||||||
|
import { CommentManager } from 'app/features/comments/CommentManager'; |
||||||
|
import { DashboardModel } from '../../state/DashboardModel'; |
||||||
|
|
||||||
|
type Props = { |
||||||
|
dashboard: DashboardModel; |
||||||
|
onDismiss: () => void; |
||||||
|
}; |
||||||
|
|
||||||
|
export const DashboardCommentsModal = ({ dashboard, onDismiss }: Props) => { |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
|
||||||
|
return ( |
||||||
|
<Modal isOpen={true} title="Dashboard comments" icon="save" onDismiss={onDismiss} className={styles.modal}> |
||||||
|
<CommentManager objectType={'dashboard'} objectId={dashboard.uid} /> |
||||||
|
</Modal> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({ |
||||||
|
modal: css` |
||||||
|
width: 500px; |
||||||
|
height: 60vh; |
||||||
|
`,
|
||||||
|
}); |
||||||
Loading…
Reference in new issue