diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index ef80af69a29..9b968cb284d 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -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', + }, + }, }; diff --git a/pkg/services/publicdashboards/api/api.go b/pkg/services/publicdashboards/api/api.go index 231dcca44b0..d0766c95634 100644 --- a/pkg/services/publicdashboards/api/api.go +++ b/pkg/services/publicdashboards/api/api.go @@ -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. diff --git a/pkg/services/publicdashboards/api/api_test.go b/pkg/services/publicdashboards/api/api_test.go index 5f4b10092e6..faffc2071f2 100644 --- a/pkg/services/publicdashboards/api/api_test.go +++ b/pkg/services/publicdashboards/api/api_test.go @@ -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} diff --git a/pkg/services/publicdashboards/database/database.go b/pkg/services/publicdashboards/database/database.go index b6231184ad3..080bb2f1c1a 100644 --- a/pkg/services/publicdashboards/database/database.go +++ b/pkg/services/publicdashboards/database/database.go @@ -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 diff --git a/pkg/services/publicdashboards/database/database_test.go b/pkg/services/publicdashboards/database/database_test.go index 1434cfea8e5..b39c86f4278 100644 --- a/pkg/services/publicdashboards/database/database_test.go +++ b/pkg/services/publicdashboards/database/database_test.go @@ -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 { diff --git a/pkg/services/publicdashboards/public_dashboard_service_mock.go b/pkg/services/publicdashboards/public_dashboard_service_mock.go index 323e56878cd..a6b7d51e853 100644 --- a/pkg/services/publicdashboards/public_dashboard_service_mock.go +++ b/pkg/services/publicdashboards/public_dashboard_service_mock.go @@ -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) diff --git a/pkg/services/publicdashboards/public_dashboard_store_mock.go b/pkg/services/publicdashboards/public_dashboard_store_mock.go index 0b301ce685b..eafc1b92a50 100644 --- a/pkg/services/publicdashboards/public_dashboard_store_mock.go +++ b/pkg/services/publicdashboards/public_dashboard_store_mock.go @@ -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) diff --git a/pkg/services/publicdashboards/publicdashboard.go b/pkg/services/publicdashboards/publicdashboard.go index e721002079c..889b58188d7 100644 --- a/pkg/services/publicdashboards/publicdashboard.go +++ b/pkg/services/publicdashboards/publicdashboard.go @@ -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) diff --git a/pkg/services/publicdashboards/service/service.go b/pkg/services/publicdashboards/service/service.go index 1a92d8e3409..766008e43ef 100644 --- a/pkg/services/publicdashboards/service/service.go +++ b/pkg/services/publicdashboards/service/service.go @@ -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 diff --git a/pkg/services/publicdashboards/service/service_test.go b/pkg/services/publicdashboards/service/service_test.go index 939a20c0f83..36d37601377 100644 --- a/pkg/services/publicdashboards/service/service_test.go +++ b/pkg/services/publicdashboards/service/service_test.go @@ -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() diff --git a/public/app/features/dashboard/api/publicDashboardApi.ts b/public/app/features/dashboard/api/publicDashboardApi.ts index df8bacf75f3..6c4b6ca625d 100644 --- a/public/app/features/dashboard/api/publicDashboardApi.ts +++ b/public/app/features/dashboard/api/publicDashboardApi.ts @@ -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({ @@ -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({ + query: () => ({ + url: '/public-dashboards', + }), + providesTags: ['PublicDashboards'], + }), + deletePublicDashboard: builder.mutation({ + 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; diff --git a/public/app/features/manage-dashboards/PublicDashboardListPage.tsx b/public/app/features/manage-dashboards/PublicDashboardListPage.tsx index 673ff8f335a..07f7ab3f950 100644 --- a/public/app/features/manage-dashboards/PublicDashboardListPage.tsx +++ b/public/app/features/manage-dashboards/PublicDashboardListPage.tsx @@ -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 ( - - - + ); }; diff --git a/public/app/features/manage-dashboards/components/PublicDashboardListTable.test.tsx b/public/app/features/manage-dashboards/components/PublicDashboardListTable.test.tsx deleted file mode 100644 index 325be4456cf..00000000000 --- a/public/app/features/manage-dashboards/components/PublicDashboardListTable.test.tsx +++ /dev/null @@ -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() -// }); - -// //await waitFor(() => screen.getByRole('table')); -// expect(screen.getByText("Dashboard")).toBeInTheDocument(); -// //expect(screen.getAllByRole("tr")).toHaveLength(2); -// }) -//}) diff --git a/public/app/features/manage-dashboards/components/PublicDashboardListTable.tsx b/public/app/features/manage-dashboards/components/PublicDashboardListTable.tsx deleted file mode 100644 index 50cfbb77813..00000000000 --- a/public/app/features/manage-dashboards/components/PublicDashboardListTable.tsx +++ /dev/null @@ -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 => { - 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([]); - - useAsync(async () => { - const publicDashboards = await getPublicDashboards(); - setPublicDashboards(publicDashboards); - }, [setPublicDashboards]); - - return ( -
- - - - - - - - - - - - {publicDashboards.map((pd) => ( - - - - - - - ))} - -
NameStatusPublic URLConfiguration
- - {pd.title} - - - - - - - - - - - - - - - -
-
- ); -}; - -function getStyles(theme: GrafanaTheme2) { - return { - link: css` - color: ${theme.colors.primary.text}; - text-decoration: underline; - margin-right: ${theme.spacing()}; - `, - }; -} diff --git a/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardButton.tsx b/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardButton.tsx new file mode 100644 index 00000000000..837098f1a4c --- /dev/null +++ b/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardButton.tsx @@ -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 ( + + {({ showModal, hideModal }) => ( + + )} + + ); +}; diff --git a/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardModal.tsx b/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardModal.tsx new file mode 100644 index 00000000000..aa5dacc86e1 --- /dev/null +++ b/public/app/features/manage-dashboards/components/PublicDashboardListTable/DeletePublicDashboardModal.tsx @@ -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 ( + <> +

Do you want to delete this public dashboard?

+

+ {title + ? `This will delete the public dashboard for ${title}. Your dashboard will not be deleted.` + : 'Orphaned public dashboard will be deleted'} +

+ + ); +}; + +export const DeletePublicDashboardModal = ({ + dashboardTitle, + onConfirm, + onDismiss, +}: { + dashboardTitle?: string; + onConfirm: () => void; + onDismiss: () => void; +}) => ( + } + 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}; + `, +}); diff --git a/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.test.tsx b/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.test.tsx new file mode 100644 index 00000000000..a6fbb91e907 --- /dev/null +++ b/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.test.tsx @@ -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( + + + + + + ); + + 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)); + }); +}; diff --git a/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.tsx b/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.tsx new file mode 100644 index 00000000000..3470524a766 --- /dev/null +++ b/public/app/features/manage-dashboards/components/PublicDashboardListTable/PublicDashboardListTable.tsx @@ -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 ( + +
+ + + + + + + + + + {publicDashboards?.map((pd) => ( + + + + + + ))} + +
NameStatus{isFetching && }
+ + + {pd.title} + + + + + + + + + + + + + {hasWritePermissions && } + +
+
+
+ ); +}; + +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'}; + `, + }; +} diff --git a/public/app/features/manage-dashboards/types.ts b/public/app/features/manage-dashboards/types.ts index 41a746e254e..b33aa67f077 100644 --- a/public/app/features/manage-dashboards/types.ts +++ b/public/app/features/manage-dashboards/types.ts @@ -17,3 +17,11 @@ export type DeleteDashboardResponse = { message: string; title: string; }; + +export interface ListPublicDashboardResponse { + uid: string; + accessToken: string; + dashboardUid: string; + title: string; + isEnabled: boolean; +}