diff --git a/pkg/api/api.go b/pkg/api/api.go index ed88dd8bad0..f61a2232cae 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -140,6 +140,10 @@ func (hs *HTTPServer) registerRoutes() { } if hs.Features.IsEnabled(featuremgmt.FlagPublicDashboards) { + // list public dashboards + r.Get("/public-dashboards/list", reqSignedIn, hs.Index) + + // anonymous view public dashboard r.Get("/public-dashboards/:accessToken", publicdashboardsapi.SetPublicDashboardFlag, publicdashboardsapi.SetPublicDashboardOrgIdOnContext(hs.PublicDashboardsApi.PublicDashboardService), diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 4d0bacb27d8..65c1f3bfa25 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -381,6 +381,15 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b Url: s.cfg.AppSubURL + "/library-panels", Icon: "library-panel", }) + + if s.features.IsEnabled(featuremgmt.FlagPublicDashboards) { + dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ + Text: "Public dashboards", + Id: "dashboards/public", + Url: s.cfg.AppSubURL + "/dashboard/public", + Icon: "library-panel", + }) + } } if s.features.IsEnabled(featuremgmt.FlagScenes) { diff --git a/pkg/services/publicdashboards/api/api.go b/pkg/services/publicdashboards/api/api.go index c2245c07fac..848fe025610 100644 --- a/pkg/services/publicdashboards/api/api.go +++ b/pkg/services/publicdashboards/api/api.go @@ -63,9 +63,11 @@ func (api *Api) RegisterAPIEndpoints() { api.RouteRegister.Get("/api/public/dashboards/:accessToken", routing.Wrap(api.GetPublicDashboard)) api.RouteRegister.Post("/api/public/dashboards/:accessToken/panels/:panelId/query", routing.Wrap(api.QueryPublicDashboard)) + // List Public Dashboards + api.RouteRegister.Get("/api/dashboards/public", middleware.ReqSignedIn, routing.Wrap(api.ListPublicDashboards)) + // Create/Update Public Dashboard uidScope := dashboards.ScopeDashboardsProvider.GetResourceScopeUID(accesscontrol.Parameter(":uid")) - api.RouteRegister.Get("/api/dashboards/uid/:uid/public-config", auth(middleware.ReqSignedIn, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead, uidScope)), routing.Wrap(api.GetPublicDashboardConfig)) @@ -111,6 +113,16 @@ func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response { return response.JSON(http.StatusOK, dto) } +// Gets list of public dashboards for an org +// GET /api/dashboards/public +func (api *Api) ListPublicDashboards(c *models.ReqContext) response.Response { + resp, err := api.PublicDashboardService.ListPublicDashboards(c.Req.Context(), c.OrgID) + if err != nil { + return api.handleError(http.StatusInternalServerError, "failed to list public dashboards", err) + } + return response.JSON(http.StatusOK, resp) +} + // Gets public dashboard configuration for dashboard // GET /api/dashboards/uid/:uid/public-config func (api *Api) GetPublicDashboardConfig(c *models.ReqContext) response.Response { diff --git a/pkg/services/publicdashboards/api/api_test.go b/pkg/services/publicdashboards/api/api_test.go index 7fa8d3bdfba..0312fa61c73 100644 --- a/pkg/services/publicdashboards/api/api_test.go +++ b/pkg/services/publicdashboards/api/api_test.go @@ -46,30 +46,129 @@ var userViewer = &user.SignedInUser{UserID: 3, OrgID: 1, OrgRole: org.RoleViewer var userViewerRBAC = &user.SignedInUser{UserID: 4, OrgID: 1, OrgRole: org.RoleViewer, Login: "testViewerUserRBAC", Permissions: map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}}}} var anonymousUser *user.SignedInUser +type JsonErrResponse struct { + Error string `json:"error"` +} + +func TestAPIFeatureFlag(t *testing.T) { + testCases := []struct { + Name string + Method string + Path string + }{ + { + Name: "API: Load Dashboard", + Method: http.MethodGet, + Path: "/api/public/dashboards/acbc123", + }, + { + Name: "API: Query Dashboard", + Method: http.MethodGet, + Path: "/api/public/dashboards/abc123/panels/2/query", + }, + { + Name: "API: List Dashboards", + Method: http.MethodGet, + Path: "/api/dashboards/public", + }, + { + Name: "API: Get Public Dashboard Config", + Method: http.MethodPost, + Path: "/api/dashboards/uid/abc123/public-config", + }, + { + Name: "API: Upate Public Dashboard", + Method: http.MethodPost, + Path: "/api/dashboards/uid/abc123/public-config", + }, + } + + for _, test := range testCases { + t.Run(test.Name, func(t *testing.T) { + cfg := setting.NewCfg() + cfg.RBACEnabled = false + service := publicdashboards.NewFakePublicDashboardService(t) + features := featuremgmt.WithFeatures() + testServer := setupTestServer(t, cfg, features, service, nil, userAdmin) + response := callAPI(testServer, test.Method, test.Path, nil, t) + assert.Equal(t, http.StatusNotFound, response.Code) + }) + } +} + +func TestAPIListPublicDashboard(t *testing.T) { + successResp := []PublicDashboardListResponse{ + { + Uid: "1234asdfasdf", + AccessToken: "asdfasdf", + DashboardUid: "abc1234", + IsEnabled: true, + }, + } + + testCases := []struct { + Name string + User *user.SignedInUser + Response []PublicDashboardListResponse + ResponseErr error + ExpectedHttpResponse int + }{ + { + Name: "Anonymous user cannot list dashboards", + User: anonymousUser, + Response: successResp, + ResponseErr: nil, + ExpectedHttpResponse: http.StatusUnauthorized, + }, + { + Name: "User viewer can see public dashboards", + User: userViewer, + Response: successResp, + ResponseErr: nil, + ExpectedHttpResponse: http.StatusOK, + }, + { + Name: "Handles Service error", + User: userViewer, + Response: nil, + ResponseErr: errors.New("error, service broken"), + ExpectedHttpResponse: http.StatusInternalServerError, + }, + } + + for _, test := range testCases { + t.Run(test.Name, func(t *testing.T) { + service := publicdashboards.NewFakePublicDashboardService(t) + service.On("ListPublicDashboards", mock.Anything, mock.Anything). + Return(test.Response, test.ResponseErr).Maybe() + + cfg := setting.NewCfg() + cfg.RBACEnabled = false + features := featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards) + testServer := setupTestServer(t, cfg, features, service, nil, test.User) + + response := callAPI(testServer, http.MethodGet, "/api/dashboards/public", nil, t) + assert.Equal(t, test.ExpectedHttpResponse, response.Code) + + if test.ExpectedHttpResponse == http.StatusOK { + var jsonResp []PublicDashboardListResponse + err := json.Unmarshal(response.Body.Bytes(), &jsonResp) + require.NoError(t, err) + assert.Equal(t, jsonResp[0].Uid, "1234asdfasdf") + } + + if test.ResponseErr != nil { + var errResp JsonErrResponse + err := json.Unmarshal(response.Body.Bytes(), &errResp) + require.NoError(t, err) + assert.Equal(t, "error, service broken", errResp.Error) + service.AssertNotCalled(t, "ListPublicDashboards") + } + }) + } +} + func TestAPIGetPublicDashboard(t *testing.T) { - t.Run("It should 404 if featureflag is not enabled", func(t *testing.T) { - cfg := setting.NewCfg() - cfg.RBACEnabled = false - service := publicdashboards.NewFakePublicDashboardService(t) - service.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")). - Return(&PublicDashboard{}, &models.Dashboard{}, nil).Maybe() - service.On("GetPublicDashboardConfig", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")). - Return(&PublicDashboard{}, nil).Maybe() - - testServer := setupTestServer(t, cfg, featuremgmt.WithFeatures(), service, nil, anonymousUser) - - response := callAPI(testServer, http.MethodGet, "/api/public/dashboards", nil, t) - assert.Equal(t, http.StatusNotFound, response.Code) - - response = callAPI(testServer, http.MethodGet, "/api/public/dashboards/asdf", nil, t) - assert.Equal(t, http.StatusNotFound, response.Code) - - // control set. make sure routes are mounted - testServer = setupTestServer(t, cfg, featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards), service, nil, userAdmin) - response = callAPI(testServer, http.MethodGet, "/api/public/dashboards/asdf", nil, t) - assert.NotEqual(t, http.StatusNotFound, response.Code) - }) - DashboardUid := "dashboard-abcd1234" token, err := uuid.NewRandom() require.NoError(t, err) @@ -138,9 +237,7 @@ func TestAPIGetPublicDashboard(t *testing.T) { assert.Equal(t, false, dashResp.Meta.CanDelete) assert.Equal(t, false, dashResp.Meta.CanSave) } else { - var errResp struct { - Error string `json:"error"` - } + var errResp JsonErrResponse err := json.Unmarshal(response.Body.Bytes(), &errResp) require.NoError(t, err) assert.Equal(t, test.Err.Error(), errResp.Error) @@ -435,12 +532,6 @@ func TestAPIQueryPublicDashboard(t *testing.T) { return testServer, service } - t.Run("Status code is 404 when feature toggle is disabled", func(t *testing.T) { - server, _ := setup(false) - resp := callAPI(server, http.MethodPost, "/api/public/dashboards/abc123/panels/2/query", strings.NewReader("{}"), t) - require.Equal(t, http.StatusNotFound, resp.Code) - }) - t.Run("Status code is 400 when the panel ID is invalid", func(t *testing.T) { server, _ := setup(true) resp := callAPI(server, http.MethodPost, "/api/public/dashboards/abc123/panels/notanumber/query", strings.NewReader("{}"), t) diff --git a/pkg/services/publicdashboards/database/database.go b/pkg/services/publicdashboards/database/database.go index 3c4b001343f..a04d86250de 100644 --- a/pkg/services/publicdashboards/database/database.go +++ b/pkg/services/publicdashboards/database/database.go @@ -37,6 +37,28 @@ func ProvideStore(sqlStore *sqlstore.SQLStore) *PublicDashboardStoreImpl { } } +// Gets list of public dashboards by orgId +func (d *PublicDashboardStoreImpl) ListPublicDashboards(ctx context.Context, orgId int64) ([]PublicDashboardListResponse, error) { + resp := make([]PublicDashboardListResponse, 0) + + err := d.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) 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"). + Where("dashboard_public.org_id = ?", orgId). + OrderBy("is_enabled DESC, dashboard.title ASC") + + err := sess.Find(&resp) + return err + }) + + if err != nil { + return nil, err + } + + return resp, nil +} + func (d *PublicDashboardStoreImpl) GetDashboard(ctx context.Context, dashboardUid string) (*models.Dashboard, error) { dashboard := &models.Dashboard{Uid: dashboardUid} err := d.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { diff --git a/pkg/services/publicdashboards/database/database_test.go b/pkg/services/publicdashboards/database/database_test.go index 82200966860..d3bf871a250 100644 --- a/pkg/services/publicdashboards/database/database_test.go +++ b/pkg/services/publicdashboards/database/database_test.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" dashboardsDB "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/publicdashboards/internal/tokens" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" @@ -29,6 +30,39 @@ func TestLogPrefix(t *testing.T) { assert.Equal(t, LogPrefix, "publicdashboards.store") } +func TestIntegrationListPublicDashboard(t *testing.T) { + sqlStore := sqlstore.InitTestDB(t, sqlstore.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagPublicDashboards}}) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg)) + publicdashboardStore := ProvideStore(sqlStore) + + var orgId int64 = 1 + + aDash := insertTestDashboard(t, dashboardStore, "a", orgId, 0, true) + bDash := insertTestDashboard(t, dashboardStore, "b", orgId, 0, true) + cDash := insertTestDashboard(t, dashboardStore, "c", orgId, 0, true) + + // these are in order of how they should be returned from ListPUblicDashboards + a := insertPublicDashboard(t, publicdashboardStore, bDash.Uid, orgId, true) + b := insertPublicDashboard(t, publicdashboardStore, cDash.Uid, orgId, true) + c := insertPublicDashboard(t, publicdashboardStore, aDash.Uid, orgId, false) + + // this is case that can happen as of now, however, postgres and mysql sort + // null in the exact opposite fashion and there is no shared syntax to sort + // nulls in the same way in all 3 db's. + //d := insertPublicDashboard(t, publicdashboardStore, "missing", orgId, false) + + // should not be included in response + _ = insertPublicDashboard(t, publicdashboardStore, "wrongOrgId", 777, false) + + resp, err := publicdashboardStore.ListPublicDashboards(context.Background(), orgId) + require.NoError(t, err) + + assert.Len(t, resp, 3) + assert.Equal(t, resp[0].Uid, a.Uid) + assert.Equal(t, resp[1].Uid, b.Uid) + assert.Equal(t, resp[2].Uid, c.Uid) +} + func TestIntegrationGetDashboard(t *testing.T) { var sqlStore *sqlstore.SQLStore var dashboardStore *dashboardsDB.DashboardStore @@ -506,7 +540,7 @@ func TestIntegrationGetPublicDashboardOrgId(t *testing.T) { }) } -// helper function insertTestDashboard +// 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 { t.Helper() @@ -527,3 +561,35 @@ func insertTestDashboard(t *testing.T, dashboardStore *dashboardsDB.DashboardSto dash.Data.Set("uid", dash.Uid) return dash } + +// helper function to insert a public dashboard +func insertPublicDashboard(t *testing.T, publicdashboardStore *PublicDashboardStoreImpl, dashboardUid string, orgId int64, isEnabled bool) *PublicDashboard { + ctx := context.Background() + + uid, err := publicdashboardStore.GenerateNewPublicDashboardUid(ctx) + require.NoError(t, err) + + accessToken, err := tokens.GenerateAccessToken() + require.NoError(t, err) + + cmd := SavePublicDashboardConfigCommand{ + PublicDashboard: PublicDashboard{ + Uid: uid, + DashboardUid: dashboardUid, + OrgId: orgId, + IsEnabled: isEnabled, + TimeSettings: &TimeSettings{}, + CreatedBy: 1, + CreatedAt: time.Now(), + AccessToken: accessToken, + }, + } + + err = publicdashboardStore.SavePublicDashboardConfig(ctx, cmd) + require.NoError(t, err) + + pubdash, err := publicdashboardStore.GetPublicDashboardByUid(ctx, uid) + require.NoError(t, err) + + return pubdash +} diff --git a/pkg/services/publicdashboards/models/models.go b/pkg/services/publicdashboards/models/models.go index 0ba668f2727..68dedd7315a 100644 --- a/pkg/services/publicdashboards/models/models.go +++ b/pkg/services/publicdashboards/models/models.go @@ -81,6 +81,14 @@ func (pd PublicDashboard) TableName() string { return "dashboard_public" } +type PublicDashboardListResponse struct { + Uid string `json:"uid" xorm:"uid"` + AccessToken string `json:"accessToken" xorm:"access_token"` + Title string `json:"title" xorm:"title"` + DashboardUid string `json:"dashboardUid" xorm:"dashboard_uid"` + IsEnabled bool `json:"isEnabled" xorm:"is_enabled"` +} + type TimeSettings struct { From string `json:"from,omitempty"` To string `json:"to,omitempty"` diff --git a/pkg/services/publicdashboards/public_dashboard_service_mock.go b/pkg/services/publicdashboards/public_dashboard_service_mock.go index 2deb1b68694..8be2bef0a1d 100644 --- a/pkg/services/publicdashboards/public_dashboard_service_mock.go +++ b/pkg/services/publicdashboards/public_dashboard_service_mock.go @@ -212,6 +212,29 @@ func (_m *FakePublicDashboardService) GetQueryDataResponse(ctx context.Context, return r0, r1 } +// ListPublicDashboards provides a mock function with given fields: ctx, orgId +func (_m *FakePublicDashboardService) ListPublicDashboards(ctx context.Context, orgId int64) ([]publicdashboardsmodels.PublicDashboardListResponse, error) { + ret := _m.Called(ctx, orgId) + + var r0 []publicdashboardsmodels.PublicDashboardListResponse + if rf, ok := ret.Get(0).(func(context.Context, int64) []publicdashboardsmodels.PublicDashboardListResponse); ok { + r0 = rf(ctx, orgId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]publicdashboardsmodels.PublicDashboardListResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { + r1 = rf(ctx, orgId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // PublicDashboardEnabled provides a mock function with given fields: ctx, dashboardUid func (_m *FakePublicDashboardService) PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error) { ret := _m.Called(ctx, dashboardUid) diff --git a/pkg/services/publicdashboards/public_dashboard_store_mock.go b/pkg/services/publicdashboards/public_dashboard_store_mock.go index 441f696cba3..3daf07021b7 100644 --- a/pkg/services/publicdashboards/public_dashboard_store_mock.go +++ b/pkg/services/publicdashboards/public_dashboard_store_mock.go @@ -182,6 +182,29 @@ func (_m *FakePublicDashboardStore) GetPublicDashboardOrgId(ctx context.Context, return r0, r1 } +// ListPublicDashboards provides a mock function with given fields: ctx, orgId +func (_m *FakePublicDashboardStore) ListPublicDashboards(ctx context.Context, orgId int64) ([]publicdashboardsmodels.PublicDashboardListResponse, error) { + ret := _m.Called(ctx, orgId) + + var r0 []publicdashboardsmodels.PublicDashboardListResponse + if rf, ok := ret.Get(0).(func(context.Context, int64) []publicdashboardsmodels.PublicDashboardListResponse); ok { + r0 = rf(ctx, orgId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]publicdashboardsmodels.PublicDashboardListResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { + r1 = rf(ctx, orgId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // PublicDashboardEnabled provides a mock function with given fields: ctx, dashboardUid func (_m *FakePublicDashboardStore) PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error) { ret := _m.Called(ctx, dashboardUid) diff --git a/pkg/services/publicdashboards/publicdashboard.go b/pkg/services/publicdashboards/publicdashboard.go index ccd235899cc..5e6f420b319 100644 --- a/pkg/services/publicdashboards/publicdashboard.go +++ b/pkg/services/publicdashboards/publicdashboard.go @@ -22,6 +22,7 @@ type Service interface { GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*PublicDashboard, error) GetPublicDashboardOrgId(ctx context.Context, accessToken string) (int64, error) GetQueryDataResponse(ctx context.Context, skipCache bool, reqDTO PublicDashboardQueryDTO, panelId int64, accessToken string) (*backend.QueryDataResponse, error) + ListPublicDashboards(ctx context.Context, orgId int64) ([]PublicDashboardListResponse, error) PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error) SavePublicDashboardConfig(ctx context.Context, u *user.SignedInUser, dto *SavePublicDashboardConfigDTO) (*PublicDashboard, error) } @@ -35,6 +36,7 @@ type Store interface { GetPublicDashboardByUid(ctx context.Context, uid string) (*PublicDashboard, error) GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*PublicDashboard, error) GetPublicDashboardOrgId(ctx context.Context, accessToken string) (int64, error) + ListPublicDashboards(ctx context.Context, orgId int64) ([]PublicDashboardListResponse, error) PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error) SavePublicDashboardConfig(ctx context.Context, cmd SavePublicDashboardConfigCommand) error UpdatePublicDashboardConfig(ctx context.Context, cmd SavePublicDashboardConfigCommand) error diff --git a/pkg/services/publicdashboards/service/service.go b/pkg/services/publicdashboards/service/service.go index dc4ea02e0bc..7e483bcf5ee 100644 --- a/pkg/services/publicdashboards/service/service.go +++ b/pkg/services/publicdashboards/service/service.go @@ -54,6 +54,12 @@ func ProvideService( } } +// Gets a list of public dashboards by orgId +func (pd *PublicDashboardServiceImpl) ListPublicDashboards(ctx context.Context, orgId int64) ([]PublicDashboardListResponse, error) { + return pd.store.ListPublicDashboards(ctx, orgId) +} + +// Gets a dashboard by Uid func (pd *PublicDashboardServiceImpl) GetDashboard(ctx context.Context, dashboardUid string) (*models.Dashboard, error) { dashboard, err := pd.store.GetDashboard(ctx, dashboardUid) diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index 87bcd84390e..e03bba4c5ba 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -1,4 +1,4 @@ -import React, { FC, ReactNode } from 'react'; +import React, { FC, ReactNode, useContext, useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { useLocation } from 'react-router-dom'; @@ -13,6 +13,7 @@ import { useForceUpdate, Tag, ToolbarButtonRow, + ModalsContext, } from '@grafana/ui'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbarSeparator'; @@ -51,6 +52,7 @@ export interface OwnProps { hideTimePicker: boolean; folderTitle?: string; title: string; + shareModalActiveTab?: string; onAddPanel: () => void; } @@ -76,6 +78,7 @@ type Props = OwnProps & ConnectedProps; export const DashNav = React.memo((props) => { const forceUpdate = useForceUpdate(); const { chrome } = useGrafana(); + const { showModal, hideModal } = useContext(ModalsContext); // We don't really care about the event payload here only that it triggeres a re-render of this component useBusEvent(props.dashboard.events, DashboardMetaChangedEvent); @@ -128,6 +131,25 @@ export const DashNav = React.memo((props) => { return playlistSrv.isPlaying; }; + // Open/Close + useEffect(() => { + const dashboard = props.dashboard; + const shareModalActiveTab = props.shareModalActiveTab; + const { canShare } = dashboard.meta; + + if (canShare && shareModalActiveTab) { + // automagically open modal + showModal(ShareModal, { + dashboard, + onDismiss: hideModal, + activeTab: shareModalActiveTab, + }); + } + return () => { + hideModal(); + }; + }, [showModal, hideModal, props.dashboard, props.shareModalActiveTab]); + const renderLeftActions = () => { const { dashboard, kioskMode } = props; const { canStar, canShare, isStarred } = dashboard.meta; diff --git a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx index 9c83c52e1e2..8064761de1e 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx @@ -28,15 +28,16 @@ export function addPanelShareTab(tab: ShareModalTabModel) { } function getInitialState(props: Props): State { - const tabs = getTabs(props); + const { tabs, activeTab } = getTabs(props); + return { tabs, - activeTab: tabs[0].value, + activeTab, }; } function getTabs(props: Props) { - const { panel } = props; + const { panel, activeTab } = props; const linkLabel = t('share-modal.tab-title.link', 'Link'); const tabs: ShareModalTabModel[] = [{ label: linkLabel, value: 'link', component: ShareLink }]; @@ -65,12 +66,18 @@ function getTabs(props: Props) { tabs.push({ label: 'Public dashboard', value: 'share', component: SharePublicDashboard }); } - return tabs; + const at = tabs.find((t) => t.value === activeTab); + + return { + tabs, + activeTab: at?.value ?? tabs[0].value, + }; } interface Props { dashboard: DashboardModel; panel?: PanelModel; + activeTab?: string; onDismiss(): void; } @@ -95,7 +102,7 @@ export class ShareModal extends React.Component { }; getTabs() { - return getTabs(this.props); + return getTabs(this.props).tabs; } getActiveTab() { @@ -107,13 +114,12 @@ export class ShareModal extends React.Component { const { panel } = this.props; const { activeTab } = this.state; const title = panel ? t('share-modal.panel.title', 'Share Panel') : t('share-modal.dashboard.title', 'Share'); - const tabs = this.getTabs(); return ( diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx index 690fc241f97..663b66ba635 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx @@ -18,7 +18,7 @@ import { configureStore } from 'app/store/configureStore'; import { ShareModal } from '../ShareModal'; const server = setupServer( - rest.get('/api/dashboards/uid/:uId/public-config', (req, res, ctx) => { + rest.get('/api/dashboards/uid/:uId/public-config', (_, res, ctx) => { return res( ctx.status(200), ctx.json({ diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.test.tsx index 3278190b022..2579d2bd866 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils.test.tsx @@ -4,8 +4,8 @@ import { VariableModel } from 'app/features/variables/types'; import { PublicDashboard, dashboardHasTemplateVariables, - generatePublicDashboardUrl, publicDashboardPersisted, + generatePublicDashboardUrl, } from './SharePublicDashboardUtils'; describe('dashboardHasTemplateVariables', () => { diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 885607f5a0b..3e2d1aa2c9c 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -49,6 +49,7 @@ export type DashboardPageRouteSearchParams = { editPanel?: string; viewPanel?: string; editview?: string; + shareView?: string; panelType?: string; inspect?: string; from?: string; @@ -352,6 +353,7 @@ export class UnthemedDashboardPage extends PureComponent { onAddPanel={this.onAddPanel} kioskMode={kioskMode} hideTimePicker={dashboard.timepicker.hidden} + shareModalActiveTab={this.props.queryParams.shareView} /> ); diff --git a/public/app/features/dashboard/routes.ts b/public/app/features/dashboard/routes.ts index 526b35587fb..6e515cd04b5 100644 --- a/public/app/features/dashboard/routes.ts +++ b/public/app/features/dashboard/routes.ts @@ -6,6 +6,17 @@ import { DashboardRoutes } from '../../types'; export const getPublicDashboardRoutes = (): RouteDescriptor[] => { if (config.featureToggles.publicDashboards) { return [ + { + path: '/dashboard/public', + pageClass: 'page-dashboard', + routeName: DashboardRoutes.Public, + component: SafeDynamicImport( + () => + import( + /* webpackChunkName: "ListPublicDashboardPage" */ '../../features/manage-dashboards/PublicDashboardListPage' + ) + ), + }, { path: '/public-dashboards/:accessToken', pageClass: 'page-dashboard', diff --git a/public/app/features/manage-dashboards/PublicDashboardListPage.tsx b/public/app/features/manage-dashboards/PublicDashboardListPage.tsx new file mode 100644 index 00000000000..673ff8f335a --- /dev/null +++ b/public/app/features/manage-dashboards/PublicDashboardListPage.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Page } from 'app/core/components/Page/Page'; + +import { ListPublicDashboardTable } from './components/PublicDashboardListTable'; + +export const ListPublicDashboardPage = ({}) => { + return ( + + + + + + ); +}; + +export default ListPublicDashboardPage; diff --git a/public/app/features/manage-dashboards/components/PublicDashboardListTable.test.tsx b/public/app/features/manage-dashboards/components/PublicDashboardListTable.test.tsx new file mode 100644 index 00000000000..32452d435c6 --- /dev/null +++ b/public/app/features/manage-dashboards/components/PublicDashboardListTable.test.tsx @@ -0,0 +1,54 @@ +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'); + }); +}); + +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 new file mode 100644 index 00000000000..0cb6dfd4e56 --- /dev/null +++ b/public/app/features/manage-dashboards/components/PublicDashboardListTable.tsx @@ -0,0 +1,94 @@ +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`; +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) => ( + + + + + + ))} + +
DashboardPublic dashboard enabled
+ + {pd.title} + + + + + + + + + + + + + +
+
+ ); +}; + +function getStyles(theme: GrafanaTheme2) { + return { + link: css` + color: ${theme.colors.primary.text}; + text-decoration: underline; + margin-right: ${theme.spacing()}; + `, + }; +}