diff --git a/pkg/services/libraryelements/api.go b/pkg/services/libraryelements/api.go index b4d9c0f35a77..0e4ed4f0b044 100644 --- a/pkg/services/libraryelements/api.go +++ b/pkg/services/libraryelements/api.go @@ -19,6 +19,7 @@ func (l *LibraryElementService) registerAPIEndpoints() { entities.Get("/", middleware.ReqSignedIn, routing.Wrap(l.getAllHandler)) entities.Get("/:uid", middleware.ReqSignedIn, routing.Wrap(l.getHandler)) entities.Get("/:uid/connections/", middleware.ReqSignedIn, routing.Wrap(l.getConnectionsHandler)) + entities.Get("/name/:name", middleware.ReqSignedIn, routing.Wrap(l.getByNameHandler)) entities.Patch("/:uid", middleware.ReqSignedIn, binding.Bind(patchLibraryElementCommand{}), routing.Wrap(l.patchHandler)) }) } @@ -45,7 +46,7 @@ func (l *LibraryElementService) deleteHandler(c *models.ReqContext) response.Res // getHandler handles GET /api/library-elements/:uid. func (l *LibraryElementService) getHandler(c *models.ReqContext) response.Response { - element, err := l.getLibraryElement(c, c.Params(":uid")) + element, err := l.getLibraryElementByUid(c) if err != nil { return toLibraryElementError(err, "Failed to get library element") } @@ -93,6 +94,16 @@ func (l *LibraryElementService) getConnectionsHandler(c *models.ReqContext) resp return response.JSON(200, util.DynMap{"result": connections}) } +// getByNameHandler handles GET /api/library-elements/name/:name/. +func (l *LibraryElementService) getByNameHandler(c *models.ReqContext) response.Response { + elements, err := l.getLibraryElementsByName(c) + if err != nil { + return toLibraryElementError(err, "Failed to get library element") + } + + return response.JSON(200, util.DynMap{"result": elements}) +} + func toLibraryElementError(err error, message string) response.Response { if errors.Is(err, errLibraryElementAlreadyExists) { return response.Error(400, errLibraryElementAlreadyExists.Error(), err) diff --git a/pkg/services/libraryelements/database.go b/pkg/services/libraryelements/database.go index ce6f0472fe7e..0864620de099 100644 --- a/pkg/services/libraryelements/database.go +++ b/pkg/services/libraryelements/database.go @@ -187,24 +187,23 @@ func (l *LibraryElementService) deleteLibraryElement(c *models.ReqContext, uid s }) } -// getLibraryElement gets a Library Element. -func (l *LibraryElementService) getLibraryElement(c *models.ReqContext, uid string) (LibraryElementDTO, error) { - var libraryElement LibraryElementWithMeta +// getLibraryElement gets a Library Element where param == value +func (l *LibraryElementService) getLibraryElements(c *models.ReqContext, params []Pair) ([]LibraryElementDTO, error) { + libraryElements := make([]LibraryElementWithMeta, 0) err := l.SQLStore.WithDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error { - libraryElements := make([]LibraryElementWithMeta, 0) builder := sqlstore.SQLBuilder{} builder.Write(selectLibraryElementDTOWithMeta) builder.Write(", 'General' as folder_name ") builder.Write(", '' as folder_uid ") builder.Write(fromLibraryElementDTOWithMeta) - builder.Write(` WHERE le.uid=? AND le.org_id=? AND le.folder_id=0`, uid, c.SignedInUser.OrgId) + writeParamSelectorSQL(&builder, append(params, Pair{"folder_id", 0})...) builder.Write(" UNION ") builder.Write(selectLibraryElementDTOWithMeta) builder.Write(", dashboard.title as folder_name ") builder.Write(", dashboard.uid as folder_uid ") builder.Write(fromLibraryElementDTOWithMeta) builder.Write(" INNER JOIN dashboard AS dashboard on le.folder_id = dashboard.id AND le.folder_id <> 0") - builder.Write(` WHERE le.uid=? AND le.org_id=?`, uid, c.SignedInUser.OrgId) + writeParamSelectorSQL(&builder, params...) if c.SignedInUser.OrgRole != models.ROLE_ADMIN { builder.WriteDashboardPermissionFilter(c.SignedInUser, models.PERMISSION_VIEW) } @@ -215,49 +214,65 @@ func (l *LibraryElementService) getLibraryElement(c *models.ReqContext, uid stri if len(libraryElements) == 0 { return errLibraryElementNotFound } - if len(libraryElements) > 1 { - return fmt.Errorf("found %d elements, while expecting at most one", len(libraryElements)) - } - - libraryElement = libraryElements[0] return nil }) if err != nil { - return LibraryElementDTO{}, err + return []LibraryElementDTO{}, err } - dto := LibraryElementDTO{ - ID: libraryElement.ID, - OrgID: libraryElement.OrgID, - FolderID: libraryElement.FolderID, - UID: libraryElement.UID, - Name: libraryElement.Name, - Kind: libraryElement.Kind, - Type: libraryElement.Type, - Description: libraryElement.Description, - Model: libraryElement.Model, - Version: libraryElement.Version, - Meta: LibraryElementDTOMeta{ - FolderName: libraryElement.FolderName, - FolderUID: libraryElement.FolderUID, - ConnectedDashboards: libraryElement.ConnectedDashboards, - Created: libraryElement.Created, - Updated: libraryElement.Updated, - CreatedBy: LibraryElementDTOMetaUser{ - ID: libraryElement.CreatedBy, - Name: libraryElement.CreatedByName, - AvatarURL: dtos.GetGravatarUrl(libraryElement.CreatedByEmail), - }, - UpdatedBy: LibraryElementDTOMetaUser{ - ID: libraryElement.UpdatedBy, - Name: libraryElement.UpdatedByName, - AvatarURL: dtos.GetGravatarUrl(libraryElement.UpdatedByEmail), + leDtos := make([]LibraryElementDTO, len(libraryElements)) + for i, libraryElement := range libraryElements { + leDtos[i] = LibraryElementDTO{ + ID: libraryElement.ID, + OrgID: libraryElement.OrgID, + FolderID: libraryElement.FolderID, + UID: libraryElement.UID, + Name: libraryElement.Name, + Kind: libraryElement.Kind, + Type: libraryElement.Type, + Description: libraryElement.Description, + Model: libraryElement.Model, + Version: libraryElement.Version, + Meta: LibraryElementDTOMeta{ + FolderName: libraryElement.FolderName, + FolderUID: libraryElement.FolderUID, + ConnectedDashboards: libraryElement.ConnectedDashboards, + Created: libraryElement.Created, + Updated: libraryElement.Updated, + CreatedBy: LibraryElementDTOMetaUser{ + ID: libraryElement.CreatedBy, + Name: libraryElement.CreatedByName, + AvatarURL: dtos.GetGravatarUrl(libraryElement.CreatedByEmail), + }, + UpdatedBy: LibraryElementDTOMetaUser{ + ID: libraryElement.UpdatedBy, + Name: libraryElement.UpdatedByName, + AvatarURL: dtos.GetGravatarUrl(libraryElement.UpdatedByEmail), + }, }, - }, + } } - return dto, nil + return leDtos, nil +} + +// getLibraryElementByUid gets a Library Element by uid. +func (l *LibraryElementService) getLibraryElementByUid(c *models.ReqContext) (LibraryElementDTO, error) { + libraryElements, err := l.getLibraryElements(c, []Pair{{key: "org_id", value: c.SignedInUser.OrgId}, {key: "uid", value: c.Params(":uid")}}) + if err != nil { + return LibraryElementDTO{}, err + } + if len(libraryElements) > 1 { + return LibraryElementDTO{}, fmt.Errorf("found %d elements, while expecting at most one", len(libraryElements)) + } + + return libraryElements[0], nil +} + +// getLibraryElementByName gets a Library Element by name. +func (l *LibraryElementService) getLibraryElementsByName(c *models.ReqContext) ([]LibraryElementDTO, error) { + return l.getLibraryElements(c, []Pair{{"org_id", c.SignedInUser.OrgId}, {"name", c.Params(":name")}}) } // getAllLibraryElements gets all Library Elements. diff --git a/pkg/services/libraryelements/libraryelements_get_test.go b/pkg/services/libraryelements/libraryelements_get_test.go index 12b1a4b5dee1..4f0218dceffc 100644 --- a/pkg/services/libraryelements/libraryelements_get_test.go +++ b/pkg/services/libraryelements/libraryelements_get_test.go @@ -14,54 +14,74 @@ import ( func TestGetLibraryElement(t *testing.T) { scenarioWithPanel(t, "When an admin tries to get a library panel that does not exist, it should fail", func(t *testing.T, sc scenarioContext) { + // by uid sc.reqContext.ReplaceAllParams(map[string]string{":uid": "unknown"}) resp := sc.service.getHandler(sc.reqContext) require.Equal(t, 404, resp.Status()) + + // by name + sc.reqContext.ReplaceAllParams(map[string]string{":name": "unknown"}) + resp = sc.service.getByNameHandler(sc.reqContext) + require.Equal(t, 404, resp.Status()) }) scenarioWithPanel(t, "When an admin tries to get a library panel that exists, it should succeed and return correct result", func(t *testing.T, sc scenarioContext) { - sc.reqContext.ReplaceAllParams(map[string]string{":uid": sc.initialResult.Result.UID}) - resp := sc.service.getHandler(sc.reqContext) - var result = validateAndUnMarshalResponse(t, resp) - var expected = libraryElementResult{ - Result: libraryElement{ - ID: 1, - OrgID: 1, - FolderID: 1, - UID: result.Result.UID, - Name: "Text - Library Panel", - Kind: int64(Panel), - Type: "text", - Description: "A description", - Model: map[string]interface{}{ - "datasource": "${DS_GDEV-TESTDATA}", - "description": "A description", - "id": float64(1), - "title": "Text - Library Panel", - "type": "text", - }, - Version: 1, - Meta: LibraryElementDTOMeta{ - FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, - ConnectedDashboards: 0, - Created: result.Result.Meta.Created, - Updated: result.Result.Meta.Updated, - CreatedBy: LibraryElementDTOMetaUser{ - ID: 1, - Name: userInDbName, - AvatarURL: userInDbAvatar, + var expected = func(res libraryElementResult) libraryElementResult { + return libraryElementResult{ + Result: libraryElement{ + ID: 1, + OrgID: 1, + FolderID: 1, + UID: res.Result.UID, + Name: "Text - Library Panel", + Kind: int64(Panel), + Type: "text", + Description: "A description", + Model: map[string]interface{}{ + "datasource": "${DS_GDEV-TESTDATA}", + "description": "A description", + "id": float64(1), + "title": "Text - Library Panel", + "type": "text", }, - UpdatedBy: LibraryElementDTOMetaUser{ - ID: 1, - Name: userInDbName, - AvatarURL: userInDbAvatar, + Version: 1, + Meta: LibraryElementDTOMeta{ + FolderName: "ScenarioFolder", + FolderUID: sc.folder.Uid, + ConnectedDashboards: 0, + Created: res.Result.Meta.Created, + Updated: res.Result.Meta.Updated, + CreatedBy: LibraryElementDTOMetaUser{ + ID: 1, + Name: userInDbName, + AvatarURL: userInDbAvatar, + }, + UpdatedBy: LibraryElementDTOMetaUser{ + ID: 1, + Name: userInDbName, + AvatarURL: userInDbAvatar, + }, }, }, - }, + } } - if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" { + + // by uid + sc.reqContext.ReplaceAllParams(map[string]string{":uid": sc.initialResult.Result.UID}) + resp := sc.service.getHandler(sc.reqContext) + var result = validateAndUnMarshalResponse(t, resp) + + if diff := cmp.Diff(expected(result), result, getCompareOptions()...); diff != "" { + t.Fatalf("Result mismatch (-want +got):\n%s", diff) + } + + // by name + sc.reqContext.ReplaceAllParams(map[string]string{":name": sc.initialResult.Result.Name}) + resp = sc.service.getByNameHandler(sc.reqContext) + arrayResult := validateAndUnMarshalArrayResponse(t, resp) + + if diff := cmp.Diff(libraryElementArrayResult{Result: []libraryElement{expected(result).Result}}, arrayResult, getCompareOptions()...); diff != "" { t.Fatalf("Result mismatch (-want +got):\n%s", diff) } }) @@ -102,57 +122,77 @@ func TestGetLibraryElement(t *testing.T) { err := sc.service.ConnectElementsToDashboard(sc.reqContext, []string{sc.initialResult.Result.UID}, dashInDB.Id) require.NoError(t, err) - sc.reqContext.ReplaceAllParams(map[string]string{":uid": sc.initialResult.Result.UID}) - resp := sc.service.getHandler(sc.reqContext) - var result = validateAndUnMarshalResponse(t, resp) - var expected = libraryElementResult{ - Result: libraryElement{ - ID: 1, - OrgID: 1, - FolderID: 1, - UID: result.Result.UID, - Name: "Text - Library Panel", - Kind: int64(Panel), - Type: "text", - Description: "A description", - Model: map[string]interface{}{ - "datasource": "${DS_GDEV-TESTDATA}", - "description": "A description", - "id": float64(1), - "title": "Text - Library Panel", - "type": "text", - }, - Version: 1, - Meta: LibraryElementDTOMeta{ - FolderName: "ScenarioFolder", - FolderUID: sc.folder.Uid, - ConnectedDashboards: 1, - Created: result.Result.Meta.Created, - Updated: result.Result.Meta.Updated, - CreatedBy: LibraryElementDTOMetaUser{ - ID: 1, - Name: userInDbName, - AvatarURL: userInDbAvatar, + expected := func(res libraryElementResult) libraryElementResult { + return libraryElementResult{ + Result: libraryElement{ + ID: 1, + OrgID: 1, + FolderID: 1, + UID: res.Result.UID, + Name: "Text - Library Panel", + Kind: int64(Panel), + Type: "text", + Description: "A description", + Model: map[string]interface{}{ + "datasource": "${DS_GDEV-TESTDATA}", + "description": "A description", + "id": float64(1), + "title": "Text - Library Panel", + "type": "text", }, - UpdatedBy: LibraryElementDTOMetaUser{ - ID: 1, - Name: userInDbName, - AvatarURL: userInDbAvatar, + Version: 1, + Meta: LibraryElementDTOMeta{ + FolderName: "ScenarioFolder", + FolderUID: sc.folder.Uid, + ConnectedDashboards: 1, + Created: res.Result.Meta.Created, + Updated: res.Result.Meta.Updated, + CreatedBy: LibraryElementDTOMetaUser{ + ID: 1, + Name: userInDbName, + AvatarURL: userInDbAvatar, + }, + UpdatedBy: LibraryElementDTOMetaUser{ + ID: 1, + Name: userInDbName, + AvatarURL: userInDbAvatar, + }, }, }, - }, + } + } + + // by uid + sc.reqContext.ReplaceAllParams(map[string]string{":uid": sc.initialResult.Result.UID}) + resp := sc.service.getHandler(sc.reqContext) + result := validateAndUnMarshalResponse(t, resp) + + if diff := cmp.Diff(expected(result), result, getCompareOptions()...); diff != "" { + t.Fatalf("Result mismatch (-want +got):\n%s", diff) } - if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" { + + // by name + sc.reqContext.ReplaceAllParams(map[string]string{":name": sc.initialResult.Result.Name}) + resp = sc.service.getByNameHandler(sc.reqContext) + arrayResult := validateAndUnMarshalArrayResponse(t, resp) + if diff := cmp.Diff(libraryElementArrayResult{Result: []libraryElement{expected(result).Result}}, arrayResult, getCompareOptions()...); diff != "" { t.Fatalf("Result mismatch (-want +got):\n%s", diff) } }) scenarioWithPanel(t, "When an admin tries to get a library panel that exists in an other org, it should fail", func(t *testing.T, sc scenarioContext) { - sc.reqContext.ReplaceAllParams(map[string]string{":uid": sc.initialResult.Result.UID}) sc.reqContext.SignedInUser.OrgId = 2 sc.reqContext.SignedInUser.OrgRole = models.ROLE_ADMIN + + // by uid + sc.reqContext.ReplaceAllParams(map[string]string{":uid": sc.initialResult.Result.UID}) resp := sc.service.getHandler(sc.reqContext) require.Equal(t, 404, resp.Status()) + + // by name + sc.reqContext.ReplaceAllParams(map[string]string{":name": sc.initialResult.Result.Name}) + resp = sc.service.getByNameHandler(sc.reqContext) + require.Equal(t, 404, resp.Status()) }) } diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index 8f848630757d..7cc9b854ae10 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -110,6 +110,10 @@ type libraryElementResult struct { Result libraryElement `json:"result"` } +type libraryElementArrayResult struct { + Result []libraryElement `json:"result"` +} + type libraryElementsSearch struct { Result libraryElementsSearchResult `json:"result"` } @@ -248,6 +252,17 @@ func validateAndUnMarshalResponse(t *testing.T, resp response.Response) libraryE return result } +func validateAndUnMarshalArrayResponse(t *testing.T, resp response.Response) libraryElementArrayResult { + t.Helper() + + require.Equal(t, 200, resp.Status()) + var result = libraryElementArrayResult{} + err := json.Unmarshal(resp.Body(), &result) + require.NoError(t, err) + + return result +} + func scenarioWithPanel(t *testing.T, desc string, fn func(t *testing.T, sc scenarioContext)) { t.Helper() diff --git a/pkg/services/libraryelements/writers.go b/pkg/services/libraryelements/writers.go index 6ad531ef774a..f8dcd52bcdfe 100644 --- a/pkg/services/libraryelements/writers.go +++ b/pkg/services/libraryelements/writers.go @@ -8,6 +8,28 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore" ) +type Pair struct { + key string + value interface{} +} + +func selectLibraryElementByParam(params []Pair) (string, []interface{}) { + conditions := make([]string, 0, len(params)) + values := make([]interface{}, 0, len(params)) + for _, p := range params { + conditions = append(conditions, "le."+p.key+"=?") + values = append(values, p.value) + } + return ` WHERE ` + strings.Join(conditions, " AND "), values +} + +func writeParamSelectorSQL(builder *sqlstore.SQLBuilder, params ...Pair) { + if len(params) > 0 { + conditionString, paramValues := selectLibraryElementByParam(params) + builder.Write(conditionString, paramValues...) + } +} + func writePerPageSQL(query searchLibraryElementsQuery, sqlStore *sqlstore.SQLStore, builder *sqlstore.SQLBuilder) { if query.perPage != 0 { offset := query.perPage * (query.page - 1) diff --git a/public/app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal.tsx b/public/app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal.tsx index 673862982ca0..a2d041be3c2d 100644 --- a/public/app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal.tsx +++ b/public/app/features/library-panels/components/AddLibraryPanelModal/AddLibraryPanelModal.tsx @@ -1,8 +1,11 @@ -import React, { useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Button, Field, Input, Modal } from '@grafana/ui'; import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { PanelModel } from '../../../dashboard/state'; import { usePanelSave } from '../../utils/usePanelSave'; +import { useAsync, useDebounce } from 'react-use'; +import { getLibraryPanelByName } from '../../state/api'; + interface AddLibraryPanelContentsProps { onDismiss: () => void; panel: PanelModel; @@ -12,11 +15,42 @@ interface AddLibraryPanelContentsProps { export const AddLibraryPanelContents = ({ panel, initialFolderId, onDismiss }: AddLibraryPanelContentsProps) => { const [folderId, setFolderId] = useState(initialFolderId); const [panelTitle, setPanelTitle] = useState(panel.title); + const [debouncedPanelTitle, setDebouncedPanelTitle] = useState(panel.title); + const [waiting, setWaiting] = useState(false); + + useEffect(() => setWaiting(true), [panelTitle]); + useDebounce(() => setDebouncedPanelTitle(panelTitle), 350, [panelTitle]); + const { saveLibraryPanel } = usePanelSave(); + const onCreate = useCallback(() => { + panel.title = panelTitle; + saveLibraryPanel(panel, folderId!).then((res) => { + if (!(res instanceof Error)) { + onDismiss(); + } + }); + }, [panel, panelTitle, folderId, onDismiss, saveLibraryPanel]); + const isValidTitle = useAsync(async () => { + try { + return !(await getLibraryPanelByName(panelTitle)).some((lp) => lp.folderId === folderId); + } catch (err) { + err.isHandled = true; + return true; + } finally { + setWaiting(false); + } + }, [debouncedPanelTitle, folderId]); + + const invalidInput = + !isValidTitle?.value && isValidTitle.value !== undefined && panelTitle === debouncedPanelTitle && !waiting; return ( <> - + setPanelTitle(e.currentTarget.value)} /> @@ -27,12 +61,7 @@ export const AddLibraryPanelContents = ({ panel, initialFolderId, onDismiss }: A - diff --git a/public/app/features/library-panels/state/api.ts b/public/app/features/library-panels/state/api.ts index 81d83569c8d4..56131c07577d 100644 --- a/public/app/features/library-panels/state/api.ts +++ b/public/app/features/library-panels/state/api.ts @@ -48,6 +48,11 @@ export async function getLibraryPanel(uid: string): Promise { return result; } +export async function getLibraryPanelByName(name: string): Promise { + const { result } = await getBackendSrv().get<{ result: LibraryElementDTO[] }>(`/api/library-elements/name/${name}`); + return result; +} + export async function addLibraryPanel( panelSaveModel: PanelModelWithLibraryPanel, folderId: number diff --git a/public/app/features/library-panels/utils/usePanelSave.ts b/public/app/features/library-panels/utils/usePanelSave.ts index ad77fa94e200..436d2b255b7f 100644 --- a/public/app/features/library-panels/utils/usePanelSave.ts +++ b/public/app/features/library-panels/utils/usePanelSave.ts @@ -13,7 +13,12 @@ import { notifyApp } from 'app/core/actions'; export const usePanelSave = () => { const dispatch = useDispatch(); const [state, saveLibraryPanel] = useAsyncFn(async (panel: PanelModel, folderId: number) => { - return await saveAndRefreshLibraryPanel(panel, folderId); + try { + return await saveAndRefreshLibraryPanel(panel, folderId); + } catch (err) { + err.isHandled = true; + throw new Error(err.data.message); + } }, []); useEffect(() => {