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 18:16:07 -03:00
committed by GitHub
parent 6e3a6cce61
commit 48120f2594
19 changed files with 750 additions and 182 deletions

View File

@@ -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',
},
},
};

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.

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}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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);
// })
//})

View File

@@ -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()};
`,
};
}

View File

@@ -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>
);
};

View File

@@ -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};
`,
});

View File

@@ -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));
});
};

View File

@@ -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'};
`,
};
}

View File

@@ -17,3 +17,11 @@ export type DeleteDashboardResponse = {
message: string;
title: string;
};
export interface ListPublicDashboardResponse {
uid: string;
accessToken: string;
dashboardUid: string;
title: string;
isEnabled: boolean;
}