WIP: rough prototype of dashboard folders

Breaks some stuff like selected dash in the search result.
In dashboard search, if the user is not searching then the result is
returned as a tree structure. No ACL's or user group ux yet.
This commit is contained in:
Daniel Lee 2017-03-27 14:36:28 +02:00
parent d10d897d65
commit 1248728d7f
15 changed files with 306 additions and 45 deletions

View File

@ -14,6 +14,7 @@ func Search(c *middleware.Context) {
tags := c.QueryStrings("tag")
starred := c.Query("starred")
limit := c.QueryInt("limit")
browseMode := c.Query("browseMode")
if limit == 0 {
limit = 1000
@ -35,6 +36,7 @@ func Search(c *middleware.Context) {
IsStarred: starred == "true",
OrgId: c.OrgId,
DashboardIds: dbids,
BrowseMode: browseMode == "true",
}
err := bus.Dispatch(&searchQuery)

View File

@ -18,6 +18,14 @@ var (
ErrDashboardTitleEmpty = errors.New("Dashboard title cannot be empty")
)
type PermissionType int
const (
PERMISSION_EDIT PermissionType = 4
PERMISSION_READ_ONLY_EDIT PermissionType = 2
PERMISSION_VIEW PermissionType = 1
)
type UpdatePluginDashboardError struct {
PluginId string
}
@ -47,6 +55,8 @@ type Dashboard struct {
UpdatedBy int64
CreatedBy int64
ParentId int64
IsFolder bool
Title string
Data *simplejson.Json
@ -111,6 +121,8 @@ func (cmd *SaveDashboardCommand) GetDashboardModel() *Dashboard {
dash.UpdatedBy = userId
dash.OrgId = cmd.OrgId
dash.PluginId = cmd.PluginId
dash.IsFolder = cmd.IsFolder
dash.ParentId = cmd.ParentId
dash.UpdateSlug()
return dash
}
@ -138,6 +150,8 @@ type SaveDashboardCommand struct {
OrgId int64 `json:"-"`
RestoredFrom int `json:"-"`
PluginId string `json:"-"`
ParentId int64 `json:"parentId"`
IsFolder bool `json:"isFolder"`
Result *Dashboard
}

13
pkg/models/user_group.go Normal file
View File

@ -0,0 +1,13 @@
package models
import "time"
// UserGroup model
type UserGroup struct {
Id int64
OrgId int64
Name string
Created time.Time
Updated time.Time
}

View File

@ -44,6 +44,7 @@ func searchHandler(query *Query) error {
IsStarred: query.IsStarred,
OrgId: query.OrgId,
DashboardIds: query.DashboardIds,
BrowseMode: query.BrowseMode,
}
if err := bus.Dispatch(&dashQuery); err != nil {

View File

@ -19,6 +19,9 @@ func TestSearch(t *testing.T) {
&Hit{Id: 16, Title: "CCAA", Tags: []string{"BB", "AA"}},
&Hit{Id: 10, Title: "AABB", Tags: []string{"CC", "AA"}},
&Hit{Id: 15, Title: "BBAA", Tags: []string{"EE", "AA", "BB"}},
&Hit{Id: 17, Title: "FOLDER", Dashboards: []Hit{
{Id: 18, Title: "ZZAA", Tags: []string{"ZZ"}},
}},
}
return nil
})
@ -57,5 +60,17 @@ func TestSearch(t *testing.T) {
})
})
Convey("That returns result in browse mode", func() {
query.BrowseMode = true
err := searchHandler(&query)
So(err, ShouldBeNil)
Convey("should return correct results", func() {
So(query.Result[3].Title, ShouldEqual, "FOLDER")
So(len(query.Result[3].Dashboards), ShouldEqual, 1)
})
})
})
}

View File

@ -7,15 +7,18 @@ const (
DashHitHome HitType = "dash-home"
DashHitJson HitType = "dash-json"
DashHitScripted HitType = "dash-scripted"
DashHitFolder HitType = "dash-folder"
)
type Hit struct {
Id int64 `json:"id"`
Title string `json:"title"`
Uri string `json:"uri"`
Type HitType `json:"type"`
Tags []string `json:"tags"`
IsStarred bool `json:"isStarred"`
Id int64 `json:"id"`
Title string `json:"title"`
Uri string `json:"uri"`
Type HitType `json:"type"`
Tags []string `json:"tags"`
IsStarred bool `json:"isStarred"`
ParentId int64 `json:"parentId"`
Dashboards []Hit `json:"dashboards"`
}
type HitList []*Hit
@ -32,6 +35,7 @@ type Query struct {
Limit int
IsStarred bool
DashboardIds []int
BrowseMode bool
Result HitList
}
@ -42,6 +46,7 @@ type FindPersistedDashboardsQuery struct {
UserId int64
IsStarred bool
DashboardIds []int
BrowseMode bool
Result HitList
}

View File

@ -12,7 +12,7 @@ func TestAlertingDataAccess(t *testing.T) {
Convey("Testing Alerting data access", t, func() {
InitTestDB(t)
testDash := insertTestDashboard("dashboard with alerts", 1, "alert")
testDash := insertTestDashboard("dashboard with alerts", 1, 0, false, "alert")
items := []*m.Alert{
{

View File

@ -148,13 +148,15 @@ func GetDashboard(query *m.GetDashboardQuery) error {
}
type DashboardSearchProjection struct {
Id int64
Title string
Slug string
Term string
Id int64
Title string
Slug string
Term string
IsFolder bool
ParentId int64
}
func SearchDashboards(query *search.FindPersistedDashboardsQuery) error {
func findDashboards(query *search.FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) {
var sql bytes.Buffer
params := make([]interface{}, 0)
@ -162,7 +164,9 @@ func SearchDashboards(query *search.FindPersistedDashboardsQuery) error {
dashboard.id,
dashboard.title,
dashboard.slug,
dashboard_tag.term
dashboard_tag.term,
dashboard.is_folder,
dashboard.parent_id
FROM dashboard
LEFT OUTER JOIN dashboard_tag on dashboard_tag.dashboard_id = dashboard.id`)
@ -200,8 +204,16 @@ func SearchDashboards(query *search.FindPersistedDashboardsQuery) error {
sql.WriteString(fmt.Sprintf(" ORDER BY dashboard.title ASC LIMIT 1000"))
var res []DashboardSearchProjection
err := x.Sql(sql.String(), params...).Find(&res)
if err != nil {
return nil, err
}
return res, nil
}
func SearchDashboards(query *search.FindPersistedDashboardsQuery) error {
res, err := findDashboards(query)
if err != nil {
return err
}
@ -213,11 +225,12 @@ func SearchDashboards(query *search.FindPersistedDashboardsQuery) error {
hit, exists := hits[item.Id]
if !exists {
hit = &search.Hit{
Id: item.Id,
Title: item.Title,
Uri: "db/" + item.Slug,
Type: search.DashHitDB,
Tags: []string{},
Id: item.Id,
Title: item.Title,
Uri: "db/" + item.Slug,
Type: getHitType(item),
ParentId: item.ParentId,
Tags: []string{},
}
query.Result = append(query.Result, hit)
hits[item.Id] = hit
@ -227,9 +240,52 @@ func SearchDashboards(query *search.FindPersistedDashboardsQuery) error {
}
}
if query.BrowseMode {
convertToDashboardFolders(query)
}
return err
}
func getHitType(item DashboardSearchProjection) search.HitType {
var hitType search.HitType
if item.IsFolder {
hitType = search.DashHitFolder
} else {
hitType = search.DashHitDB
}
return hitType
}
func convertToDashboardFolders(query *search.FindPersistedDashboardsQuery) error {
root := make(map[int64]*search.Hit)
var keys []int64
// Add dashboards and folders that should be at the root level
for _, item := range query.Result {
if item.Type == search.DashHitFolder || item.ParentId == 0 {
root[item.Id] = item
keys = append(keys, item.Id)
}
}
// Populate folders with their child dashboards
for _, item := range query.Result {
if item.Type == search.DashHitDB && item.ParentId > 0 {
root[item.ParentId].Dashboards = append(root[item.ParentId].Dashboards, *item)
}
}
query.Result = make([]*search.Hit, 0)
for _, key := range keys {
query.Result = append(query.Result, root[key])
}
return nil
}
func GetDashboardTags(query *m.GetDashboardTagsQuery) error {
sql := `SELECT
COUNT(*) as count,

View File

@ -11,9 +11,11 @@ import (
"github.com/grafana/grafana/pkg/services/search"
)
func insertTestDashboard(title string, orgId int64, tags ...interface{}) *m.Dashboard {
func insertTestDashboard(title string, orgId int64, parentId int64, isFolder bool, tags ...interface{}) *m.Dashboard {
cmd := m.SaveDashboardCommand{
OrgId: orgId,
OrgId: orgId,
ParentId: parentId,
IsFolder: isFolder,
Dashboard: simplejson.NewFromAny(map[string]interface{}{
"id": nil,
"title": title,
@ -33,14 +35,23 @@ func TestDashboardDataAccess(t *testing.T) {
InitTestDB(t)
Convey("Given saved dashboard", func() {
savedDash := insertTestDashboard("test dash 23", 1, "prod", "webapp")
insertTestDashboard("test dash 45", 1, "prod")
insertTestDashboard("test dash 67", 1, "prod", "webapp")
savedFolder := insertTestDashboard("1 test dash folder", 1, 0, true, "prod", "webapp")
savedDash := insertTestDashboard("test dash 23", 1, savedFolder.Id, false, "prod", "webapp")
insertTestDashboard("test dash 45", 1, savedFolder.Id, false, "prod")
insertTestDashboard("test dash 67", 1, 0, false, "prod", "webapp")
Convey("Should return dashboard model", func() {
So(savedDash.Title, ShouldEqual, "test dash 23")
So(savedDash.Slug, ShouldEqual, "test-dash-23")
So(savedDash.Id, ShouldNotEqual, 0)
So(savedDash.IsFolder, ShouldBeFalse)
So(savedDash.ParentId, ShouldBeGreaterThan, 0)
So(savedFolder.Title, ShouldEqual, "1 test dash folder")
So(savedFolder.Slug, ShouldEqual, "1-test-dash-folder")
So(savedFolder.Id, ShouldNotEqual, 0)
So(savedFolder.IsFolder, ShouldBeTrue)
So(savedFolder.ParentId, ShouldEqual, 0)
})
Convey("Should be able to get dashboard", func() {
@ -54,10 +65,11 @@ func TestDashboardDataAccess(t *testing.T) {
So(query.Result.Title, ShouldEqual, "test dash 23")
So(query.Result.Slug, ShouldEqual, "test-dash-23")
So(query.Result.IsFolder, ShouldBeFalse)
})
Convey("Should be able to delete dashboard", func() {
insertTestDashboard("delete me", 1, "delete this")
insertTestDashboard("delete me", 1, 0, false, "delete this")
dashboardSlug := slug.Make("delete me")
@ -114,12 +126,45 @@ func TestDashboardDataAccess(t *testing.T) {
So(len(query.Result), ShouldEqual, 1)
hit := query.Result[0]
So(len(hit.Tags), ShouldEqual, 2)
So(hit.Type, ShouldEqual, search.DashHitDB)
So(hit.ParentId, ShouldBeGreaterThan, 0)
})
Convey("Should be able to search for dashboard folder", func() {
query := search.FindPersistedDashboardsQuery{
Title: "1 test dash folder",
OrgId: 1,
}
err := SearchDashboards(&query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 1)
hit := query.Result[0]
So(hit.Type, ShouldEqual, search.DashHitFolder)
})
Convey("Should be able to browse dashboard folders", func() {
query := search.FindPersistedDashboardsQuery{
OrgId: 1,
BrowseMode: true,
}
err := SearchDashboards(&query)
So(err, ShouldBeNil)
So(len(query.Result), ShouldEqual, 2)
hit := query.Result[0]
So(hit.Type, ShouldEqual, search.DashHitFolder)
So(len(hit.Dashboards), ShouldEqual, 2)
So(hit.Dashboards[0].Title, ShouldEqual, "test dash 23")
})
Convey("Should be able to search for dashboard by dashboard ids", func() {
Convey("should be able to find two dashboards by id", func() {
query := search.FindPersistedDashboardsQuery{
DashboardIds: []int{1, 2},
DashboardIds: []int{2, 3},
OrgId: 1,
}
@ -171,7 +216,7 @@ func TestDashboardDataAccess(t *testing.T) {
})
Convey("Given two dashboards, one is starred dashboard by user 10, other starred by user 1", func() {
starredDash := insertTestDashboard("starred dash", 1)
starredDash := insertTestDashboard("starred dash", 1, 0, false)
StarDashboard(&m.StarDashboardCommand{
DashboardId: starredDash.Id,
UserId: 10,

View File

@ -136,4 +136,40 @@ func addDashboardMigration(mg *Migrator) {
mg.AddMigration("Update dashboard_tag table charset", NewTableCharsetMigration("dashboard_tag", []*Column{
{Name: "term", Type: DB_NVarchar, Length: 50, Nullable: false},
}))
// add column to store parent_id for dashboard folder structure
mg.AddMigration("Add column parent_id in dashboard", NewAddColumnMigration(dashboardV2, &Column{
Name: "parent_id", Type: DB_BigInt, Nullable: true,
}))
mg.AddMigration("Add column isFolder in dashboard", NewAddColumnMigration(dashboardV2, &Column{
Name: "is_folder", Type: DB_Bool, Nullable: false, Default: "0",
}))
dashboardAclV1 := Table{
Name: "dashboard_acl",
Columns: []*Column{
{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "org_id", Type: DB_BigInt},
{Name: "dashboard_id", Type: DB_BigInt},
{Name: "user_id", Type: DB_BigInt, Nullable: true},
{Name: "user_group_id", Type: DB_BigInt, Nullable: true},
{Name: "permissions", Type: DB_SmallInt, Default: "4"},
{Name: "created", Type: DB_DateTime, Nullable: false},
{Name: "updated", Type: DB_DateTime, Nullable: false},
},
Indices: []*Index{
{Cols: []string{"org_id"}},
{Cols: []string{"dashboard_id", "user_id"}, Type: UniqueIndex},
{Cols: []string{"dashboard_id", "user_group_id"}, Type: UniqueIndex},
},
}
mg.AddMigration("create dashboard acl table", NewAddTableMigration(dashboardAclV1))
//------- indexes ------------------
mg.AddMigration("add unique index dashboard_acl_org_id", NewAddIndexMigration(dashboardAclV1, dashboardAclV1.Indices[0]))
mg.AddMigration("add unique index dashboard_acl_dashboard_id_user_id", NewAddIndexMigration(dashboardAclV1, dashboardAclV1.Indices[1]))
mg.AddMigration("add unique index dashboard_acl_dashboard_id_group_id", NewAddIndexMigration(dashboardAclV1, dashboardAclV1.Indices[2]))
}

View File

@ -26,6 +26,7 @@ func AddMigrations(mg *Migrator) {
addAnnotationMig(mg)
addTestDataMigrations(mg)
addDashboardVersionMigration(mg)
addUserGroupMigrations(mg)
}
func addMigrationLogMigrations(mg *Migrator) {

View File

@ -0,0 +1,48 @@
package migrations
import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
func addUserGroupMigrations(mg *Migrator) {
userGroupV1 := Table{
Name: "user_group",
Columns: []*Column{
{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "name", Type: DB_NVarchar, Length: 255, Nullable: false},
{Name: "org_id", Type: DB_BigInt},
{Name: "created", Type: DB_DateTime, Nullable: false},
{Name: "updated", Type: DB_DateTime, Nullable: false},
},
Indices: []*Index{
{Cols: []string{"org_id"}},
{Cols: []string{"org_id", "name"}, Type: UniqueIndex},
},
}
mg.AddMigration("create user group table", NewAddTableMigration(userGroupV1))
//------- indexes ------------------
mg.AddMigration("add index user_group.org_id", NewAddIndexMigration(userGroupV1, userGroupV1.Indices[0]))
mg.AddMigration("add unique index user_group_org_id_name", NewAddIndexMigration(userGroupV1, userGroupV1.Indices[1]))
userGroupMemberV1 := Table{
Name: "user_group_member",
Columns: []*Column{
{Name: "id", Type: DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true},
{Name: "org_id", Type: DB_BigInt},
{Name: "user_group_id", Type: DB_BigInt},
{Name: "user_id", Type: DB_BigInt},
{Name: "created", Type: DB_DateTime, Nullable: false},
{Name: "updated", Type: DB_DateTime, Nullable: false},
},
Indices: []*Index{
{Cols: []string{"org_id"}},
{Cols: []string{"org_id", "user_group_id", "user_id"}, Type: UniqueIndex},
},
}
mg.AddMigration("create user group member table", NewAddTableMigration(userGroupMemberV1))
//------- indexes ------------------
mg.AddMigration("add index user_group_member.org_id", NewAddIndexMigration(userGroupMemberV1, userGroupMemberV1.Indices[0]))
mg.AddMigration("add unique index user_group_member_org_id_user_group_id_user_id", NewAddIndexMigration(userGroupMemberV1, userGroupMemberV1.Indices[1]))
}

View File

@ -56,22 +56,38 @@
<div class="search-results-container" ng-if="!ctrl.tagsMode">
<h6 ng-hide="ctrl.results.length">No dashboards matching your query were found.</h6>
<a class="search-item pointer search-item-{{row.type}}" bindonce ng-repeat="row in ctrl.results"
ng-class="{'selected': $index == ctrl.selectedIndex}" ng-href="{{row.url}}">
<div bindonce ng-repeat="row in ctrl.results">
<a class="search-item pointer search-item-{{row.type}}"
ng-class="{'selected': $index == ctrl.selectedIndex}" ng-href="{{row.url}}">
<span class="search-result-tags">
<span ng-click="ctrl.filterByTag(tag, $event)" ng-repeat="tag in row.tags" tag-color-from-name="tag" class="label label-tag">
{{tag}}
</span>
<i class="fa" ng-class="{'fa-star': row.isStarred, 'fa-star-o': !row.isStarred}"></i>
</span>
<span class="search-result-tags">
<span ng-click="ctrl.filterByTag(tag, $event)" ng-repeat="tag in row.tags" tag-color-from-name="tag" class="label label-tag">
{{tag}}
</span>
<i class="fa" ng-class="{'fa-star': row.isStarred, 'fa-star-o': !row.isStarred}"></i>
</span>
<span class="search-result-link">
<i class="fa search-result-icon"></i>
<span bo-text="row.title"></span>
</span>
</a>
</div>
<span class="search-result-link">
<i class="fa search-result-icon"></i>
<span bo-text="row.title"></span>
</span>
<a class="search-item search-item-child pointer search-item-{{child.type}}" ng-repeat="child in row.dashboards"
ng-class="{'selected': $index == ctrl.selectedIndex}" ng-href="{{'dashboard/' + child.uri}}">
<span class="search-result-tags">
<span ng-click="ctrl.filterByTag(tag, $event)" ng-repeat="tag in child.tags" tag-color-from-name="tag" class="label label-tag">
{{tag}}
</span>
<i class="fa" ng-class="{'fa-star': child.isStarred, 'fa-star-o': !child.isStarred}"></i>
</span>
<span class="search-result-link">
<i class="fa search-result-icon"></i>
<span bo-text="child.title"></span>
</span>
</a>
</a>
</div>
</div>
<div class="search-button-row">
<a class="btn btn-secondary" href="dashboard/new" ng-show="ctrl.contextSrv.isEditor" ng-click="ctrl.isOpen = false;">

View File

@ -104,6 +104,8 @@ export class SearchCtrl {
this.currentSearchId = this.currentSearchId + 1;
var localSearchId = this.currentSearchId;
this.query.browseMode = this.queryHasNoFilters();
return this.backendSrv.search(this.query).then((results) => {
if (localSearchId < this.currentSearchId) { return; }

View File

@ -118,10 +118,6 @@
content: "\f009";
}
&.search-item-dash-home .search-result-icon::before {
content: "\f015";
}
&:hover {
background-color: $tight-form-func-bg;
@include left-brand-border-gradient();
@ -142,6 +138,17 @@
}
}
.search-item-child {
margin-left: 20px;
}
.search-item-dash-home > .search-result-link > .search-result-icon::before {
content: "\f015";
}
.search-item-dash-folder > .search-result-link > .search-result-icon::before {
content: "\f07c";
}
.search-button-row {
padding: $spacer*2;