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:
juanicabanas
2022-10-31 17:16:07 -04:00
committed by GitHub
co-authored by Jeff Levin
parent 6e3a6cce61
commit 48120f2594
19 changed files with 750 additions and 182 deletions
@@ -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',
},
},
};
+21
View File
@@ -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.
+132 -4
View File
@@ -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;
}