mirror of https://github.com/grafana/grafana.git
				
				
				
			Navigation: Introduce a preferences table to store Navbar preferences (#44914)
* First attempt at creating new navbar_preferences table in db * Apply to every nav item instead of just home * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * Chore: introduce initTestDB options for features * fix unit tests * Add another unit test and some logic for detecting if a preference already exists * tidy up * Only override IsFeatureToggleEnabled if it's defined * Extract setNavPreferences out into it's own function, initialise features correctly * Make the linter happy * Use new structure * user essentials mob! 🔱 * user essentials mob! 🔱 * Split NavbarPreferences from Preferences * user essentials mob! 🔱 * user essentials mob! 🔱 * Fix lint error * Start adding tests * Change internal db structure to be a generic json object * GetJsonData -> GetPreferencesJsonData * Stop using simplejson + add some more unit tests * Update pkg/api/preferences.go Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com> * Updates following review comments * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * Change patch to upsert, add a unit test * remove commented out code * introduce patch user/org preferences methods * Return Navbar preferences in the get call * Fix integration test by instantiating JsonData * Address review comments * Rename HideFromNavbar -> Hide * add swagger:model comment * Add patch to the preferences documentation * Add openapi annotations * Add a short description * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * user essentials mob! 🔱 * Update unit tests * remove unneeded url * remove outdated comment * Update integration tests * update generated swagger Co-authored-by: Alexandra Vargas <alexa1866@gmail.com> Co-authored-by: Hugo Häggmark <hugo.haggmark@gmail.com> Co-authored-by: Marcus Efraimsson <marcus.efraimsson@gmail.com>
This commit is contained in:
		
							parent
							
								
									60af3af92c
								
							
						
					
					
						commit
						586272e5f0
					
				|  | @ -66,6 +66,34 @@ Content-Type: text/plain; charset=utf-8 | |||
| {"message":"Preferences updated"} | ||||
| ``` | ||||
| 
 | ||||
| ## Patch Current User Prefs | ||||
| 
 | ||||
| Update one or more preferences without modifying the others. | ||||
| 
 | ||||
| `PATCH /api/user/preferences` | ||||
| 
 | ||||
| **Example Request**: | ||||
| 
 | ||||
| ```http | ||||
| PATCH /api/user/preferences HTTP/1.1 | ||||
| Accept: application/json | ||||
| Content-Type: application/json | ||||
| Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk | ||||
| 
 | ||||
| { | ||||
|   "theme": "dark" | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| **Example Response**: | ||||
| 
 | ||||
| ```http | ||||
| HTTP/1.1 200 | ||||
| Content-Type: text/plain; charset=utf-8 | ||||
| 
 | ||||
| {"message":"Preferences updated"} | ||||
| ``` | ||||
| 
 | ||||
| ## Get Current Org Prefs | ||||
| 
 | ||||
| `GET /api/org/preferences` | ||||
|  | @ -115,3 +143,31 @@ Content-Type: text/plain; charset=utf-8 | |||
| 
 | ||||
| {"message":"Preferences updated"} | ||||
| ``` | ||||
| 
 | ||||
| ## Patch Current Org Prefs | ||||
| 
 | ||||
| Update one or more preferences without modifying the others. | ||||
| 
 | ||||
| `PATCH /api/org/preferences` | ||||
| 
 | ||||
| **Example Request**: | ||||
| 
 | ||||
| ```http | ||||
| PATCH /api/org/preferences HTTP/1.1 | ||||
| Accept: application/json | ||||
| Content-Type: application/json | ||||
| Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk | ||||
| 
 | ||||
| { | ||||
|   "theme": "dark" | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| **Example Response**: | ||||
| 
 | ||||
| ```http | ||||
| HTTP/1.1 200 | ||||
| Content-Type: text/plain; charset=utf-8 | ||||
| 
 | ||||
| {"message":"Preferences updated"} | ||||
| ``` | ||||
|  |  | |||
|  | @ -167,6 +167,7 @@ func (hs *HTTPServer) registerRoutes() { | |||
| 
 | ||||
| 			userRoute.Get("/preferences", routing.Wrap(hs.GetUserPreferences)) | ||||
| 			userRoute.Put("/preferences", routing.Wrap(hs.UpdateUserPreferences)) | ||||
| 			userRoute.Patch("/preferences", routing.Wrap(hs.PatchUserPreferences)) | ||||
| 
 | ||||
| 			userRoute.Get("/auth-tokens", routing.Wrap(hs.GetUserAuthTokens)) | ||||
| 			userRoute.Post("/revoke-auth-token", routing.Wrap(hs.RevokeUserAuthToken)) | ||||
|  | @ -229,6 +230,7 @@ func (hs *HTTPServer) registerRoutes() { | |||
| 			// prefs
 | ||||
| 			orgRoute.Get("/preferences", authorize(reqOrgAdmin, ac.EvalPermission(ActionOrgsPreferencesRead)), routing.Wrap(hs.GetOrgPreferences)) | ||||
| 			orgRoute.Put("/preferences", authorize(reqOrgAdmin, ac.EvalPermission(ActionOrgsPreferencesWrite)), routing.Wrap(hs.UpdateOrgPreferences)) | ||||
| 			orgRoute.Patch("/preferences", authorize(reqOrgAdmin, ac.EvalPermission(ActionOrgsPreferencesWrite)), routing.Wrap(hs.PatchOrgPreferences)) | ||||
| 		}) | ||||
| 
 | ||||
| 		// current org without requirement of user to be org admin
 | ||||
|  |  | |||
|  | @ -20,3 +20,14 @@ package definitions | |||
| // 401: unauthorisedError
 | ||||
| // 403: forbiddenError
 | ||||
| // 500: internalServerError
 | ||||
| 
 | ||||
| // swagger:route PATCH /org/preferences org_preferences patchOrgPreferences
 | ||||
| //
 | ||||
| // Patch Current Org Prefs.
 | ||||
| //
 | ||||
| // Responses:
 | ||||
| // 200: addOrgUser
 | ||||
| // 400: badRequestError
 | ||||
| // 401: unauthorisedError
 | ||||
| // 403: forbiddenError
 | ||||
| // 500: internalServerError
 | ||||
|  |  | |||
|  | @ -23,6 +23,16 @@ import "github.com/grafana/grafana/pkg/api/dtos" | |||
| // 401: unauthorisedError
 | ||||
| // 500: internalServerError
 | ||||
| 
 | ||||
| // swagger:route PATCH /user/preferences user_preferences patchUserPreferences
 | ||||
| //
 | ||||
| // Patch user preferences.
 | ||||
| //
 | ||||
| // Responses:
 | ||||
| // 200: okResponse
 | ||||
| // 400: badRequestError
 | ||||
| // 401: unauthorisedError
 | ||||
| // 500: internalServerError
 | ||||
| 
 | ||||
| // swagger:parameters updateUserPreferences updateOrgPreferences updateTeamPreferences
 | ||||
| type UpdateUserPreferencesParam struct { | ||||
| 	// in:body
 | ||||
|  | @ -35,3 +45,10 @@ type GetPreferencesResponse struct { | |||
| 	// in:body
 | ||||
| 	Body dtos.Prefs `json:"body"` | ||||
| } | ||||
| 
 | ||||
| // swagger:parameters patchUserPreferences patchOrgPreferences patchTeamPreferences
 | ||||
| type PatchUserPreferencesParam struct { | ||||
| 	// in:body
 | ||||
| 	// required:true
 | ||||
| 	Body dtos.PatchPrefsCmd `json:"body"` | ||||
| } | ||||
|  |  | |||
|  | @ -37,7 +37,8 @@ const ( | |||
| 	// are negative to ensure that the default items are placed above
 | ||||
| 	// any items with default weight.
 | ||||
| 
 | ||||
| 	WeightHome = (iota - 20) * 100 | ||||
| 	WeightSavedItems = (iota - 20) * 100 | ||||
| 	WeightHome | ||||
| 	WeightCreate | ||||
| 	WeightDashboard | ||||
| 	WeightExplore | ||||
|  |  | |||
|  | @ -1,10 +1,13 @@ | |||
| package dtos | ||||
| 
 | ||||
| import "github.com/grafana/grafana/pkg/models" | ||||
| 
 | ||||
| type Prefs struct { | ||||
| 	Theme           string                  `json:"theme"` | ||||
| 	HomeDashboardID int64                   `json:"homeDashboardId"` | ||||
| 	Timezone        string                  `json:"timezone"` | ||||
| 	WeekStart       string                  `json:"weekStart"` | ||||
| 	Navbar          models.NavbarPreference `json:"navbar,omitempty"` | ||||
| } | ||||
| 
 | ||||
| // swagger:model
 | ||||
|  | @ -17,4 +20,18 @@ type UpdatePrefsCmd struct { | |||
| 	// Enum: utc,browser
 | ||||
| 	Timezone  string                   `json:"timezone"` | ||||
| 	WeekStart string                   `json:"weekStart"` | ||||
| 	Navbar    *models.NavbarPreference `json:"navbar,omitempty"` | ||||
| } | ||||
| 
 | ||||
| // swagger:model
 | ||||
| type PatchPrefsCmd struct { | ||||
| 	// Enum: light,dark
 | ||||
| 	Theme *string `json:"theme,omitempty"` | ||||
| 	// The numerical :id of a favorited dashboard
 | ||||
| 	// Default:0
 | ||||
| 	HomeDashboardID *int64 `json:"homeDashboardId,omitempty"` | ||||
| 	// Enum: utc,browser
 | ||||
| 	Timezone  *string                  `json:"timezone,omitempty"` | ||||
| 	WeekStart *string                  `json:"weekStart,omitempty"` | ||||
| 	Navbar    *models.NavbarPreference `json:"navbar,omitempty"` | ||||
| } | ||||
|  |  | |||
|  | @ -159,6 +159,20 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto | |||
| 	navTree := []*dtos.NavLink{} | ||||
| 
 | ||||
| 	if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) { | ||||
| 		savedItemsLinks, err := hs.buildSavedItemsNavLinks(c) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 
 | ||||
| 		navTree = append(navTree, &dtos.NavLink{ | ||||
| 			Text:       "Saved Items", | ||||
| 			Id:         "saved-items", | ||||
| 			Icon:       "heart", | ||||
| 			SortWeight: dtos.WeightSavedItems, | ||||
| 			Section:    dtos.NavSectionCore, | ||||
| 			Children:   savedItemsLinks, | ||||
| 		}) | ||||
| 
 | ||||
| 		navTree = append(navTree, &dtos.NavLink{ | ||||
| 			Text:       "Home", | ||||
| 			Id:         "home", | ||||
|  | @ -388,6 +402,30 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto | |||
| 	return navTree, nil | ||||
| } | ||||
| 
 | ||||
| func (hs *HTTPServer) buildSavedItemsNavLinks(c *models.ReqContext) ([]*dtos.NavLink, error) { | ||||
| 	savedItemsChildNavs := []*dtos.NavLink{} | ||||
| 
 | ||||
| 	// query preferences table for any saved items
 | ||||
| 	prefsQuery := models.GetPreferencesWithDefaultsQuery{User: c.SignedInUser} | ||||
| 	if err := hs.SQLStore.GetPreferencesWithDefaults(c.Req.Context(), &prefsQuery); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	savedItems := prefsQuery.Result.JsonData.Navbar.SavedItems | ||||
| 
 | ||||
| 	if len(savedItems) > 0 { | ||||
| 		for _, savedItem := range savedItems { | ||||
| 			savedItemsChildNavs = append(savedItemsChildNavs, &dtos.NavLink{ | ||||
| 				Id:     savedItem.Id, | ||||
| 				Text:   savedItem.Text, | ||||
| 				Url:    savedItem.Url, | ||||
| 				Target: savedItem.Target, | ||||
| 			}) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return savedItemsChildNavs, nil | ||||
| } | ||||
| 
 | ||||
| func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm bool) []*dtos.NavLink { | ||||
| 	dashboardChildNavs := []*dtos.NavLink{} | ||||
| 	if !hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) { | ||||
|  |  | |||
|  | @ -51,6 +51,10 @@ func (hs *HTTPServer) getPreferencesFor(ctx context.Context, orgID, userID, team | |||
| 		WeekStart:       prefsQuery.Result.WeekStart, | ||||
| 	} | ||||
| 
 | ||||
| 	if prefsQuery.Result.JsonData != nil { | ||||
| 		dto.Navbar = prefsQuery.Result.JsonData.Navbar | ||||
| 	} | ||||
| 
 | ||||
| 	return response.JSON(200, &dto) | ||||
| } | ||||
| 
 | ||||
|  | @ -84,6 +88,37 @@ func (hs *HTTPServer) updatePreferencesFor(ctx context.Context, orgID, userID, t | |||
| 	return response.Success("Preferences updated") | ||||
| } | ||||
| 
 | ||||
| // PATCH /api/user/preferences
 | ||||
| func (hs *HTTPServer) PatchUserPreferences(c *models.ReqContext) response.Response { | ||||
| 	dtoCmd := dtos.PatchPrefsCmd{} | ||||
| 	if err := web.Bind(c.Req, &dtoCmd); err != nil { | ||||
| 		return response.Error(http.StatusBadRequest, "bad request data", err) | ||||
| 	} | ||||
| 	return hs.patchPreferencesFor(c.Req.Context(), c.OrgId, c.UserId, 0, &dtoCmd) | ||||
| } | ||||
| 
 | ||||
| func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, teamId int64, dtoCmd *dtos.PatchPrefsCmd) response.Response { | ||||
| 	if dtoCmd.Theme != nil && *dtoCmd.Theme != lightTheme && *dtoCmd.Theme != darkTheme && *dtoCmd.Theme != defaultTheme { | ||||
| 		return response.Error(400, "Invalid theme", nil) | ||||
| 	} | ||||
| 	patchCmd := models.PatchPreferencesCommand{ | ||||
| 		UserId:          userID, | ||||
| 		OrgId:           orgID, | ||||
| 		TeamId:          teamId, | ||||
| 		Theme:           dtoCmd.Theme, | ||||
| 		Timezone:        dtoCmd.Timezone, | ||||
| 		WeekStart:       dtoCmd.WeekStart, | ||||
| 		HomeDashboardId: dtoCmd.HomeDashboardID, | ||||
| 		Navbar:          dtoCmd.Navbar, | ||||
| 	} | ||||
| 
 | ||||
| 	if err := hs.SQLStore.PatchPreferences(ctx, &patchCmd); err != nil { | ||||
| 		return response.Error(500, "Failed to save preferences", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return response.Success("Preferences updated") | ||||
| } | ||||
| 
 | ||||
| // GET /api/org/preferences
 | ||||
| func (hs *HTTPServer) GetOrgPreferences(c *models.ReqContext) response.Response { | ||||
| 	return hs.getPreferencesFor(c.Req.Context(), c.OrgId, 0, 0) | ||||
|  | @ -97,3 +132,12 @@ func (hs *HTTPServer) UpdateOrgPreferences(c *models.ReqContext) response.Respon | |||
| 	} | ||||
| 	return hs.updatePreferencesFor(c.Req.Context(), c.OrgId, 0, 0, &dtoCmd) | ||||
| } | ||||
| 
 | ||||
| // PATCH /api/org/preferences
 | ||||
| func (hs *HTTPServer) PatchOrgPreferences(c *models.ReqContext) response.Response { | ||||
| 	dtoCmd := dtos.PatchPrefsCmd{} | ||||
| 	if err := web.Bind(c.Req, &dtoCmd); err != nil { | ||||
| 		return response.Error(http.StatusBadRequest, "bad request data", err) | ||||
| 	} | ||||
| 	return hs.patchPreferencesFor(c.Req.Context(), c.OrgId, 0, 0, &dtoCmd) | ||||
| } | ||||
|  |  | |||
|  | @ -13,8 +13,14 @@ import ( | |||
| var ( | ||||
| 	getOrgPreferencesURL    = "/api/org/preferences/" | ||||
| 	putOrgPreferencesURL    = "/api/org/preferences/" | ||||
| 	patchOrgPreferencesUrl  = "/api/org/preferences/" | ||||
| 	patchUserPreferencesUrl = "/api/user/preferences/" | ||||
| 
 | ||||
| 	testUpdateOrgPreferencesCmd    = `{ "theme": "light", "homeDashboardId": 1 }` | ||||
| 	testPatchOrgPreferencesCmd     = `{"navbar":{"savedItems":[{"id":"snapshots","text":"Snapshots","icon":"camera","url":"/dashboard/snapshots"}]}}` | ||||
| 	testPatchOrgPreferencesCmdBad  = `this is not json` | ||||
| 	testPatchUserPreferencesCmd    = `{"navbar":{"savedItems":[{"id":"snapshots","text":"Snapshots","icon":"camera","url":"/dashboard/snapshots"}]}}` | ||||
| 	testPatchUserPreferencesCmdBad = `this is not json` | ||||
| ) | ||||
| 
 | ||||
| func TestAPIEndpoint_GetCurrentOrgPreferences_LegacyAccessControl(t *testing.T) { | ||||
|  | @ -109,3 +115,43 @@ func TestAPIEndpoint_PutCurrentOrgPreferences_AccessControl(t *testing.T) { | |||
| 		assert.Equal(t, http.StatusForbidden, response.Code) | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| func TestAPIEndpoint_PatchUserPreferences(t *testing.T) { | ||||
| 	sc := setupHTTPServer(t, true, false) | ||||
| 
 | ||||
| 	_, err := sc.db.CreateOrgWithMember("TestOrg", testUserID) | ||||
| 	require.NoError(t, err) | ||||
| 
 | ||||
| 	setInitCtxSignedInOrgAdmin(sc.initCtx) | ||||
| 	input := strings.NewReader(testPatchUserPreferencesCmd) | ||||
| 	t.Run("Returns 200 on success", func(t *testing.T) { | ||||
| 		response := callAPI(sc.server, http.MethodPatch, patchUserPreferencesUrl, input, t) | ||||
| 		assert.Equal(t, http.StatusOK, response.Code) | ||||
| 	}) | ||||
| 
 | ||||
| 	input = strings.NewReader(testPatchUserPreferencesCmdBad) | ||||
| 	t.Run("Returns 400 with bad data", func(t *testing.T) { | ||||
| 		response := callAPI(sc.server, http.MethodPut, patchUserPreferencesUrl, input, t) | ||||
| 		assert.Equal(t, http.StatusBadRequest, response.Code) | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| func TestAPIEndpoint_PatchOrgPreferences(t *testing.T) { | ||||
| 	sc := setupHTTPServer(t, true, false) | ||||
| 
 | ||||
| 	_, err := sc.db.CreateOrgWithMember("TestOrg", testUserID) | ||||
| 	require.NoError(t, err) | ||||
| 
 | ||||
| 	setInitCtxSignedInOrgAdmin(sc.initCtx) | ||||
| 	input := strings.NewReader(testPatchOrgPreferencesCmd) | ||||
| 	t.Run("Returns 200 on success", func(t *testing.T) { | ||||
| 		response := callAPI(sc.server, http.MethodPatch, patchOrgPreferencesUrl, input, t) | ||||
| 		assert.Equal(t, http.StatusOK, response.Code) | ||||
| 	}) | ||||
| 
 | ||||
| 	input = strings.NewReader(testPatchOrgPreferencesCmdBad) | ||||
| 	t.Run("Returns 400 with bad data", func(t *testing.T) { | ||||
| 		response := callAPI(sc.server, http.MethodPut, patchOrgPreferencesUrl, input, t) | ||||
| 		assert.Equal(t, http.StatusBadRequest, response.Code) | ||||
| 	}) | ||||
| } | ||||
|  |  | |||
|  | @ -1,9 +1,18 @@ | |||
| package models | ||||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| type NavLink struct { | ||||
| 	Id     string `json:"id,omitempty"` | ||||
| 	Text   string `json:"text,omitempty"` | ||||
| 	Url    string `json:"url,omitempty"` | ||||
| 	Target string `json:"target,omitempty"` | ||||
| } | ||||
| 
 | ||||
| type Preferences struct { | ||||
| 	Id              int64 | ||||
| 	OrgId           int64 | ||||
|  | @ -16,6 +25,32 @@ type Preferences struct { | |||
| 	Theme           string | ||||
| 	Created         time.Time | ||||
| 	Updated         time.Time | ||||
| 	JsonData        *PreferencesJsonData | ||||
| } | ||||
| 
 | ||||
| // The following needed for to implement the xorm/database ORM Conversion interface do the
 | ||||
| // conversion when reading/writing to the database, see https://gobook.io/read/gitea.com/xorm/manual-en-US/chapter-02/4.columns.html.
 | ||||
| 
 | ||||
| func (j *PreferencesJsonData) FromDB(data []byte) error { | ||||
| 	dec := json.NewDecoder(bytes.NewBuffer(data)) | ||||
| 	dec.UseNumber() | ||||
| 	return dec.Decode(j) | ||||
| } | ||||
| 
 | ||||
| func (j *PreferencesJsonData) ToDB() ([]byte, error) { | ||||
| 	if j == nil { | ||||
| 		return nil, nil | ||||
| 	} | ||||
| 
 | ||||
| 	return json.Marshal(j) | ||||
| } | ||||
| 
 | ||||
| type NavbarPreference struct { | ||||
| 	SavedItems []NavLink `json:"savedItems"` | ||||
| } | ||||
| 
 | ||||
| type PreferencesJsonData struct { | ||||
| 	Navbar NavbarPreference `json:"navbar"` | ||||
| } | ||||
| 
 | ||||
| // ---------------------
 | ||||
|  | @ -43,8 +78,21 @@ type SavePreferencesCommand struct { | |||
| 	OrgId  int64 | ||||
| 	TeamId int64 | ||||
| 
 | ||||
| 	HomeDashboardId int64  `json:"homeDashboardId"` | ||||
| 	Timezone        string `json:"timezone"` | ||||
| 	WeekStart       string `json:"weekStart"` | ||||
| 	Theme           string `json:"theme"` | ||||
| 	HomeDashboardId int64             `json:"homeDashboardId,omitempty"` | ||||
| 	Timezone        string            `json:"timezone,omitempty"` | ||||
| 	WeekStart       string            `json:"weekStart,omitempty"` | ||||
| 	Theme           string            `json:"theme,omitempty"` | ||||
| 	Navbar          *NavbarPreference `json:"navbar,omitempty"` | ||||
| } | ||||
| 
 | ||||
| type PatchPreferencesCommand struct { | ||||
| 	UserId int64 | ||||
| 	OrgId  int64 | ||||
| 	TeamId int64 | ||||
| 
 | ||||
| 	HomeDashboardId *int64            `json:"homeDashboardId,omitempty"` | ||||
| 	Timezone        *string           `json:"timezone,omitempty"` | ||||
| 	WeekStart       *string           `json:"weekStart,omitempty"` | ||||
| 	Theme           *string           `json:"theme,omitempty"` | ||||
| 	Navbar          *NavbarPreference `json:"navbar,omitempty"` | ||||
| } | ||||
|  |  | |||
|  | @ -1,6 +1,8 @@ | |||
| package migrations | ||||
| 
 | ||||
| import . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" | ||||
| import ( | ||||
| 	. "github.com/grafana/grafana/pkg/services/sqlstore/migrator" | ||||
| ) | ||||
| 
 | ||||
| func addPreferencesMigrations(mg *Migrator) { | ||||
| 	mg.AddMigration("drop preferences table v2", NewDropTableMigration("preferences")) | ||||
|  | @ -46,4 +48,11 @@ func addPreferencesMigrations(mg *Migrator) { | |||
| 	mg.AddMigration("Add column week_start in preferences", NewAddColumnMigration(preferencesV2, &Column{ | ||||
| 		Name: "week_start", Type: DB_NVarchar, Length: 10, Nullable: true, | ||||
| 	})) | ||||
| 
 | ||||
| 	mg.AddMigration("Add column preferences.json_data", NewAddColumnMigration(preferencesV2, &Column{ | ||||
| 		Name: "json_data", Type: DB_Text, Nullable: true, | ||||
| 	})) | ||||
| 	// change column type of preferences.json_data
 | ||||
| 	mg.AddMigration("alter preferences.json_data to mediumtext v1", NewRawSQLMigration(""). | ||||
| 		Mysql("ALTER TABLE preferences MODIFY json_data MEDIUMTEXT;")) | ||||
| } | ||||
|  |  | |||
|  | @ -287,6 +287,10 @@ func (m *SQLStoreMock) SavePreferences(ctx context.Context, cmd *models.SavePref | |||
| 	return m.ExpectedError | ||||
| } | ||||
| 
 | ||||
| func (m *SQLStoreMock) PatchPreferences(ctx context.Context, cmd *models.PatchPreferencesCommand) error { | ||||
| 	return m.ExpectedError | ||||
| } | ||||
| 
 | ||||
| func (m *SQLStoreMock) GetPluginSettings(ctx context.Context, orgID int64) ([]*models.PluginSettingInfoDTO, error) { | ||||
| 	return nil, m.ExpectedError | ||||
| } | ||||
|  |  | |||
|  | @ -13,6 +13,7 @@ func (ss *SQLStore) addPreferencesQueryAndCommandHandlers() { | |||
| 	bus.AddHandler("sql", ss.GetPreferences) | ||||
| 	bus.AddHandler("sql", ss.GetPreferencesWithDefaults) | ||||
| 	bus.AddHandler("sql", ss.SavePreferences) | ||||
| 	bus.AddHandler("sql", ss.PatchPreferences) | ||||
| } | ||||
| 
 | ||||
| func (ss *SQLStore) GetPreferencesWithDefaults(ctx context.Context, query *models.GetPreferencesWithDefaultsQuery) error { | ||||
|  | @ -46,6 +47,7 @@ func (ss *SQLStore) GetPreferencesWithDefaults(ctx context.Context, query *model | |||
| 			Timezone:        ss.Cfg.DateFormats.DefaultTimezone, | ||||
| 			WeekStart:       ss.Cfg.DateFormats.DefaultWeekStart, | ||||
| 			HomeDashboardId: 0, | ||||
| 			JsonData:        &models.PreferencesJsonData{}, | ||||
| 		} | ||||
| 
 | ||||
| 		for _, p := range prefs { | ||||
|  | @ -61,6 +63,9 @@ func (ss *SQLStore) GetPreferencesWithDefaults(ctx context.Context, query *model | |||
| 			if p.HomeDashboardId != 0 { | ||||
| 				res.HomeDashboardId = p.HomeDashboardId | ||||
| 			} | ||||
| 			if p.JsonData != nil { | ||||
| 				res.JsonData = p.JsonData | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		query.Result = res | ||||
|  | @ -106,10 +111,24 @@ func (ss *SQLStore) SavePreferences(ctx context.Context, cmd *models.SavePrefere | |||
| 				Theme:           cmd.Theme, | ||||
| 				Created:         time.Now(), | ||||
| 				Updated:         time.Now(), | ||||
| 				JsonData:        &models.PreferencesJsonData{}, | ||||
| 			} | ||||
| 
 | ||||
| 			if cmd.Navbar != nil { | ||||
| 				prefs.JsonData.Navbar = *cmd.Navbar | ||||
| 			} | ||||
| 			_, err = sess.Insert(&prefs) | ||||
| 			return err | ||||
| 		} | ||||
| 		// Wrap this in an if statement to maintain backwards compatibility
 | ||||
| 		if cmd.Navbar != nil { | ||||
| 			if prefs.JsonData == nil { | ||||
| 				prefs.JsonData = &models.PreferencesJsonData{} | ||||
| 			} | ||||
| 			if cmd.Navbar.SavedItems != nil { | ||||
| 				prefs.JsonData.Navbar.SavedItems = cmd.Navbar.SavedItems | ||||
| 			} | ||||
| 		} | ||||
| 		prefs.HomeDashboardId = cmd.HomeDashboardId | ||||
| 		prefs.Timezone = cmd.Timezone | ||||
| 		prefs.WeekStart = cmd.WeekStart | ||||
|  | @ -120,3 +139,58 @@ func (ss *SQLStore) SavePreferences(ctx context.Context, cmd *models.SavePrefere | |||
| 		return err | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| func (ss *SQLStore) PatchPreferences(ctx context.Context, cmd *models.PatchPreferencesCommand) error { | ||||
| 	return ss.WithTransactionalDbSession(ctx, func(sess *DBSession) error { | ||||
| 		var prefs models.Preferences | ||||
| 		exists, err := sess.Where("org_id=? AND user_id=? AND team_id=?", cmd.OrgId, cmd.UserId, cmd.TeamId).Get(&prefs) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 
 | ||||
| 		if !exists { | ||||
| 			prefs = models.Preferences{ | ||||
| 				UserId:   cmd.UserId, | ||||
| 				OrgId:    cmd.OrgId, | ||||
| 				TeamId:   cmd.TeamId, | ||||
| 				Created:  time.Now(), | ||||
| 				JsonData: &models.PreferencesJsonData{}, | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		if cmd.Navbar != nil { | ||||
| 			if prefs.JsonData == nil { | ||||
| 				prefs.JsonData = &models.PreferencesJsonData{} | ||||
| 			} | ||||
| 			if cmd.Navbar.SavedItems != nil { | ||||
| 				prefs.JsonData.Navbar.SavedItems = cmd.Navbar.SavedItems | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		if cmd.HomeDashboardId != nil { | ||||
| 			prefs.HomeDashboardId = *cmd.HomeDashboardId | ||||
| 		} | ||||
| 
 | ||||
| 		if cmd.Timezone != nil { | ||||
| 			prefs.Timezone = *cmd.Timezone | ||||
| 		} | ||||
| 
 | ||||
| 		if cmd.WeekStart != nil { | ||||
| 			prefs.WeekStart = *cmd.WeekStart | ||||
| 		} | ||||
| 
 | ||||
| 		if cmd.Theme != nil { | ||||
| 			prefs.Theme = *cmd.Theme | ||||
| 		} | ||||
| 
 | ||||
| 		prefs.Updated = time.Now() | ||||
| 		prefs.Version += 1 | ||||
| 
 | ||||
| 		if exists { | ||||
| 			_, err = sess.ID(prefs.Id).AllCols().Update(&prefs) | ||||
| 		} else { | ||||
| 			_, err = sess.Insert(&prefs) | ||||
| 		} | ||||
| 		return err | ||||
| 	}) | ||||
| } | ||||
|  |  | |||
|  | @ -15,6 +15,48 @@ import ( | |||
| 
 | ||||
| func TestPreferencesDataAccess(t *testing.T) { | ||||
| 	ss := InitTestDB(t) | ||||
| 	emptyNavbarPreferences := models.NavbarPreference{} | ||||
| 	userNavbarPreferences := models.NavbarPreference{ | ||||
| 		SavedItems: []models.NavLink{{ | ||||
| 			Id:   "explore", | ||||
| 			Text: "Explore", | ||||
| 			Url:  "/explore", | ||||
| 		}}, | ||||
| 	} | ||||
| 	orgNavbarPreferences := models.NavbarPreference{ | ||||
| 		SavedItems: []models.NavLink{{ | ||||
| 			Id:   "alerting", | ||||
| 			Text: "Alerting", | ||||
| 			Url:  "/alerting", | ||||
| 		}}, | ||||
| 	} | ||||
| 	team1NavbarPreferences := models.NavbarPreference{ | ||||
| 		SavedItems: []models.NavLink{{ | ||||
| 			Id:   "dashboards", | ||||
| 			Text: "Dashboards", | ||||
| 			Url:  "/dashboards", | ||||
| 		}}, | ||||
| 	} | ||||
| 	team2NavbarPreferences := models.NavbarPreference{ | ||||
| 		SavedItems: []models.NavLink{{ | ||||
| 			Id:   "home", | ||||
| 			Text: "Home", | ||||
| 			Url:  "/home", | ||||
| 		}}, | ||||
| 	} | ||||
| 
 | ||||
| 	emptyPreferencesJsonData := models.PreferencesJsonData{ | ||||
| 		Navbar: emptyNavbarPreferences, | ||||
| 	} | ||||
| 	userPreferencesJsonData := models.PreferencesJsonData{ | ||||
| 		Navbar: userNavbarPreferences, | ||||
| 	} | ||||
| 	orgPreferencesJsonData := models.PreferencesJsonData{ | ||||
| 		Navbar: orgNavbarPreferences, | ||||
| 	} | ||||
| 	team2PreferencesJsonData := models.PreferencesJsonData{ | ||||
| 		Navbar: team2NavbarPreferences, | ||||
| 	} | ||||
| 
 | ||||
| 	t.Run("GetPreferencesWithDefaults with no saved preferences should return defaults", func(t *testing.T) { | ||||
| 		ss.Cfg.DefaultTheme = "light" | ||||
|  | @ -26,6 +68,7 @@ func TestPreferencesDataAccess(t *testing.T) { | |||
| 		require.Equal(t, "light", query.Result.Theme) | ||||
| 		require.Equal(t, "UTC", query.Result.Timezone) | ||||
| 		require.Equal(t, int64(0), query.Result.HomeDashboardId) | ||||
| 		require.Equal(t, &emptyPreferencesJsonData, query.Result.JsonData) | ||||
| 	}) | ||||
| 
 | ||||
| 	t.Run("GetPreferencesWithDefaults with saved org and user home dashboard should return user home dashboard", func(t *testing.T) { | ||||
|  | @ -118,8 +161,98 @@ func TestPreferencesDataAccess(t *testing.T) { | |||
| 		require.Equal(t, int64(1), query.Result.HomeDashboardId) | ||||
| 	}) | ||||
| 
 | ||||
| 	t.Run("GetPreferencesWithDefaults with saved org and user json data should return user json data", func(t *testing.T) { | ||||
| 		err := ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{OrgId: 1, Navbar: &orgNavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 		err = ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{OrgId: 1, UserId: 1, Navbar: &userNavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 
 | ||||
| 		query := &models.GetPreferencesWithDefaultsQuery{User: &models.SignedInUser{OrgId: 1, UserId: 1}} | ||||
| 		err = ss.GetPreferencesWithDefaults(context.Background(), query) | ||||
| 		require.NoError(t, err) | ||||
| 		require.Equal(t, &userPreferencesJsonData, query.Result.JsonData) | ||||
| 	}) | ||||
| 
 | ||||
| 	t.Run("GetPreferencesWithDefaults with saved org and other user json data should return org json data", func(t *testing.T) { | ||||
| 		err := ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{OrgId: 1, Navbar: &orgNavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 		err = ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{OrgId: 1, UserId: 1, Navbar: &userNavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 
 | ||||
| 		query := &models.GetPreferencesWithDefaultsQuery{User: &models.SignedInUser{OrgId: 1, UserId: 2}} | ||||
| 		err = ss.GetPreferencesWithDefaults(context.Background(), query) | ||||
| 		require.NoError(t, err) | ||||
| 		require.Equal(t, &orgPreferencesJsonData, query.Result.JsonData) | ||||
| 	}) | ||||
| 
 | ||||
| 	t.Run("GetPreferencesWithDefaults with saved org and teams json data should return last team json data", func(t *testing.T) { | ||||
| 		err := ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{OrgId: 1, Navbar: &orgNavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 		err = ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{OrgId: 1, TeamId: 2, Navbar: &team1NavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 		err = ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{OrgId: 1, TeamId: 3, Navbar: &team2NavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 
 | ||||
| 		query := &models.GetPreferencesWithDefaultsQuery{ | ||||
| 			User: &models.SignedInUser{OrgId: 1, Teams: []int64{2, 3}}, | ||||
| 		} | ||||
| 		err = ss.GetPreferencesWithDefaults(context.Background(), query) | ||||
| 		require.NoError(t, err) | ||||
| 		require.Equal(t, &team2PreferencesJsonData, query.Result.JsonData) | ||||
| 	}) | ||||
| 
 | ||||
| 	t.Run("GetPreferencesWithDefaults with saved org and other teams json data should return org json data", func(t *testing.T) { | ||||
| 		err := ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{OrgId: 1, Navbar: &orgNavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 		err = ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{OrgId: 1, TeamId: 2, Navbar: &team1NavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 		err = ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{OrgId: 1, TeamId: 3, Navbar: &team2NavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 
 | ||||
| 		query := &models.GetPreferencesWithDefaultsQuery{User: &models.SignedInUser{OrgId: 1}} | ||||
| 		err = ss.GetPreferencesWithDefaults(context.Background(), query) | ||||
| 		require.NoError(t, err) | ||||
| 		require.Equal(t, &orgPreferencesJsonData, query.Result.JsonData) | ||||
| 	}) | ||||
| 
 | ||||
| 	t.Run("GetPreferencesWithDefaults with saved org, teams and user json data should return user json data", func(t *testing.T) { | ||||
| 		err := ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{OrgId: 1, Navbar: &orgNavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 		err = ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{OrgId: 1, TeamId: 2, Navbar: &team1NavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 		err = ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{OrgId: 1, TeamId: 3, Navbar: &team2NavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 		err = ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{OrgId: 1, UserId: 1, Navbar: &userNavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 
 | ||||
| 		query := &models.GetPreferencesWithDefaultsQuery{ | ||||
| 			User: &models.SignedInUser{OrgId: 1, UserId: 1, Teams: []int64{2, 3}}, | ||||
| 		} | ||||
| 		err = ss.GetPreferencesWithDefaults(context.Background(), query) | ||||
| 		require.NoError(t, err) | ||||
| 		require.Equal(t, &userPreferencesJsonData, query.Result.JsonData) | ||||
| 	}) | ||||
| 
 | ||||
| 	t.Run("GetPreferencesWithDefaults with saved org, other teams and user json data should return org json data", func(t *testing.T) { | ||||
| 		err := ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{OrgId: 1, Navbar: &orgNavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 		err = ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{OrgId: 1, TeamId: 2, Navbar: &team1NavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 		err = ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{OrgId: 1, TeamId: 3, Navbar: &team2NavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 		err = ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{OrgId: 1, UserId: 1, Navbar: &userNavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 
 | ||||
| 		query := &models.GetPreferencesWithDefaultsQuery{ | ||||
| 			User: &models.SignedInUser{OrgId: 1, UserId: 2}, | ||||
| 		} | ||||
| 		err = ss.GetPreferencesWithDefaults(context.Background(), query) | ||||
| 		require.NoError(t, err) | ||||
| 		require.Equal(t, &orgPreferencesJsonData, query.Result.JsonData) | ||||
| 	}) | ||||
| 
 | ||||
| 	t.Run("SavePreferences for a user should store correct values", func(t *testing.T) { | ||||
| 		err := ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{UserId: models.SignedInUser{}.UserId, Theme: "dark", Timezone: "browser", HomeDashboardId: 5, WeekStart: "1"}) | ||||
| 		err := ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{UserId: models.SignedInUser{}.UserId, Theme: "dark", Timezone: "browser", HomeDashboardId: 5, WeekStart: "1", Navbar: &userNavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 
 | ||||
| 		query := &models.GetPreferencesWithDefaultsQuery{User: &models.SignedInUser{}} | ||||
|  | @ -132,6 +265,33 @@ func TestPreferencesDataAccess(t *testing.T) { | |||
| 			Timezone:        "browser", | ||||
| 			WeekStart:       "1", | ||||
| 			Theme:           "dark", | ||||
| 			JsonData:        &userPreferencesJsonData, | ||||
| 			Created:         query.Result.Created, | ||||
| 			Updated:         query.Result.Updated, | ||||
| 		} | ||||
| 		if diff := cmp.Diff(expected, query.Result); diff != "" { | ||||
| 			t.Fatalf("Result mismatch (-want +got):\n%s", diff) | ||||
| 		} | ||||
| 	}) | ||||
| 
 | ||||
| 	t.Run("PatchPreferences for a user should only modify a single value", func(t *testing.T) { | ||||
| 		err := ss.SavePreferences(context.Background(), &models.SavePreferencesCommand{UserId: models.SignedInUser{}.UserId, Theme: "dark", Timezone: "browser", HomeDashboardId: 5, WeekStart: "1", Navbar: &orgNavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 
 | ||||
| 		err = ss.PatchPreferences(context.Background(), &models.PatchPreferencesCommand{UserId: models.SignedInUser{}.UserId, Navbar: &userNavbarPreferences}) | ||||
| 		require.NoError(t, err) | ||||
| 
 | ||||
| 		query := &models.GetPreferencesWithDefaultsQuery{User: &models.SignedInUser{}} | ||||
| 		err = ss.GetPreferencesWithDefaults(context.Background(), query) | ||||
| 		require.NoError(t, err) | ||||
| 		expected := &models.Preferences{ | ||||
| 			Id:              query.Result.Id, | ||||
| 			Version:         query.Result.Version, | ||||
| 			HomeDashboardId: 5, | ||||
| 			Timezone:        "browser", | ||||
| 			WeekStart:       "1", | ||||
| 			Theme:           "dark", | ||||
| 			JsonData:        &userPreferencesJsonData, | ||||
| 			Created:         query.Result.Created, | ||||
| 			Updated:         query.Result.Updated, | ||||
| 		} | ||||
|  |  | |||
|  | @ -63,6 +63,7 @@ type Store interface { | |||
| 	GetPreferencesWithDefaults(ctx context.Context, query *models.GetPreferencesWithDefaultsQuery) error | ||||
| 	GetPreferences(ctx context.Context, query *models.GetPreferencesQuery) error | ||||
| 	SavePreferences(ctx context.Context, cmd *models.SavePreferencesCommand) error | ||||
| 	PatchPreferences(ctx context.Context, cmd *models.PatchPreferencesCommand) error | ||||
| 	GetPluginSettings(ctx context.Context, orgID int64) ([]*models.PluginSettingInfoDTO, error) | ||||
| 	GetPluginSettingById(ctx context.Context, query *models.GetPluginSettingByIdQuery) error | ||||
| 	UpdatePluginSetting(ctx context.Context, cmd *models.UpdatePluginSettingCmd) error | ||||
|  |  | |||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										2965
									
								
								public/api-spec.json
								
								
								
								
							
							
						
						
									
										2965
									
								
								public/api-spec.json
								
								
								
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
		Loading…
	
		Reference in New Issue