Library Panels: Add name endpoint & unique name validation to AddLibraryPanelModal (#33987)

This commit is contained in:
kay delaney 2021-05-14 15:03:37 +01:00 committed by GitHub
parent d49deebefe
commit c778d6a4a2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 269 additions and 127 deletions

View File

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

View File

@ -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
}
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 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
}
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),
},
},
if len(libraryElements) > 1 {
return LibraryElementDTO{}, fmt.Errorf("found %d elements, while expecting at most one", len(libraryElements))
}
return dto, nil
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.

View File

@ -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) {
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",
},
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,
},
},
},
}
}
// by uid
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,
},
UpdatedBy: LibraryElementDTOMetaUser{
ID: 1,
Name: userInDbName,
AvatarURL: userInDbAvatar,
},
},
},
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)
}
})
@ -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)
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",
},
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)
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,
},
UpdatedBy: LibraryElementDTOMetaUser{
ID: 1,
Name: userInDbName,
AvatarURL: userInDbAvatar,
},
},
},
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())
})
}

View File

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

View File

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

View File

@ -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 (
<>
<Field label="Library panel name">
<Field
label="Library panel name"
invalid={invalidInput}
error={invalidInput ? 'Library panel with this name already exists' : ''}
>
<Input name="name" value={panelTitle} onChange={(e) => setPanelTitle(e.currentTarget.value)} />
</Field>
<Field label="Save in folder" description="Library panel permissions are derived from the folder permissions">
@ -27,12 +61,7 @@ export const AddLibraryPanelContents = ({ panel, initialFolderId, onDismiss }: A
<Button variant="secondary" onClick={onDismiss} fill="outline">
Cancel
</Button>
<Button
onClick={() => {
panel.title = panelTitle;
saveLibraryPanel(panel, folderId!).then(() => onDismiss());
}}
>
<Button onClick={onCreate} disabled={invalidInput}>
Create library panel
</Button>
</Modal.ButtonRow>

View File

@ -48,6 +48,11 @@ export async function getLibraryPanel(uid: string): Promise<LibraryElementDTO> {
return result;
}
export async function getLibraryPanelByName(name: string): Promise<LibraryElementDTO[]> {
const { result } = await getBackendSrv().get<{ result: LibraryElementDTO[] }>(`/api/library-elements/name/${name}`);
return result;
}
export async function addLibraryPanel(
panelSaveModel: PanelModelWithLibraryPanel,
folderId: number

View File

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