mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
PublicDashboards: Delete public dashboard in public dashboard table (#57766)
PublicDashboards: delete button added in public dashboard table in order to delete a public dashboard. Co-authored-by: Jeff Levin <jeff@levinology.com>
This commit is contained in:
@@ -227,4 +227,11 @@ export const Pages = {
|
||||
url: '/?search=open&layout=folders',
|
||||
},
|
||||
},
|
||||
PublicDashboards: {
|
||||
ListItem: {
|
||||
linkButton: 'public-dashboard-link-button',
|
||||
configButton: 'public-dashboard-configuration-button',
|
||||
trashcanButton: 'public-dashboard-remove-button',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -77,6 +77,11 @@ func (api *Api) RegisterAPIEndpoints() {
|
||||
api.RouteRegister.Post("/api/dashboards/uid/:dashboardUid/public-dashboards",
|
||||
auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(dashboards.ActionDashboardsPublicWrite, uidScope)),
|
||||
routing.Wrap(api.SavePublicDashboard))
|
||||
|
||||
// Delete Public dashboard
|
||||
api.RouteRegister.Delete("/api/dashboards/uid/:dashboardUid/public-dashboards/:uid",
|
||||
auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(dashboards.ActionDashboardsPublicWrite, uidScope)),
|
||||
routing.Wrap(api.DeletePublicDashboard))
|
||||
}
|
||||
|
||||
// ListPublicDashboards Gets list of public dashboards for an org
|
||||
@@ -137,6 +142,22 @@ func (api *Api) SavePublicDashboard(c *models.ReqContext) response.Response {
|
||||
return response.JSON(http.StatusOK, pubdash)
|
||||
}
|
||||
|
||||
// Delete a public dashboard
|
||||
// DELETE /api/dashboards/uid/:dashboardUid/public-dashboards/:uid
|
||||
func (api *Api) DeletePublicDashboard(c *models.ReqContext) response.Response {
|
||||
uid := web.Params(c.Req)[":uid"]
|
||||
if uid == "" || !util.IsValidShortUID(uid) {
|
||||
return api.handleError(c.Req.Context(), http.StatusBadRequest, "DeletePublicDashboard: invalid dashboard uid", dashboards.ErrDashboardIdentifierNotSet)
|
||||
}
|
||||
|
||||
err := api.PublicDashboardService.Delete(c.Req.Context(), c.OrgID, uid)
|
||||
if err != nil {
|
||||
return api.handleError(c.Req.Context(), http.StatusInternalServerError, "DeletePublicDashboard: failed to delete public dashboard", err)
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, nil)
|
||||
}
|
||||
|
||||
// util to help us unpack dashboard and publicdashboard errors or use default http code and message
|
||||
// we should look to do some future refactoring of these errors as publicdashboard err is the same as a dashboarderr, just defined in a
|
||||
// different package.
|
||||
|
||||
@@ -3,14 +3,11 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
@@ -18,6 +15,9 @@ import (
|
||||
. "github.com/grafana/grafana/pkg/services/publicdashboards/models"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var userAdmin = &user.SignedInUser{UserID: 1, OrgID: 1, OrgRole: org.RoleAdmin, Login: "testAdminUser"}
|
||||
@@ -148,6 +148,134 @@ func TestAPIListPublicDashboard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDeletePublicDashboard(t *testing.T) {
|
||||
dashboardUid := "abc1234"
|
||||
publicDashboardUid := "1234asdfasdf"
|
||||
userEditorAllPublicDashboard := &user.SignedInUser{UserID: 4, OrgID: 1, OrgRole: org.RoleEditor, Login: "testEditorUser", Permissions: map[int64]map[string][]string{1: {dashboards.ActionDashboardsPublicWrite: {dashboards.ScopeDashboardsAll}}}}
|
||||
userEditorAnotherPublicDashboard := &user.SignedInUser{UserID: 4, OrgID: 1, OrgRole: org.RoleEditor, Login: "testEditorUser", Permissions: map[int64]map[string][]string{1: {dashboards.ActionDashboardsPublicWrite: {"another-uid"}}}}
|
||||
userEditorPublicDashboard := &user.SignedInUser{UserID: 4, OrgID: 1, OrgRole: org.RoleEditor, Login: "testEditorUser", Permissions: map[int64]map[string][]string{1: {dashboards.ActionDashboardsPublicWrite: {fmt.Sprintf("dashboards:uid:%s", dashboardUid)}}}}
|
||||
|
||||
testCases := []struct {
|
||||
Name string
|
||||
User *user.SignedInUser
|
||||
DashboardUid string
|
||||
PublicDashboardUid string
|
||||
ResponseErr error
|
||||
ExpectedHttpResponse int
|
||||
ShouldCallService bool
|
||||
}{
|
||||
{
|
||||
Name: "User viewer cannot delete public dashboard",
|
||||
User: userViewer,
|
||||
DashboardUid: dashboardUid,
|
||||
PublicDashboardUid: publicDashboardUid,
|
||||
ResponseErr: nil,
|
||||
ExpectedHttpResponse: http.StatusForbidden,
|
||||
ShouldCallService: false,
|
||||
},
|
||||
{
|
||||
Name: "User editor without specific dashboard access cannot delete public dashboard",
|
||||
User: userEditorAnotherPublicDashboard,
|
||||
DashboardUid: dashboardUid,
|
||||
PublicDashboardUid: publicDashboardUid,
|
||||
ResponseErr: nil,
|
||||
ExpectedHttpResponse: http.StatusForbidden,
|
||||
ShouldCallService: false,
|
||||
},
|
||||
{
|
||||
Name: "User editor with all dashboard accesses can delete public dashboard",
|
||||
User: userEditorAllPublicDashboard,
|
||||
DashboardUid: dashboardUid,
|
||||
PublicDashboardUid: publicDashboardUid,
|
||||
ResponseErr: nil,
|
||||
ExpectedHttpResponse: http.StatusOK,
|
||||
ShouldCallService: true,
|
||||
},
|
||||
{
|
||||
Name: "User editor with dashboard access can delete public dashboard",
|
||||
User: userEditorPublicDashboard,
|
||||
DashboardUid: dashboardUid,
|
||||
PublicDashboardUid: publicDashboardUid,
|
||||
ResponseErr: nil,
|
||||
ExpectedHttpResponse: http.StatusOK,
|
||||
ShouldCallService: true,
|
||||
},
|
||||
{
|
||||
Name: "Internal server error returns an error",
|
||||
User: userEditorPublicDashboard,
|
||||
DashboardUid: dashboardUid,
|
||||
PublicDashboardUid: publicDashboardUid,
|
||||
ResponseErr: errors.New("server error"),
|
||||
ExpectedHttpResponse: http.StatusInternalServerError,
|
||||
ShouldCallService: true,
|
||||
},
|
||||
{
|
||||
Name: "PublicDashboard error returns correct status code instead of 500",
|
||||
User: userEditorPublicDashboard,
|
||||
DashboardUid: dashboardUid,
|
||||
PublicDashboardUid: publicDashboardUid,
|
||||
ResponseErr: ErrPublicDashboardIdentifierNotSet,
|
||||
ExpectedHttpResponse: ErrPublicDashboardIdentifierNotSet.StatusCode,
|
||||
ShouldCallService: true,
|
||||
},
|
||||
{
|
||||
Name: "Invalid publicDashboardUid throws an error",
|
||||
User: userEditorPublicDashboard,
|
||||
DashboardUid: dashboardUid,
|
||||
PublicDashboardUid: "inv@lid-publicd@shboard-uid!",
|
||||
ResponseErr: nil,
|
||||
ExpectedHttpResponse: ErrPublicDashboardIdentifierNotSet.StatusCode,
|
||||
ShouldCallService: false,
|
||||
},
|
||||
{
|
||||
Name: "Public dashboard uid does not exist",
|
||||
User: userEditorPublicDashboard,
|
||||
DashboardUid: dashboardUid,
|
||||
PublicDashboardUid: "UIDDOESNOTEXIST",
|
||||
ResponseErr: ErrPublicDashboardNotFound,
|
||||
ExpectedHttpResponse: ErrPublicDashboardNotFound.StatusCode,
|
||||
ShouldCallService: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range testCases {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
service := publicdashboards.NewFakePublicDashboardService(t)
|
||||
|
||||
if test.ShouldCallService {
|
||||
service.On("Delete", mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(test.ResponseErr)
|
||||
}
|
||||
|
||||
cfg := setting.NewCfg()
|
||||
|
||||
features := featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards)
|
||||
testServer := setupTestServer(t, cfg, features, service, nil, test.User)
|
||||
|
||||
response := callAPI(testServer, http.MethodDelete, fmt.Sprintf("/api/dashboards/uid/%s/public-dashboards/%s", test.DashboardUid, test.PublicDashboardUid), nil, t)
|
||||
assert.Equal(t, test.ExpectedHttpResponse, response.Code)
|
||||
|
||||
if test.ExpectedHttpResponse == http.StatusOK {
|
||||
var jsonResp any
|
||||
err := json.Unmarshal(response.Body.Bytes(), &jsonResp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, jsonResp, nil)
|
||||
}
|
||||
|
||||
if !test.ShouldCallService {
|
||||
service.AssertNotCalled(t, "Delete")
|
||||
}
|
||||
|
||||
if test.ResponseErr != nil {
|
||||
var errResp JsonErrResponse
|
||||
err := json.Unmarshal(response.Body.Bytes(), &errResp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.ResponseErr.Error(), errResp.Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGetPublicDashboard(t *testing.T) {
|
||||
pubdash := &PublicDashboard{IsEnabled: true}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ func ProvideStore(sqlStore db.DB) *PublicDashboardStoreImpl {
|
||||
func (d *PublicDashboardStoreImpl) FindAll(ctx context.Context, orgId int64) ([]PublicDashboardListResponse, error) {
|
||||
resp := make([]PublicDashboardListResponse, 0)
|
||||
|
||||
err := d.sqlStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||
err := d.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
sess.Table("dashboard_public").
|
||||
Join("LEFT", "dashboard", "dashboard.uid = dashboard_public.dashboard_uid AND dashboard.org_id = dashboard_public.org_id").
|
||||
Cols("dashboard_public.uid", "dashboard_public.access_token", "dashboard_public.dashboard_uid", "dashboard_public.is_enabled", "dashboard.title").
|
||||
@@ -60,7 +60,7 @@ func (d *PublicDashboardStoreImpl) FindDashboard(ctx context.Context, orgId int6
|
||||
dashboard := &models.Dashboard{OrgId: orgId, Uid: dashboardUid}
|
||||
|
||||
var found bool
|
||||
err := d.sqlStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||
err := d.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
var err error
|
||||
found, err = sess.Get(dashboard)
|
||||
return err
|
||||
@@ -81,7 +81,7 @@ func (d *PublicDashboardStoreImpl) Find(ctx context.Context, uid string) (*Publi
|
||||
|
||||
var found bool
|
||||
pdRes := &PublicDashboard{Uid: uid}
|
||||
err := d.sqlStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||
err := d.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
var err error
|
||||
found, err = sess.Get(pdRes)
|
||||
return err
|
||||
@@ -106,7 +106,7 @@ func (d *PublicDashboardStoreImpl) FindByAccessToken(ctx context.Context, access
|
||||
|
||||
var found bool
|
||||
pdRes := &PublicDashboard{AccessToken: accessToken}
|
||||
err := d.sqlStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||
err := d.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
var err error
|
||||
found, err = sess.Get(pdRes)
|
||||
return err
|
||||
@@ -130,10 +130,10 @@ func (d *PublicDashboardStoreImpl) FindByDashboardUid(ctx context.Context, orgId
|
||||
}
|
||||
|
||||
var found bool
|
||||
pdRes := &PublicDashboard{OrgId: orgId, DashboardUid: dashboardUid}
|
||||
err := d.sqlStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||
publicDashboard := &PublicDashboard{OrgId: orgId, DashboardUid: dashboardUid}
|
||||
err := d.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
var err error
|
||||
found, err = sess.Get(pdRes)
|
||||
found, err = sess.Get(publicDashboard)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func (d *PublicDashboardStoreImpl) FindByDashboardUid(ctx context.Context, orgId
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return pdRes, err
|
||||
return publicDashboard, err
|
||||
}
|
||||
|
||||
// Save Persists public dashboard
|
||||
@@ -158,7 +158,7 @@ func (d *PublicDashboardStoreImpl) Save(ctx context.Context, cmd SavePublicDashb
|
||||
return dashboards.ErrDashboardIdentifierNotSet
|
||||
}
|
||||
|
||||
err := d.sqlStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||
err := d.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
_, err := sess.UseBool("is_enabled").Insert(&cmd.PublicDashboard)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -172,7 +172,7 @@ func (d *PublicDashboardStoreImpl) Save(ctx context.Context, cmd SavePublicDashb
|
||||
|
||||
// Update updates existing public dashboard
|
||||
func (d *PublicDashboardStoreImpl) Update(ctx context.Context, cmd SavePublicDashboardCommand) error {
|
||||
err := d.sqlStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||
err := d.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
timeSettingsJSON, err := json.Marshal(cmd.PublicDashboard.TimeSettings)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -196,6 +196,22 @@ func (d *PublicDashboardStoreImpl) Update(ctx context.Context, cmd SavePublicDas
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *PublicDashboardStoreImpl) Delete(ctx context.Context, orgId int64, uid string) (int64, error) {
|
||||
dashboard := &PublicDashboard{OrgId: orgId, Uid: uid}
|
||||
var affectedRows int64
|
||||
err := d.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
var err error
|
||||
affectedRows, err = sess.Delete(dashboard)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return affectedRows, err
|
||||
}
|
||||
|
||||
// ExistsEnabledByDashboardUid Responds true if there is an enabled public dashboard for a dashboard uid
|
||||
func (d *PublicDashboardStoreImpl) ExistsEnabledByDashboardUid(ctx context.Context, dashboardUid string) (bool, error) {
|
||||
hasPublicDashboard := false
|
||||
|
||||
@@ -5,9 +5,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
@@ -19,6 +16,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/tag/tagimpl"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// This is what the db sets empty time settings to
|
||||
@@ -553,6 +552,44 @@ func TestIntegrationGetOrgIdByAccessToken(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationDelete(t *testing.T) {
|
||||
var sqlStore db.DB
|
||||
var cfg *setting.Cfg
|
||||
var dashboardStore *dashboardsDB.DashboardStore
|
||||
var publicdashboardStore *PublicDashboardStoreImpl
|
||||
var savedDashboard *models.Dashboard
|
||||
var savedPublicDashboard *PublicDashboard
|
||||
|
||||
setup := func() {
|
||||
sqlStore, cfg = db.InitTestDBwithCfg(t)
|
||||
dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, cfg))
|
||||
publicdashboardStore = ProvideStore(sqlStore)
|
||||
savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true)
|
||||
savedPublicDashboard = insertPublicDashboard(t, publicdashboardStore, savedDashboard.Uid, savedDashboard.OrgId, true)
|
||||
}
|
||||
|
||||
t.Run("Delete success", func(t *testing.T) {
|
||||
setup()
|
||||
// Do the deletion
|
||||
affectedRows, err := publicdashboardStore.Delete(context.Background(), savedPublicDashboard.OrgId, savedPublicDashboard.Uid)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, affectedRows, 1)
|
||||
|
||||
// Verify public dashboard is actually deleted
|
||||
deletedDashboard, err := publicdashboardStore.FindByDashboardUid(context.Background(), savedPublicDashboard.OrgId, savedPublicDashboard.DashboardUid)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, deletedDashboard)
|
||||
})
|
||||
|
||||
t.Run("Non-existent public dashboard deletion doesn't throw an error", func(t *testing.T) {
|
||||
setup()
|
||||
|
||||
affectedRows, err := publicdashboardStore.Delete(context.Background(), 15, "non-existent-uid")
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, affectedRows, 0)
|
||||
})
|
||||
}
|
||||
|
||||
// helper function to insert a dashboard
|
||||
func insertTestDashboard(t *testing.T, dashboardStore *dashboardsDB.DashboardStore, title string, orgId int64,
|
||||
folderId int64, isFolder bool, tags ...interface{}) *models.Dashboard {
|
||||
|
||||
@@ -25,6 +25,20 @@ type FakePublicDashboardService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Delete provides a mock function with given fields: ctx, orgId, uid
|
||||
func (_m *FakePublicDashboardService) Delete(ctx context.Context, orgId int64, uid string) error {
|
||||
ret := _m.Called(ctx, orgId, uid)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int64, string) error); ok {
|
||||
r0 = rf(ctx, orgId, uid)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// ExistsEnabledByAccessToken provides a mock function with given fields: ctx, accessToken
|
||||
func (_m *FakePublicDashboardService) ExistsEnabledByAccessToken(ctx context.Context, accessToken string) (bool, error) {
|
||||
ret := _m.Called(ctx, accessToken)
|
||||
|
||||
@@ -18,6 +18,27 @@ type FakePublicDashboardStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// Delete provides a mock function with given fields: ctx, orgId, uid
|
||||
func (_m *FakePublicDashboardStore) Delete(ctx context.Context, orgId int64, uid string) (int64, error) {
|
||||
ret := _m.Called(ctx, orgId, uid)
|
||||
|
||||
var r0 int64
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int64, string) int64); ok {
|
||||
r0 = rf(ctx, orgId, uid)
|
||||
} else {
|
||||
r0 = ret.Get(0).(int64)
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, int64, string) error); ok {
|
||||
r1 = rf(ctx, orgId, uid)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// ExistsEnabledByAccessToken provides a mock function with given fields: ctx, accessToken
|
||||
func (_m *FakePublicDashboardStore) ExistsEnabledByAccessToken(ctx context.Context, accessToken string) (bool, error) {
|
||||
ret := _m.Called(ctx, accessToken)
|
||||
|
||||
@@ -20,6 +20,7 @@ type Service interface {
|
||||
FindDashboard(ctx context.Context, orgId int64, dashboardUid string) (*models.Dashboard, error)
|
||||
FindAll(ctx context.Context, u *user.SignedInUser, orgId int64) ([]PublicDashboardListResponse, error)
|
||||
Save(ctx context.Context, u *user.SignedInUser, dto *SavePublicDashboardDTO) (*PublicDashboard, error)
|
||||
Delete(ctx context.Context, orgId int64, uid string) error
|
||||
|
||||
GetMetricRequest(ctx context.Context, dashboard *models.Dashboard, publicDashboard *PublicDashboard, panelId int64, reqDTO PublicDashboardQueryDTO) (dtos.MetricRequest, error)
|
||||
GetQueryDataResponse(ctx context.Context, skipCache bool, reqDTO PublicDashboardQueryDTO, panelId int64, accessToken string) (*backend.QueryDataResponse, error)
|
||||
@@ -40,6 +41,7 @@ type Store interface {
|
||||
FindAll(ctx context.Context, orgId int64) ([]PublicDashboardListResponse, error)
|
||||
Save(ctx context.Context, cmd SavePublicDashboardCommand) error
|
||||
Update(ctx context.Context, cmd SavePublicDashboardCommand) error
|
||||
Delete(ctx context.Context, orgId int64, uid string) (int64, error)
|
||||
|
||||
GetOrgIdByAccessToken(ctx context.Context, accessToken string) (int64, error)
|
||||
ExistsEnabledByAccessToken(ctx context.Context, accessToken string) (bool, error)
|
||||
|
||||
@@ -277,6 +277,19 @@ func (pd *PublicDashboardServiceImpl) GetOrgIdByAccessToken(ctx context.Context,
|
||||
return pd.store.GetOrgIdByAccessToken(ctx, accessToken)
|
||||
}
|
||||
|
||||
func (pd *PublicDashboardServiceImpl) Delete(ctx context.Context, orgId int64, uid string) error {
|
||||
affectedRows, err := pd.store.Delete(ctx, orgId, uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if affectedRows == 0 {
|
||||
return ErrPublicDashboardNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// intervalMS and maxQueryData values are being calculated on the frontend for regular dashboards
|
||||
// we are doing the same for public dashboards but because this access would be public, we need a way to keep this
|
||||
// values inside reasonable bounds to avoid an attack that could hit data sources with a small interval and a big
|
||||
|
||||
@@ -9,10 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
@@ -30,6 +26,9 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/tsdb/intervalv2"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var timeSettings = &TimeSettings{From: "now-12h", To: "now"}
|
||||
@@ -380,6 +379,49 @@ func TestUpdatePublicDashboard(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeletePublicDashboard(t *testing.T) {
|
||||
testCases := []struct {
|
||||
Name string
|
||||
AffectedRowsResp int64
|
||||
ErrResp error
|
||||
ExpectedErr error
|
||||
}{
|
||||
{
|
||||
Name: "Successfully deletes a public dashboards",
|
||||
AffectedRowsResp: 1,
|
||||
ErrResp: nil,
|
||||
ExpectedErr: nil,
|
||||
},
|
||||
{
|
||||
Name: "Public dashboard not found",
|
||||
AffectedRowsResp: 0,
|
||||
ErrResp: nil,
|
||||
ExpectedErr: ErrPublicDashboardNotFound,
|
||||
},
|
||||
{
|
||||
Name: "Database error",
|
||||
AffectedRowsResp: 0,
|
||||
ErrResp: errors.New("db error!"),
|
||||
ExpectedErr: errors.New("db error!"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
store := NewFakePublicDashboardStore(t)
|
||||
store.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tt.AffectedRowsResp, tt.ExpectedErr)
|
||||
|
||||
service := &PublicDashboardServiceImpl{
|
||||
log: log.New("test.logger"),
|
||||
store: store,
|
||||
}
|
||||
|
||||
err := service.Delete(context.Background(), 13, "uid")
|
||||
assert.Equal(t, tt.ExpectedErr, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func insertTestDashboard(t *testing.T, dashboardStore *dashboardsDB.DashboardStore, title string, orgId int64,
|
||||
folderId int64, isFolder bool, templateVars []map[string]interface{}, customPanels []interface{}, tags ...interface{}) *models.Dashboard {
|
||||
t.Helper()
|
||||
|
||||
@@ -6,6 +6,7 @@ import { notifyApp } from 'app/core/actions';
|
||||
import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification';
|
||||
import { PublicDashboard } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
import { DashboardModel } from 'app/features/dashboard/state';
|
||||
import { ListPublicDashboardResponse } from 'app/features/manage-dashboards/types';
|
||||
|
||||
type ReqOptions = {
|
||||
manageError?: (err: unknown) => { error: unknown };
|
||||
@@ -33,8 +34,8 @@ const getConfigError = (err: { status: number }) => ({ error: err.status !== 404
|
||||
|
||||
export const publicDashboardApi = createApi({
|
||||
reducerPath: 'publicDashboardApi',
|
||||
baseQuery: retry(backendSrvBaseQuery({ baseUrl: '/api/dashboards' }), { maxRetries: 3 }),
|
||||
tagTypes: ['Config'],
|
||||
baseQuery: retry(backendSrvBaseQuery({ baseUrl: '/api/dashboards' }), { maxRetries: 0 }),
|
||||
tagTypes: ['Config', 'PublicDashboards'],
|
||||
keepUnusedDataFor: 0,
|
||||
endpoints: (builder) => ({
|
||||
getConfig: builder.query<PublicDashboard, string>({
|
||||
@@ -60,7 +61,6 @@ export const publicDashboardApi = createApi({
|
||||
method: 'POST',
|
||||
data: params.payload,
|
||||
}),
|
||||
extraOptions: { maxRetries: 0 },
|
||||
async onQueryStarted({ dashboard, payload }, { dispatch, queryFulfilled }) {
|
||||
const { data } = await queryFulfilled;
|
||||
dispatch(notifyApp(createSuccessNotification('Dashboard sharing configuration saved')));
|
||||
@@ -73,7 +73,38 @@ export const publicDashboardApi = createApi({
|
||||
},
|
||||
invalidatesTags: ['Config'],
|
||||
}),
|
||||
listPublicDashboards: builder.query<ListPublicDashboardResponse[], void>({
|
||||
query: () => ({
|
||||
url: '/public-dashboards',
|
||||
}),
|
||||
providesTags: ['PublicDashboards'],
|
||||
}),
|
||||
deletePublicDashboard: builder.mutation<void, { dashboardTitle: string; dashboardUid: string; uid: string }>({
|
||||
query: (params) => ({
|
||||
url: `/uid/${params.dashboardUid}/public-dashboards/${params.uid}`,
|
||||
method: 'DELETE',
|
||||
}),
|
||||
async onQueryStarted({ dashboardTitle }, { dispatch, queryFulfilled }) {
|
||||
await queryFulfilled;
|
||||
dispatch(
|
||||
notifyApp(
|
||||
createSuccessNotification(
|
||||
'Public dashboard deleted',
|
||||
!!dashboardTitle
|
||||
? `Public dashboard for ${dashboardTitle} has been deleted`
|
||||
: `Public dashboard has been deleted`
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
invalidatesTags: ['PublicDashboards'],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useGetConfigQuery, useSaveConfigMutation } = publicDashboardApi;
|
||||
export const {
|
||||
useGetConfigQuery,
|
||||
useSaveConfigMutation,
|
||||
useDeletePublicDashboardMutation,
|
||||
useListPublicDashboardsQuery,
|
||||
} = publicDashboardApi;
|
||||
|
||||
@@ -2,14 +2,12 @@ import React from 'react';
|
||||
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { ListPublicDashboardTable } from './components/PublicDashboardListTable';
|
||||
import { PublicDashboardListTable } from './components/PublicDashboardListTable/PublicDashboardListTable';
|
||||
|
||||
export const ListPublicDashboardPage = ({}) => {
|
||||
return (
|
||||
<Page navId="dashboards/public">
|
||||
<Page.Contents>
|
||||
<ListPublicDashboardTable />
|
||||
</Page.Contents>
|
||||
<PublicDashboardListTable />
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import {
|
||||
LIST_PUBLIC_DASHBOARD_URL,
|
||||
viewPublicDashboardUrl,
|
||||
//ListPublicDashboardTable,
|
||||
} from './PublicDashboardListTable';
|
||||
|
||||
//import { render, screen, waitFor, act } from '@testing-library/react';
|
||||
//import React from 'react';
|
||||
|
||||
describe('listPublicDashboardsUrl', () => {
|
||||
it('has the correct url', () => {
|
||||
expect(LIST_PUBLIC_DASHBOARD_URL).toEqual('/api/dashboards/public-dashboards');
|
||||
});
|
||||
});
|
||||
|
||||
describe('viewPublicDashboardUrl', () => {
|
||||
it('has the correct url', () => {
|
||||
expect(viewPublicDashboardUrl('abcd')).toEqual('public-dashboards/abcd');
|
||||
});
|
||||
});
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...(jest.requireActual('@grafana/runtime') as unknown as object),
|
||||
getBackendSrv: () => ({
|
||||
get: jest.fn().mockResolvedValue([
|
||||
{
|
||||
uid: 'SdZwuCZVz',
|
||||
accessToken: 'beeaf92f6ab3467f80b2be922c7741ab',
|
||||
title: 'New dashboardasdf',
|
||||
dashboardUid: 'iF36Qb6nz',
|
||||
isEnabled: false,
|
||||
},
|
||||
{
|
||||
uid: 'EuiEbd3nz',
|
||||
accessToken: '8687b0498ccf4babb2f92810d8563b33',
|
||||
title: 'New dashboard',
|
||||
dashboardUid: 'kFlxbd37k',
|
||||
isEnabled: true,
|
||||
},
|
||||
]),
|
||||
}),
|
||||
}));
|
||||
|
||||
//describe('ListPublicDashboardTable', () => {
|
||||
// test('renders properly', async() => {
|
||||
// act(() => {
|
||||
// render(<ListPublicDashboardTable />)
|
||||
// });
|
||||
|
||||
// //await waitFor(() => screen.getByRole('table'));
|
||||
// expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
||||
// //expect(screen.getAllByRole("tr")).toHaveLength(2);
|
||||
// })
|
||||
//})
|
||||
@@ -1,99 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useState } from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { Link, ButtonGroup, LinkButton, Icon, Tag, useStyles2 } from '@grafana/ui';
|
||||
import { getConfig } from 'app/core/config';
|
||||
|
||||
export interface ListPublicDashboardResponse {
|
||||
uid: string;
|
||||
accessToken: string;
|
||||
dashboardUid: string;
|
||||
title: string;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export const LIST_PUBLIC_DASHBOARD_URL = `/api/dashboards/public-dashboards`;
|
||||
export const getPublicDashboards = async (): Promise<ListPublicDashboardResponse[]> => {
|
||||
return getBackendSrv().get(LIST_PUBLIC_DASHBOARD_URL);
|
||||
};
|
||||
|
||||
export const viewPublicDashboardUrl = (accessToken: string): string => {
|
||||
return `${getConfig().appUrl}public-dashboards/${accessToken}`;
|
||||
};
|
||||
|
||||
export const ListPublicDashboardTable = () => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [publicDashboards, setPublicDashboards] = useState<ListPublicDashboardResponse[]>([]);
|
||||
|
||||
useAsync(async () => {
|
||||
const publicDashboards = await getPublicDashboards();
|
||||
setPublicDashboards(publicDashboards);
|
||||
}, [setPublicDashboards]);
|
||||
|
||||
return (
|
||||
<div className="page-action-bar">
|
||||
<table className="filter-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Public URL</th>
|
||||
<th>Configuration</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{publicDashboards.map((pd) => (
|
||||
<tr key={pd.uid}>
|
||||
<td>
|
||||
<Link className={styles.link} href={`/d/${pd.dashboardUid}`}>
|
||||
{pd.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<Tag name={pd.isEnabled ? 'enabled' : 'disabled'} colorIndex={pd.isEnabled ? 20 : 15} />
|
||||
</td>
|
||||
<td>
|
||||
<ButtonGroup>
|
||||
<LinkButton
|
||||
href={viewPublicDashboardUrl(pd.accessToken)}
|
||||
fill="text"
|
||||
title={pd.isEnabled ? 'View public dashboard' : 'Public dashboard is disabled'}
|
||||
target="_blank"
|
||||
disabled={!pd.isEnabled}
|
||||
>
|
||||
<Icon name="external-link-alt" />
|
||||
</LinkButton>
|
||||
</ButtonGroup>
|
||||
</td>
|
||||
<td>
|
||||
<ButtonGroup>
|
||||
<LinkButton
|
||||
fill="text"
|
||||
href={`/d/${pd.dashboardUid}?shareView=share`}
|
||||
title="Configure public dashboard"
|
||||
>
|
||||
<Icon name="cog" />
|
||||
</LinkButton>
|
||||
</ButtonGroup>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
link: css`
|
||||
color: ${theme.colors.primary.text};
|
||||
text-decoration: underline;
|
||||
margin-right: ${theme.spacing()};
|
||||
`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
|
||||
import { Button, ComponentSize, Icon, ModalsController, Spinner } from '@grafana/ui/src';
|
||||
|
||||
import { useDeletePublicDashboardMutation } from '../../../dashboard/api/publicDashboardApi';
|
||||
import { ListPublicDashboardResponse } from '../../types';
|
||||
|
||||
import { DeletePublicDashboardModal } from './DeletePublicDashboardModal';
|
||||
|
||||
export const DeletePublicDashboardButton = ({
|
||||
publicDashboard,
|
||||
size,
|
||||
}: {
|
||||
publicDashboard: ListPublicDashboardResponse;
|
||||
size: ComponentSize;
|
||||
}) => {
|
||||
const [deletePublicDashboard, { isLoading }] = useDeletePublicDashboardMutation();
|
||||
|
||||
const onDeletePublicDashboardClick = (pd: ListPublicDashboardResponse, onDelete: () => void) => {
|
||||
deletePublicDashboard({ uid: pd.uid, dashboardUid: pd.dashboardUid, dashboardTitle: pd.title });
|
||||
onDelete();
|
||||
};
|
||||
|
||||
const selectors = e2eSelectors.pages.PublicDashboards;
|
||||
|
||||
return (
|
||||
<ModalsController>
|
||||
{({ showModal, hideModal }) => (
|
||||
<Button
|
||||
fill="text"
|
||||
aria-label="Delete public dashboard"
|
||||
title="Delete public dashboard"
|
||||
onClick={() =>
|
||||
showModal(DeletePublicDashboardModal, {
|
||||
dashboardTitle: publicDashboard.title,
|
||||
onConfirm: () => onDeletePublicDashboardClick(publicDashboard, hideModal),
|
||||
onDismiss: hideModal,
|
||||
})
|
||||
}
|
||||
data-testid={selectors.ListItem.trashcanButton}
|
||||
size={size}
|
||||
>
|
||||
{isLoading ? <Spinner /> : <Icon size={size} name="trash-alt" />}
|
||||
</Button>
|
||||
)}
|
||||
</ModalsController>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data/src';
|
||||
import { ConfirmModal, useStyles2 } from '@grafana/ui/src';
|
||||
|
||||
const Body = ({ title }: { title?: string }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className={styles.title}>Do you want to delete this public dashboard?</p>
|
||||
<p className={styles.description}>
|
||||
{title
|
||||
? `This will delete the public dashboard for ${title}. Your dashboard will not be deleted.`
|
||||
: 'Orphaned public dashboard will be deleted'}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const DeletePublicDashboardModal = ({
|
||||
dashboardTitle,
|
||||
onConfirm,
|
||||
onDismiss,
|
||||
}: {
|
||||
dashboardTitle?: string;
|
||||
onConfirm: () => void;
|
||||
onDismiss: () => void;
|
||||
}) => (
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
body={<Body title={dashboardTitle} />}
|
||||
onConfirm={onConfirm}
|
||||
onDismiss={onDismiss}
|
||||
title="Delete"
|
||||
icon="trash-alt"
|
||||
confirmText="Delete"
|
||||
/>
|
||||
);
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
title: css`
|
||||
margin-bottom: ${theme.spacing(1)};
|
||||
`,
|
||||
description: css`
|
||||
font-size: ${theme.typography.bodySmall.fontSize};
|
||||
`,
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { render, screen, waitForElementToBeRemoved, within } from '@testing-library/react';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import 'whatwg-fetch';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
|
||||
import { backendSrv } from 'app/core/services/backend_srv';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
|
||||
import { ListPublicDashboardResponse } from '../../types';
|
||||
|
||||
import { PublicDashboardListTable, viewPublicDashboardUrl } from './PublicDashboardListTable';
|
||||
|
||||
const publicDashboardListResponse: ListPublicDashboardResponse[] = [
|
||||
{
|
||||
uid: 'SdZwuCZVz',
|
||||
accessToken: 'beeaf92f6ab3467f80b2be922c7741ab',
|
||||
title: 'New dashboardasdf',
|
||||
dashboardUid: 'iF36Qb6nz',
|
||||
isEnabled: false,
|
||||
},
|
||||
{
|
||||
uid: 'EuiEbd3nz',
|
||||
accessToken: '8687b0498ccf4babb2f92810d8563b33',
|
||||
title: 'New dashboard',
|
||||
dashboardUid: 'kFlxbd37k',
|
||||
isEnabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const server = setupServer(
|
||||
rest.get('/api/dashboards/public-dashboards', (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(publicDashboardListResponse))
|
||||
),
|
||||
rest.delete('/api/dashboards/uid/:dashboardUid/public-dashboards/:uid', (_, res, ctx) => res(ctx.status(200)))
|
||||
);
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...(jest.requireActual('@grafana/runtime') as unknown as object),
|
||||
getBackendSrv: () => backendSrv,
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen({ onUnhandledRequest: 'bypass' });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
const selectors = e2eSelectors.pages.PublicDashboards;
|
||||
|
||||
const renderPublicDashboardTable = async (waitForListRendering?: boolean) => {
|
||||
const store = configureStore();
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<PublicDashboardListTable />
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
waitForListRendering && (await waitForElementToBeRemoved(screen.getByTestId('Spinner'), { timeout: 3000 }));
|
||||
};
|
||||
|
||||
describe('viewPublicDashboardUrl', () => {
|
||||
it('has the correct url', () => {
|
||||
expect(viewPublicDashboardUrl('abcd')).toEqual('public-dashboards/abcd');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Show table', () => {
|
||||
it('renders loader spinner while loading', async () => {
|
||||
await renderPublicDashboardTable();
|
||||
const spinner = screen.getByTestId('Spinner');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
|
||||
await waitForElementToBeRemoved(spinner);
|
||||
});
|
||||
it('renders public dashboard list items', async () => {
|
||||
await renderPublicDashboardTable(true);
|
||||
|
||||
const tableBody = screen.getAllByRole('rowgroup')[1];
|
||||
expect(within(tableBody).getAllByRole('row')).toHaveLength(publicDashboardListResponse.length);
|
||||
});
|
||||
it('renders empty list', async () => {
|
||||
server.use(
|
||||
rest.get('/api/dashboards/public-dashboards', (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json([]));
|
||||
})
|
||||
);
|
||||
|
||||
await renderPublicDashboardTable(true);
|
||||
|
||||
const tableBody = screen.getAllByRole('rowgroup')[1];
|
||||
expect(within(tableBody).queryAllByRole('row')).toHaveLength(0);
|
||||
});
|
||||
it('renders public dashboards in a good way without trashcan', async () => {
|
||||
jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(false);
|
||||
await renderPublicDashboardItems();
|
||||
|
||||
const tableBody = screen.getAllByRole('rowgroup')[1];
|
||||
const tableRows = within(tableBody).getAllByRole('row');
|
||||
|
||||
publicDashboardListResponse.forEach((pd, idx) => {
|
||||
const tableRow = tableRows[idx];
|
||||
const rowDataCells = within(tableRow).getAllByRole('cell');
|
||||
expect(within(rowDataCells[2]).queryByTestId(selectors.ListItem.trashcanButton)).toBeNull();
|
||||
});
|
||||
});
|
||||
it('renders public dashboards in a good way with trashcan', async () => {
|
||||
jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(true);
|
||||
await renderPublicDashboardItems();
|
||||
|
||||
const tableBody = screen.getAllByRole('rowgroup')[1];
|
||||
const tableRows = within(tableBody).getAllByRole('row');
|
||||
|
||||
publicDashboardListResponse.forEach((pd, idx) => {
|
||||
const tableRow = tableRows[idx];
|
||||
const rowDataCells = within(tableRow).getAllByRole('cell');
|
||||
expect(within(rowDataCells[2]).getByTestId(selectors.ListItem.trashcanButton));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Delete public dashboard', () => {
|
||||
it('when user does not have public dashboard write permissions, then dashboards are listed without delete button ', async () => {
|
||||
jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(false);
|
||||
await renderPublicDashboardTable(true);
|
||||
|
||||
const tableBody = screen.getAllByRole('rowgroup')[1];
|
||||
expect(within(tableBody).queryAllByTestId(selectors.ListItem.trashcanButton)).toHaveLength(0);
|
||||
});
|
||||
it('when user has public dashboard write permissions, then dashboards are listed with delete button ', async () => {
|
||||
jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(true);
|
||||
await renderPublicDashboardTable(true);
|
||||
|
||||
const tableBody = screen.getAllByRole('rowgroup')[1];
|
||||
expect(within(tableBody).getAllByTestId(selectors.ListItem.trashcanButton)).toHaveLength(
|
||||
publicDashboardListResponse.length
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const renderPublicDashboardItems = async () => {
|
||||
await renderPublicDashboardTable(true);
|
||||
|
||||
const tableBody = screen.getAllByRole('rowgroup')[1];
|
||||
const tableRows = within(tableBody).getAllByRole('row');
|
||||
|
||||
publicDashboardListResponse.forEach((pd, idx) => {
|
||||
const tableRow = tableRows[idx];
|
||||
const rowDataCells = within(tableRow).getAllByRole('cell');
|
||||
expect(rowDataCells).toHaveLength(3);
|
||||
|
||||
expect(within(rowDataCells[0]).getByText(pd.title));
|
||||
expect(within(rowDataCells[1]).getByText(pd.isEnabled ? 'enabled' : 'disabled'));
|
||||
expect(within(rowDataCells[2]).getByTestId(selectors.ListItem.linkButton));
|
||||
expect(within(rowDataCells[2]).getByTestId(selectors.ListItem.configButton));
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
import { useWindowSize } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data/src';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
|
||||
import { Link, ButtonGroup, LinkButton, Icon, Tag, useStyles2, Tooltip, useTheme2, Spinner } from '@grafana/ui/src';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { getConfig } from 'app/core/config';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { useListPublicDashboardsQuery } from 'app/features/dashboard/api/publicDashboardApi';
|
||||
import { isOrgAdmin } from 'app/features/plugins/admin/permissions';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { DeletePublicDashboardButton } from './DeletePublicDashboardButton';
|
||||
|
||||
export const viewPublicDashboardUrl = (accessToken: string): string =>
|
||||
`${getConfig().appUrl}public-dashboards/${accessToken}`;
|
||||
|
||||
export const PublicDashboardListTable = () => {
|
||||
const { width } = useWindowSize();
|
||||
const isMobile = width <= 480;
|
||||
const theme = useTheme2();
|
||||
const styles = useStyles2(() => getStyles(theme, isMobile));
|
||||
|
||||
const { data: publicDashboards, isLoading, isFetching } = useListPublicDashboardsQuery();
|
||||
|
||||
const selectors = e2eSelectors.pages.PublicDashboards;
|
||||
const hasWritePermissions = contextSrv.hasAccess(AccessControlAction.DashboardsPublicWrite, isOrgAdmin());
|
||||
const responsiveSize = isMobile ? 'sm' : 'md';
|
||||
|
||||
return (
|
||||
<Page.Contents isLoading={isLoading}>
|
||||
<div className="page-action-bar">
|
||||
<table className="filter-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.nameTh}>Name</th>
|
||||
<th>Status</th>
|
||||
<th className={styles.fetchingSpinner}>{isFetching && <Spinner />}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{publicDashboards?.map((pd) => (
|
||||
<tr key={pd.uid}>
|
||||
<td className={styles.titleTd}>
|
||||
<Tooltip content={pd.title} placement="top">
|
||||
<Link className={styles.link} href={`/d/${pd.dashboardUid}`}>
|
||||
{pd.title}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td>
|
||||
<Tag name={pd.isEnabled ? 'enabled' : 'disabled'} colorIndex={pd.isEnabled ? 20 : 15} />
|
||||
</td>
|
||||
<td>
|
||||
<ButtonGroup className={styles.buttonGroup}>
|
||||
<LinkButton
|
||||
href={viewPublicDashboardUrl(pd.accessToken)}
|
||||
fill="text"
|
||||
size={responsiveSize}
|
||||
title={pd.isEnabled ? 'View public dashboard' : 'Public dashboard is disabled'}
|
||||
target="_blank"
|
||||
disabled={!pd.isEnabled}
|
||||
data-testid={selectors.ListItem.linkButton}
|
||||
>
|
||||
<Icon size={responsiveSize} name="external-link-alt" />
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
fill="text"
|
||||
size={responsiveSize}
|
||||
href={`/d/${pd.dashboardUid}?shareView=share`}
|
||||
title="Configure public dashboard"
|
||||
data-testid={selectors.ListItem.configButton}
|
||||
>
|
||||
<Icon size={responsiveSize} name="cog" />
|
||||
</LinkButton>
|
||||
{hasWritePermissions && <DeletePublicDashboardButton publicDashboard={pd} size={responsiveSize} />}
|
||||
</ButtonGroup>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Page.Contents>
|
||||
);
|
||||
};
|
||||
|
||||
function getStyles(theme: GrafanaTheme2, isMobile: boolean) {
|
||||
return {
|
||||
fetchingSpinner: css`
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
`,
|
||||
link: css`
|
||||
color: ${theme.colors.primary.text};
|
||||
text-decoration: underline;
|
||||
margin-right: ${theme.spacing()};
|
||||
`,
|
||||
nameTh: css`
|
||||
width: 20%;
|
||||
`,
|
||||
titleTd: css`
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
buttonGroup: css`
|
||||
justify-content: ${isMobile ? 'space-between' : 'end'};
|
||||
`,
|
||||
};
|
||||
}
|
||||
@@ -17,3 +17,11 @@ export type DeleteDashboardResponse = {
|
||||
message: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export interface ListPublicDashboardResponse {
|
||||
uid: string;
|
||||
accessToken: string;
|
||||
dashboardUid: string;
|
||||
title: string;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user