Commit cd61d2b2 by Marcus Efraimsson

Merge branch 'master' into 7883_new_url_structure

parents cef910dd 744f402a
+++
title = "Permissions"
description = "Grafana user permissions"
keywords = ["grafana", "configuration", "documentation", "admin", "users", "permissions"]
type = "docs"
aliases = ["/reference/admin"]
[menu.docs]
name = "Permissions"
parent = "admin"
weight = 3
+++
# Permissions
Grafana users have permissions that are determined by their:
- **Organization Role** (Admin, Editor, Viewer)
- Via **Team** memberships where the **Team** has been assigned specific permissions.
- Via permissions assigned directly to user (on folders or dashboards)
- The Grafana Admin (i.e. Super Admin) user flag.
## Organization Roles
Users can be belong to one or more organizations. A user's organization membership is tied to a role that defines what the user is allowed to do
in that organization.
### Admin Role
Can do everything scoped to the organization. For example:
- Add & Edit data data sources.
- Add & Edit organization users & teams.
- Configure App plugins & set org settings.
### Editor Role
- Can create and modify dashboards & alert rules. This can be disabled on specific folders and dashboards.
- **Cannot** create or edit data sources nor invite new users.
### Viewer Role
- View any dashboard. This can be disabled on specific folders and dashboards.
- **Cannot** create or edit dashboards nor data sources.
This role can be tweaked via Grafana server setting [viewers_can_edit]({{< relref "installation/configuration.md#viewers-can-edit" >}}). If you set this to true users
with **Viewer** can also make transient dashboard edits, meaning they can modify panels & queries but not save the changes (nor create new dashboards).
Useful for public Grafana installations where you want anonymous users to be able to edit panels & queries but not save or create new dashboards.
## Grafana Admin
This admin flag makes a user a `Super Admin`. This means they can access the `Server Admin` views where all users and organizations can be administrated.
### Dashboard & Folder Permissions
> Introduced in Grafana v5.0
{{< docs-imagebox img="/img/docs/v50/folder_permissions.png" max-width="500px" class="docs-image--right" >}}
For dashboards and dashboard folders there is a **Permissions** page that make it possible to
remove the default role based permssions for Editors and Viewers. It's here you can add and assign permissions to specific **Users** and **Teams**.
You can assign & remove permissions for **Organization Roles**, **Users** and **Teams**.
Permission levels:
- **Admin**: Can edit & create dashboards and edit permissions.
- **Edit**: Can edit & create dashboards. **Cannot** edit folder/dashboard permissions.
- **View**: Can only view existing dashboars/folders.
#### Restricting access
The highest permission always wins so if you for example want to hide a folder or dashboard from others you need to remove the **Organization Role** based permission from the
Access Control List (ACL).
- You cannot override permissions for users with **Org Admin Role**
- A more specific permission with lower permission level will not have any effect if a more general rule exists with higher permission level. For example if "Everyone with Editor Role Can Edit" exists in the ACL list then **John Doe** will still have Edit permission even after you have specifically added a permission for this user with the permission set to **View**. You need to remove or lower the permission level of the more general rule.
\ No newline at end of file
+++
title = "What's New in Grafana v5.0"
description = "Feature & improvement highlights for Grafana v5.0"
keywords = ["grafana", "new", "documentation", "5.0"]
type = "docs"
[menu.docs]
name = "Version 5.0"
identifier = "v5.0"
parent = "whatsnew"
weight = -6
+++
# What's New in Grafana v5.0
This is the most substantial update that Grafana has ever seen. This article will detail the major new features and enhancements.
- [New Dashboard Layout Engine]({{< relref "#new-dashboard-layout-engine" >}}) enables a much easier drag, drop and resize experience and new types of layouts.
- [New UX]({{< relref "#new-ux-layout-engine" >}}). The UI has big improvements in both look and function.
- [New Light Theme]({{< relref "#new-light-theme" >}}) is now looking really nice.
- [Dashboard Folders]({{< relref "#dashboard-folders" >}}) helps you keep your dashboards organized.
- [Permissions]({{< relref "#dashboard-folders" >}}) on folders and dashboards helps manage larger Grafana installations.
- [Group users into teams]({{< relref "#teams" >}}) and use them in the new permission system.
- [Datasource provisioning]({{< relref "#data-sources" >}}) makes it possible to setup datasources via config files.
- [Dashboard provisioning]({{< relref "#dashboards" >}}) makes it possible to setup dashboards via config files.
### Video showing new features
<iframe height="215" src="https://www.youtube.com/embed/BC_YRNpqj5k?rel=0&amp;showinfo=0" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
<br />
## New Dashboard Layout Engine
{{< docs-imagebox img="/img/docs/v50/new_grid.png" max-width="1000px" class="docs-image--right">}}
The new dashboard layout engine allows for much easier movement and sizing of panels, as other panels now move out of the way in
a very intuitive way. Panels are sized independently, so rows are no longer necessary to create layouts. This opens
up many new types of layouts where panels of different heights can be aligned easily. Checkout the new grid in the video
above or on the [play site](http://play.grafana.org). All your existing dashboards will automatically migrate to the
new position system and look close to identical. The new panel position makes dashboards saved in v5.0 not compatible
with older versions of Grafana.
<div class="clearfix"></div>
## New UX
{{< docs-imagebox img="/img/docs/v50/new_ux_nav.png" max-width="1000px" class="docs-image--right" >}}
Almost every page has seen significant UX improvements. All pages (except dashboard pages) have a new tab-based layout that improves navigation between pages. The side menu has also changed quite a bit. You can still hide the side menu completely if you click on the Grafana logo.
<div class="clearfix"></div>
### Dashboard Settings
{{< docs-imagebox img="/img/docs/v50/dashboard_settings.png" max-width="1000px" class="docs-image--right" >}}
Dashboard pages have a new header toolbar where buttons and actions are now all moved to the right. All the dashboard
settings views have been combined with a side nav which allows you to easily move between different setting categories.
<div class="clearfix"></div>
## New Light Theme
{{< docs-imagebox img="/img/docs/v50/new_white_theme.png" max-width="1000px" class="docs-image--right" >}}
This theme has not seen a lot of love in recent years and we felt it was time to rework it and give it a major overhaul. We are very happy with the result.
<div class="clearfix"></div>
## Dashboard Folders
{{< docs-imagebox img="/img/docs/v50/new_search.png" max-width="1000px" class="docs-image--right" >}}
The big new feature that comes with Grafana v5.0 is dashboard folders. Now you can organize your dashboards in folders,
which is very useful if you have a lot of dashboards or multiple teams.
- New search design adds expandable sections for each folder, starred and recently viewed dashboards.
- New manage dashboard pages enable batch actions and views for folder settings and permissions.
- Set permissions on folders and have dashboards inherit the permissions.
## Teams
A team is a new concept in Grafana v5. They are simply a group of users that can be then be used in the new permission system for dashboards and folders. Only an admin can create teams.
We hope to do more with teams in future releases like integration with LDAP and a team landing page.
## Permissions
{{< docs-imagebox img="/img/docs/v50/folder_permissions.png" max-width="1000px" class="docs-image--right" >}}
You can assign permissions to folders and dashboards. The default user role-based permissions can be removed and replaced with specific teams or users enabling more control over what a user can see and edit.
<div class="clearfix"></div>
# Provisioning from configuration
In previous versions of Grafana, you could only use the API for provisioning data sources and dashboards.
But that required the service to be running before you started creating dashboards and you also needed to
set up credentials for the HTTP API. In 5.0 we decided to improve this experience by adding a new active
provisioning system that uses config files. This will make GitOps more natural as data sources and dashboards can
be defined via files that can be version controlled. We hope to extend this system to later add support for users, orgs
and alerts as well.
### Data sources
Data sources can now be setup using config files. These data sources are by default not editable from the Grafana GUI.
It's also possible to update and delete data sources from the config file. More info in the [data source provisioning docs](/administration/provisioning/#datasources).
### Dashboards
We also deprecated the [dashboard.json] in favor of our new dashboard provisioner that keeps dashboards on disk
in sync with dashboards in Grafana's database. The dashboard provisioner has multiple advantages over the old
[dashboard.json] feature. Instead of storing the dashboard in memory we now insert the dashboard into the database,
which makes it possible to star them, use one as the home dashboard, set permissions and other features in Grafana that
expects the dashboards to exist in the database. More info in the [dashboard provisioning docs](/administration/provisioning/#dashboards)
# Dashboard model & API
We are introducing a new identifier (`uid`) in the dashboard JSON model. The new identifier will be a 9-12 character long unique id.
We are also changing the route for getting dashboards to use this `uid` instead of the slug that the current route and API are using.
We will keep supporting the old route for backward compatibility. This will make it possible to change the title on dashboards without breaking links.
Sharing dashboards between instances becomes much easier since the uid is unique (unique enough). This might seem like a small change,
but we are incredibly excited about it since it will make it much easier to manage, collaborate and navigate between dashboards.
\ No newline at end of file
......@@ -671,31 +671,6 @@ session provider you have configured.
- **memcache:** ex: 127.0.0.1:11211
- **redis:** ex: `addr=127.0.0.1:6379,pool_size=100,prefix=grafana`
If you use MySQL or Postgres as the session store you need to create the
session table manually.
Mysql Example:
```bash
CREATE TABLE `session` (
`key` CHAR(16) NOT NULL,
`data` BLOB,
`expiry` INT(11) UNSIGNED NOT NULL,
PRIMARY KEY (`key`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
```
Postgres Example:
```bash
CREATE TABLE session (
key CHAR(16) NOT NULL,
data BYTEA,
expiry INTEGER NOT NULL,
PRIMARY KEY (key)
);
```
Postgres valid `sslmode` are `disable`, `require`, `verify-ca`, and `verify-full` (default).
### cookie_name
......
+++
title = "Admin Roles"
description = "Users & Organization permission and administration"
keywords = ["grafana", "configuration", "documentation", "admin", "users", "permissions"]
type = "docs"
[menu.docs]
name = "Admin Roles"
parent = "admin"
weight = 3
+++
# Administration
Grafana has two levels of administrators:
* Organizational administrators: These admins can manage users within specific organizations in a particular Grafana installation
* Grafana administrators: These super admins can manage users across all organizations in a Grafana installation. They can also change and access system-wide settings.
## Organizational Administrators
As an Organizational administrator, you can add `Data Sources`, add Users to your Organization and
modify Organization details and options.
> *Note*: If Grafana is configured with `users.allow_org_create = true`, any User of any Organization will be able to
> start their own Organization and become the administrator of that Organization.
## Grafana Administrators
<img src="/img/v2/admin_sidenav.png" class="pull-right" style="margin-left: 15px">
As a Grafana Administrator, you have complete access to any Organization or User in that instance of Grafana.
When performing actions as a Grafana admin, the sidebar will change it's appearance as below to indicate you are performing global server administration.
From the Grafana Server Admin page, you can access the System Info page which summarizes all of the backend configuration settings of the Grafana server.
## Why would I have multiple Organizations?
Organizations in Grafana are best suited for a **multi-tenant deployment**. In a multi-tenant deployment,
Organizations can be used to provide a full Grafana experience to different sets of users from a single Grafana instance,
at the convenience of the Grafana Administrator.
In most cases, a Grafana installation will only have **one** Organization. Since dashboards, data sources and other configuration items are not shared between organizations, there's no need to create multiple Organizations if you want all your users to have access to the same set of dashboards and data.
......@@ -115,6 +115,10 @@
"*.scss": [
"prettier --write",
"git add"
],
"*.go": [
"gofmt -w -s",
"git add"
]
},
"prettier": {
......@@ -153,6 +157,7 @@
"react-popper": "^0.7.5",
"react-select": "^1.1.0",
"react-sizeme": "^2.3.6",
"react-transition-group": "^2.2.1",
"remarkable": "^1.7.1",
"rst2html": "github:thoward/rst2html#990cb89",
"rxjs": "^5.4.3",
......
......@@ -91,9 +91,15 @@ func RenderToPng(params *RenderOpts) (string, error) {
timeout = 15
}
phantomDebugArg := "--debug=false"
if log.GetLogLevelFor("png-renderer") >= log.LvlDebug {
phantomDebugArg = "--debug=true"
}
cmdArgs := []string{
"--ignore-ssl-errors=true",
"--web-security=false",
phantomDebugArg,
scriptPath,
"url=" + url,
"width=" + params.Width,
......@@ -109,15 +115,13 @@ func RenderToPng(params *RenderOpts) (string, error) {
}
cmd := exec.Command(binPath, cmdArgs...)
stdout, err := cmd.StdoutPipe()
output, err := cmd.StdoutPipe()
if err != nil {
rendererLog.Error("Could not acquire stdout pipe", err)
return "", err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return "", err
}
cmd.Stderr = cmd.Stdout
if params.Timezone != "" {
baseEnviron := os.Environ()
......@@ -126,11 +130,12 @@ func RenderToPng(params *RenderOpts) (string, error) {
err = cmd.Start()
if err != nil {
rendererLog.Error("Could not start command", err)
return "", err
}
go io.Copy(os.Stdout, stdout)
go io.Copy(os.Stdout, stderr)
logWriter := log.NewLogWriter(rendererLog, log.LvlDebug, "[phantom] ")
go io.Copy(logWriter, output)
done := make(chan error)
go func() {
......
......@@ -21,6 +21,7 @@ import (
var Root log15.Logger
var loggersToClose []DisposableHandler
var filters map[string]log15.Lvl
func init() {
loggersToClose = make([]DisposableHandler, 0)
......@@ -114,6 +115,25 @@ func Close() {
loggersToClose = make([]DisposableHandler, 0)
}
func GetLogLevelFor(name string) Lvl {
if level, ok := filters[name]; ok {
switch level {
case log15.LvlWarn:
return LvlWarn
case log15.LvlInfo:
return LvlInfo
case log15.LvlError:
return LvlError
case log15.LvlCrit:
return LvlCrit
default:
return LvlDebug
}
}
return LvlInfo
}
var logLevels = map[string]log15.Lvl{
"trace": log15.LvlDebug,
"debug": log15.LvlDebug,
......@@ -187,7 +207,7 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) {
// Log level.
_, level := getLogLevelFromConfig("log."+mode, defaultLevelName, cfg)
modeFilters := getFilters(util.SplitString(sec.Key("filters").String()))
filters := getFilters(util.SplitString(sec.Key("filters").String()))
format := getLogFormat(sec.Key("format").MustString(""))
var handler log15.Handler
......@@ -219,12 +239,12 @@ func ReadLoggingConfig(modes []string, logsPath string, cfg *ini.File) {
}
for key, value := range defaultFilters {
if _, exist := modeFilters[key]; !exist {
modeFilters[key] = value
if _, exist := filters[key]; !exist {
filters[key] = value
}
}
handler = LogFilterHandler(level, modeFilters, handler)
handler = LogFilterHandler(level, filters, handler)
handlers = append(handlers, handler)
}
......
package log
import (
"io"
"strings"
)
type logWriterImpl struct {
log Logger
level Lvl
prefix string
}
func NewLogWriter(log Logger, level Lvl, prefix string) io.Writer {
return &logWriterImpl{
log: log,
level: level,
prefix: prefix,
}
}
func (l *logWriterImpl) Write(p []byte) (n int, err error) {
message := l.prefix + strings.TrimSpace(string(p))
switch l.level {
case LvlCrit:
l.log.Crit(message)
case LvlError:
l.log.Error(message)
case LvlWarn:
l.log.Warn(message)
case LvlInfo:
l.log.Info(message)
default:
l.log.Debug(message)
}
return len(p), nil
}
package log
import (
"testing"
"github.com/inconshreveable/log15"
. "github.com/smartystreets/goconvey/convey"
)
type FakeLogger struct {
debug string
info string
warn string
err string
crit string
}
func (f *FakeLogger) New(ctx ...interface{}) log15.Logger {
return nil
}
func (f *FakeLogger) Debug(msg string, ctx ...interface{}) {
f.debug = msg
}
func (f *FakeLogger) Info(msg string, ctx ...interface{}) {
f.info = msg
}
func (f *FakeLogger) Warn(msg string, ctx ...interface{}) {
f.warn = msg
}
func (f *FakeLogger) Error(msg string, ctx ...interface{}) {
f.err = msg
}
func (f *FakeLogger) Crit(msg string, ctx ...interface{}) {
f.crit = msg
}
func (f *FakeLogger) GetHandler() log15.Handler {
return nil
}
func (f *FakeLogger) SetHandler(l log15.Handler) {}
func TestLogWriter(t *testing.T) {
Convey("When writing to a LogWriter", t, func() {
Convey("Should write using the correct level [crit]", func() {
fake := &FakeLogger{}
crit := NewLogWriter(fake, LvlCrit, "")
n, err := crit.Write([]byte("crit"))
So(n, ShouldEqual, 4)
So(err, ShouldBeNil)
So(fake.crit, ShouldEqual, "crit")
})
Convey("Should write using the correct level [error]", func() {
fake := &FakeLogger{}
crit := NewLogWriter(fake, LvlError, "")
n, err := crit.Write([]byte("error"))
So(n, ShouldEqual, 5)
So(err, ShouldBeNil)
So(fake.err, ShouldEqual, "error")
})
Convey("Should write using the correct level [warn]", func() {
fake := &FakeLogger{}
crit := NewLogWriter(fake, LvlWarn, "")
n, err := crit.Write([]byte("warn"))
So(n, ShouldEqual, 4)
So(err, ShouldBeNil)
So(fake.warn, ShouldEqual, "warn")
})
Convey("Should write using the correct level [info]", func() {
fake := &FakeLogger{}
crit := NewLogWriter(fake, LvlInfo, "")
n, err := crit.Write([]byte("info"))
So(n, ShouldEqual, 4)
So(err, ShouldBeNil)
So(fake.info, ShouldEqual, "info")
})
Convey("Should write using the correct level [debug]", func() {
fake := &FakeLogger{}
crit := NewLogWriter(fake, LvlDebug, "")
n, err := crit.Write([]byte("debug"))
So(n, ShouldEqual, 5)
So(err, ShouldBeNil)
So(fake.debug, ShouldEqual, "debug")
})
Convey("Should prefix the output with the prefix", func() {
fake := &FakeLogger{}
crit := NewLogWriter(fake, LvlDebug, "prefix")
n, err := crit.Write([]byte("debug"))
So(n, ShouldEqual, 5) // n is how much of input consumed
So(err, ShouldBeNil)
So(fake.debug, ShouldEqual, "prefixdebug")
})
})
}
......@@ -374,16 +374,19 @@ func GetFoldersForSignedInUser(query *m.GetFoldersForSignedInUserQuery) error {
params = append(params, query.SignedInUser.UserId)
params = append(params, query.OrgId)
sql += `WHERE
sql += ` WHERE
d.org_id = ? AND
d.is_folder = 1 AND
d.is_folder = ? AND
(
(d.has_acl = 1 AND da.permission > 1 AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL))
OR (d.has_acl = 0 AND ouRole.id IS NOT NULL)
(d.has_acl = ? AND da.permission > 1 AND (da.user_id = ? OR ugm.user_id = ? OR ou.id IS NOT NULL))
OR (d.has_acl = ? AND ouRole.id IS NOT NULL)
)`
params = append(params, query.OrgId)
params = append(params, dialect.BooleanStr(true))
params = append(params, dialect.BooleanStr(true))
params = append(params, query.SignedInUser.UserId)
params = append(params, query.SignedInUser.UserId)
params = append(params, dialect.BooleanStr(false))
if len(query.Title) > 0 {
sql += " AND d.title " + dialect.LikeStr() + " ?"
......@@ -391,7 +394,6 @@ func GetFoldersForSignedInUser(query *m.GetFoldersForSignedInUserQuery) error {
}
sql += ` ORDER BY d.title ASC`
err = x.Sql(sql, params...).Find(&query.Result)
}
......@@ -488,9 +490,9 @@ func GetDashboardPermissionsForUser(query *m.GetDashboardPermissionsForUserQuery
params = append(params, query.OrgId)
sql += `
LEFT JOIN (SELECT 1 AS permission, 'Viewer' AS 'role'
UNION SELECT 2 AS permission, 'Editor' AS 'role'
UNION SELECT 4 AS permission, 'Admin' AS 'role') pt ON ouRole.role = pt.role
LEFT JOIN (SELECT 1 AS permission, 'Viewer' AS role
UNION SELECT 2 AS permission, 'Editor' AS role
UNION SELECT 4 AS permission, 'Admin' AS role) pt ON ouRole.role = pt.role
WHERE
d.Id IN (?` + strings.Repeat(",?", len(query.DashboardIds)-1) + `) `
for _, id := range query.DashboardIds {
......@@ -505,13 +507,15 @@ func GetDashboardPermissionsForUser(query *m.GetDashboardPermissionsForUserQuery
)
group by d.id
order by d.id asc`
params = append(params, dialect.BooleanStr(true))
params = append(params, query.OrgId)
params = append(params, dialect.BooleanStr(true))
params = append(params, query.UserId)
params = append(params, query.UserId)
params = append(params, dialect.BooleanStr(false))
x.ShowSQL(true)
err := x.Sql(sql, params...).Find(&query.Result)
x.ShowSQL(false)
for _, p := range query.Result {
p.PermissionName = p.Permission.String()
......
......@@ -6,11 +6,14 @@ import PageHeader from 'app/core/components/PageHeader/PageHeader';
import Permissions from 'app/core/components/Permissions/Permissions';
import Tooltip from 'app/core/components/Tooltip/Tooltip';
import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
import AddPermissions from 'app/core/components/Permissions/AddPermissions';
import SlideDown from 'app/core/components/Animations/SlideDown';
@inject('nav', 'folder', 'view', 'permissions')
@observer
export class FolderPermissions extends Component<IContainerProps, any> {
constructor(props) {
super(props);
this.handleAddPermission = this.handleAddPermission.bind(this);
this.loadStore();
}
......@@ -22,6 +25,11 @@ export class FolderPermissions extends Component<IContainerProps, any> {
});
}
handleAddPermission() {
const { permissions } = this.props;
permissions.toggleAddPermissions();
}
render() {
const { nav, folder, permissions, backendSrv } = this.props;
......@@ -35,13 +43,23 @@ export class FolderPermissions extends Component<IContainerProps, any> {
<div>
<PageHeader model={nav as any} />
<div className="page-container page-body">
<div className="page-sub-heading">
<div className="page-action-bar">
<h2 className="d-inline-block">Folder Permissions</h2>
<Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
<i className="gicon gicon-question gicon--has-hover" />
</Tooltip>
<div className="page-action-bar__spacer" />
<button
className="btn btn-success pull-right"
onClick={this.handleAddPermission}
disabled={permissions.isAddPermissionsVisible}
>
<i className="fa fa-plus" /> Add Permission
</button>
</div>
<SlideDown in={permissions.isAddPermissionsVisible}>
<AddPermissions permissions={permissions} backendSrv={backendSrv} dashboardId={dashboardId} />
</SlideDown>
<Permissions permissions={permissions} isFolder={true} dashboardId={dashboardId} backendSrv={backendSrv} />
</div>
</div>
......
import React from 'react';
import Transition from 'react-transition-group/Transition';
const defaultMaxHeight = '200px'; // When animating using max-height we need to use a static value.
// If this is not enough, pass in <SlideDown maxHeight="....
const defaultDuration = 200;
const defaultStyle = {
transition: `max-height ${defaultDuration}ms ease-in-out`,
overflow: 'hidden',
};
export default ({ children, in: inProp, maxHeight = defaultMaxHeight }) => {
// There are 4 main states a Transition can be in:
// ENTERING, ENTERED, EXITING, EXITED
// https://reactcommunity.org/react-transition-group/
const transitionStyles = {
exited: { maxHeight: 0 },
entering: { maxHeight: maxHeight },
entered: { maxHeight: maxHeight, overflow: 'visible' },
exiting: { maxHeight: 0 },
};
return (
<Transition in={inProp} timeout={defaultDuration}>
{state => (
<div
style={{
...defaultStyle,
...transitionStyles[state],
}}
>
{children}
</div>
)}
</Transition>
);
};
import React from 'react';
import Permissions from './Permissions';
import React from 'react';
import AddPermissions from './AddPermissions';
import { RootStore } from 'app/stores/RootStore/RootStore';
import { backendSrv } from 'test/mocks/common';
import { shallow } from 'enzyme';
describe('Permissions', () => {
describe('AddPermissions', () => {
let wrapper;
let store;
let instance;
beforeAll(() => {
backendSrv.get.mockReturnValue(
Promise.resolve([
{ id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
{ id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
{
id: 4,
dashboardId: 1,
userId: 2,
userLogin: 'danlimerick',
userEmail: 'dan.limerick@gmail.com',
permission: 4,
permissionName: 'Admin',
},
])
);
backendSrv.post = jest.fn();
const store = RootStore.create(
store = RootStore.create(
{},
{
backendSrv: backendSrv,
}
);
wrapper = shallow(<Permissions backendSrv={backendSrv} isFolder={true} dashboardId={1} {...store} />);
return wrapper.instance().loadStore(1, true);
wrapper = shallow(<AddPermissions permissions={store.permissions} backendSrv={backendSrv} dashboardId={1} />);
instance = wrapper.instance();
return store.permissions.load(1, true, false);
});
describe('when permission for a user is added', () => {
it('should save permission to db', () => {
const evt = {
target: {
value: 'User',
},
};
const userItem = {
id: 2,
login: 'user2',
};
wrapper
.instance()
.userPicked(userItem)
.then(() => {
expect(backendSrv.post.mock.calls.length).toBe(1);
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
});
instance.typeChanged(evt);
instance.userPicked(userItem);
wrapper.update();
expect(wrapper.find('[data-save-permission]').prop('disabled')).toBe(false);
wrapper.find('form').simulate('submit', { preventDefault() {} });
expect(backendSrv.post.mock.calls.length).toBe(1);
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
});
});
describe('when permission for team is added', () => {
it('should save permission to db', () => {
const evt = {
target: {
value: 'Group',
},
};
const teamItem = {
id: 2,
name: 'ug1',
};
wrapper
.instance()
.teamPicked(teamItem)
.then(() => {
expect(backendSrv.post.mock.calls.length).toBe(1);
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
});
instance.typeChanged(evt);
instance.teamPicked(teamItem);
wrapper.update();
expect(wrapper.find('[data-save-permission]').prop('disabled')).toBe(false);
wrapper.find('form').simulate('submit', { preventDefault() {} });
expect(backendSrv.post.mock.calls.length).toBe(1);
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
});
});
afterEach(() => {
backendSrv.post.mockClear();
});
});
import React, { Component } from 'react';
import { observer } from 'mobx-react';
import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker';
import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
export interface IProps {
permissions: any;
backendSrv: any;
dashboardId: any;
}
@observer
class AddPermissions extends Component<IProps, any> {
constructor(props) {
super(props);
this.userPicked = this.userPicked.bind(this);
this.teamPicked = this.teamPicked.bind(this);
this.permissionPicked = this.permissionPicked.bind(this);
this.typeChanged = this.typeChanged.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
componentWillMount() {
const { permissions } = this.props;
permissions.resetNewType();
}
typeChanged(evt) {
const { value } = evt.target;
const { permissions } = this.props;
// if (value === 'Viewer' || value === 'Editor') {
// // permissions.addStoreItem({ permission: 1, role: value, dashboardId: dashboardId }, dashboardId);
// // this.resetNewType();
// return;
// }
permissions.setNewType(value);
}
userPicked(user: User) {
const { permissions } = this.props;
if (!user) {
permissions.newItem.setUser(null, null);
return;
}
return permissions.newItem.setUser(user.id, user.login);
}
teamPicked(team: Team) {
const { permissions } = this.props;
if (!team) {
permissions.newItem.setTeam(null, null);
return;
}
return permissions.newItem.setTeam(team.id, team.name);
}
permissionPicked(permission: OptionWithDescription) {
const { permissions } = this.props;
return permissions.newItem.setPermission(permission.value);
}
resetNewType() {
const { permissions } = this.props;
return permissions.resetNewType();
}
handleSubmit(evt) {
evt.preventDefault();
const { permissions } = this.props;
permissions.addStoreItem();
}
render() {
const { permissions, backendSrv } = this.props;
const newItem = permissions.newItem;
const pickerClassName = 'width-20';
const isValid = newItem.isValid();
return (
<div className="gf-form-inline cta-form">
<button className="cta-form__close btn btn-transparent" onClick={permissions.hideAddPermissions}>
<i className="fa fa-close" />
</button>
<form name="addPermission" onSubmit={this.handleSubmit}>
<h6>Add Permission For</h6>
<div className="gf-form-inline">
<div className="gf-form">
<div className="gf-form-select-wrapper">
<select className="gf-form-input gf-size-auto" value={newItem.type} onChange={this.typeChanged}>
{aclTypes.map((option, idx) => {
return (
<option key={idx} value={option.value}>
{option.text}
</option>
);
})}
</select>
</div>
</div>
{newItem.type === 'User' ? (
<div className="gf-form">
<UserPicker
backendSrv={backendSrv}
handlePicked={this.userPicked}
value={newItem.userId}
className={pickerClassName}
/>
</div>
) : null}
{newItem.type === 'Group' ? (
<div className="gf-form">
<TeamPicker
backendSrv={backendSrv}
handlePicked={this.teamPicked}
value={newItem.teamId}
className={pickerClassName}
/>
</div>
) : null}
<div className="gf-form">
<DescriptionPicker
optionsWithDesc={permissionOptions}
handlePicked={this.permissionPicked}
value={newItem.permission}
disabled={false}
className={'gf-form-input--form-dropdown-right'}
/>
</div>
<div className="gf-form">
<button data-save-permission className="btn btn-success" type="submit" disabled={!isValid}>
Save
</button>
</div>
</div>
</form>
{permissions.error ? (
<div className="gf-form width-17">
<span ng-if="ctrl.error" className="text-error p-l-1">
<i className="fa fa-warning" />
{permissions.error}
</span>
</div>
) : null}
</div>
);
}
}
export default AddPermissions;
import React, { Component } from 'react';
import { observer } from 'mobx-react';
import { store } from 'app/stores/store';
import Permissions from 'app/core/components/Permissions/Permissions';
import Tooltip from 'app/core/components/Tooltip/Tooltip';
import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
import AddPermissions from 'app/core/components/Permissions/AddPermissions';
import SlideDown from 'app/core/components/Animations/SlideDown';
export interface IProps {
dashboardId: number;
......@@ -11,26 +14,44 @@ export interface IProps {
folderSlug: string;
backendSrv: any;
}
@observer
class DashboardPermissions extends Component<IProps, any> {
permissions: any;
constructor(props) {
super(props);
this.handleAddPermission = this.handleAddPermission.bind(this);
this.permissions = store.permissions;
}
handleAddPermission() {
this.permissions.toggleAddPermissions();
}
render() {
const { dashboardId, folderTitle, folderSlug, folderId, backendSrv } = this.props;
return (
<div>
<div className="dashboard-settings__header">
<h3 className="d-inline-block">Permissions</h3>
<Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
<i className="gicon gicon-question gicon--has-hover" />
</Tooltip>
<div className="page-action-bar">
<h3 className="d-inline-block">Permissions</h3>
<Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
<i className="gicon gicon-question gicon--has-hover" />
</Tooltip>
<div className="page-action-bar__spacer" />
<button
className="btn btn-success pull-right"
onClick={this.handleAddPermission}
disabled={this.permissions.isAddPermissionsVisible}
>
<i className="fa fa-plus" /> Add Permission
</button>
</div>
</div>
<SlideDown in={this.permissions.isAddPermissionsVisible}>
<AddPermissions permissions={this.permissions} backendSrv={backendSrv} dashboardId={dashboardId} />
</SlideDown>
<Permissions
permissions={this.permissions}
isFolder={false}
......
import React, { Component } from 'react';
import React, { Component } from 'react';
import PermissionsList from './PermissionsList';
import { observer } from 'mobx-react';
import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
import { FolderInfo } from './FolderInfo';
export interface DashboardAcl {
......@@ -40,8 +37,6 @@ class Permissions extends Component<IProps, any> {
this.permissionChanged = this.permissionChanged.bind(this);
this.typeChanged = this.typeChanged.bind(this);
this.removeItem = this.removeItem.bind(this);
this.userPicked = this.userPicked.bind(this);
this.teamPicked = this.teamPicked.bind(this);
this.loadStore(dashboardId, isFolder, folderInfo && folderInfo.id === 0);
}
......@@ -77,28 +72,8 @@ class Permissions extends Component<IProps, any> {
permissions.setNewType(value);
}
userPicked(user: User) {
const { permissions, dashboardId } = this.props;
return permissions.addStoreItem({
userId: user.id,
userLogin: user.login,
permission: 1,
dashboardId: dashboardId,
});
}
teamPicked(team: Team) {
const { permissions, dashboardId } = this.props;
return permissions.addStoreItem({
teamId: team.id,
team: team.name,
permission: 1,
dashboardId: dashboardId,
});
}
render() {
const { permissions, folderInfo, backendSrv } = this.props;
const { permissions, folderInfo } = this.props;
return (
<div className="gf-form-group">
......@@ -109,50 +84,6 @@ class Permissions extends Component<IProps, any> {
fetching={permissions.fetching}
folderInfo={folderInfo}
/>
<div className="gf-form-inline">
<form name="addPermission" className="gf-form-group">
<h6 className="muted">Add Permission For</h6>
<div className="gf-form-inline">
<div className="gf-form">
<div className="gf-form-select-wrapper">
<select
className="gf-form-input gf-size-auto"
value={permissions.newType}
onChange={this.typeChanged}
>
{aclTypes.map((option, idx) => {
return (
<option key={idx} value={option.value}>
{option.text}
</option>
);
})}
</select>
</div>
</div>
{permissions.newType === 'User' ? (
<div className="gf-form">
<UserPicker backendSrv={backendSrv} handlePicked={this.userPicked} />
</div>
) : null}
{permissions.newType === 'Group' ? (
<div className="gf-form">
<TeamPicker backendSrv={backendSrv} handlePicked={this.teamPicked} />
</div>
) : null}
</div>
</form>
{permissions.error ? (
<div className="gf-form width-17">
<span ng-if="ctrl.error" className="text-error p-l-1">
<i className="fa fa-warning" />
{permissions.error}
</span>
</div>
) : null}
</div>
</div>
);
}
......
import React from 'react';
import renderer from 'react-test-renderer';
import TeamPicker from './TeamPicker';
const model = {
backendSrv: {
get: () => {
return new Promise((resolve, reject) => {});
},
},
handlePicked: () => {},
};
describe('TeamPicker', () => {
it('renders correctly', () => {
const tree = renderer.create(<TeamPicker {...model} />).toJSON();
expect(tree).toMatchSnapshot();
});
});
......@@ -9,6 +9,8 @@ export interface IProps {
isLoading: boolean;
toggleLoading: any;
handlePicked: (user) => void;
value?: string;
className?: string;
}
export interface Team {
......@@ -54,7 +56,7 @@ class TeamPicker extends Component<IProps, any> {
render() {
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
const { isLoading, handlePicked } = this.props;
const { isLoading, handlePicked, value, className } = this.props;
return (
<div className="user-picker">
......@@ -66,10 +68,13 @@ class TeamPicker extends Component<IProps, any> {
isLoading={isLoading}
loadOptions={this.debouncedSearch}
loadingPlaceholder="Loading..."
noResultsText="No teams found"
onChange={handlePicked}
className="width-8 gf-form-input gf-form-input--form-dropdown"
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
optionComponent={PickerOption}
placeholder="Choose"
value={value}
autosize={true}
/>
</div>
);
......
......@@ -9,6 +9,8 @@ export interface IProps {
isLoading: boolean;
toggleLoading: any;
handlePicked: (user) => void;
value?: string;
className?: string;
}
export interface User {
......@@ -53,8 +55,7 @@ class UserPicker extends Component<IProps, any> {
render() {
const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
const { isLoading, handlePicked } = this.props;
const { isLoading, handlePicked, value, className } = this.props;
return (
<div className="user-picker">
<AsyncComponent
......@@ -67,9 +68,11 @@ class UserPicker extends Component<IProps, any> {
loadingPlaceholder="Loading..."
noResultsText="No users found"
onChange={handlePicked}
className="width-8 gf-form-input gf-form-input--form-dropdown"
className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
optionComponent={PickerOption}
placeholder="Choose"
value={value}
autosize={true}
/>
</div>
);
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TeamPicker renders correctly 1`] = `
<div
className="user-picker"
>
<div
className="Select gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
style={undefined}
>
<div
className="Select-control"
onKeyDown={[Function]}
onMouseDown={[Function]}
onTouchEnd={[Function]}
onTouchMove={[Function]}
onTouchStart={[Function]}
style={undefined}
>
<span
className="Select-multi-value-wrapper"
id="react-select-2--value"
>
<div
className="Select-placeholder"
>
Loading...
</div>
<div
className="Select-input"
style={
Object {
"display": "inline-block",
}
}
>
<input
aria-activedescendant="react-select-2--value"
aria-describedby={undefined}
aria-expanded="false"
aria-haspopup="false"
aria-label={undefined}
aria-labelledby={undefined}
aria-owns=""
className={undefined}
id={undefined}
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
required={false}
role="combobox"
style={
Object {
"boxSizing": "content-box",
"width": "5px",
}
}
tabIndex={undefined}
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div>
</span>
<span
aria-hidden="true"
className="Select-loading-zone"
>
<span
className="Select-loading"
/>
</span>
<span
className="Select-arrow-zone"
onMouseDown={[Function]}
>
<span
className="Select-arrow"
onMouseDown={[Function]}
/>
</span>
</div>
</div>
</div>
`;
......@@ -5,7 +5,7 @@ exports[`UserPicker renders correctly 1`] = `
className="user-picker"
>
<div
className="Select width-8 gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
className="Select gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
style={undefined}
>
<div
......
......@@ -3,6 +3,8 @@
export interface IProps {
backendSrv: any;
handlePicked: (data) => void;
value?: string;
className?: string;
}
export default function withPicker(WrappedComponent) {
......
......@@ -7,25 +7,29 @@ export function geminiScrollbar() {
restrict: 'A',
link: function(scope, elem, attrs) {
let scrollbar = new PerfectScrollbar(elem[0]);
let lastPos = 0;
appEvents.on(
'smooth-scroll-top',
() => {
elem.animate(
{
scrollTop: 0,
},
500
);
'dash-scroll',
evt => {
if (evt.restore) {
elem[0].scrollTop = lastPos;
return;
}
lastPos = elem[0].scrollTop;
if (evt.animate) {
elem.animate({ scrollTop: evt.pos }, 500);
} else {
elem[0].scrollTop = evt.pos;
}
},
scope
);
scope.$on('$routeChangeSuccess', () => {
elem[0].scrollTop = 0;
});
scope.$on('$routeUpdate', () => {
lastPos = 0;
elem[0].scrollTop = 0;
});
......
......@@ -281,6 +281,40 @@ export class DashboardModel {
this.events.emit('repeats-processed');
}
cleanUpRowRepeats(rowPanels) {
let panelsToRemove = [];
for (let i = 0; i < rowPanels.length; i++) {
let panel = rowPanels[i];
if (!panel.repeat && panel.repeatPanelId) {
panelsToRemove.push(panel);
}
}
_.pull(rowPanels, ...panelsToRemove);
_.pull(this.panels, ...panelsToRemove);
}
processRowRepeats(row: PanelModel) {
if (this.snapshot || this.templating.list.length === 0) {
return;
}
let rowPanels = row.panels;
if (!row.collapsed) {
let rowPanelIndex = _.findIndex(this.panels, p => p.id === row.id);
rowPanels = this.getRowPanels(rowPanelIndex);
}
this.cleanUpRowRepeats(rowPanels);
for (let i = 0; i < rowPanels.length; i++) {
let panel = rowPanels[i];
if (panel.repeat) {
let panelIndex = _.findIndex(this.panels, p => p.id === panel.id);
this.repeatPanel(panel, panelIndex);
}
}
}
getPanelRepeatClone(sourcePanel, valueIndex, sourcePanelIndex) {
// if first clone return source
if (valueIndex === 0) {
......@@ -571,7 +605,7 @@ export class DashboardModel {
if (row.collapsed) {
row.collapsed = false;
let hasRepeat = false;
let hasRepeat = _.some(row.panels, p => p.repeat);
if (row.panels.length > 0) {
// Use first panel to figure out if it was moved or pushed
......@@ -592,10 +626,6 @@ export class DashboardModel {
// update insert post and y max
insertPos += 1;
yMax = Math.max(yMax, panel.gridPos.y + panel.gridPos.h);
if (panel.repeat) {
hasRepeat = true;
}
}
const pushDownAmount = yMax - row.gridPos.y;
......@@ -608,7 +638,7 @@ export class DashboardModel {
row.panels = [];
if (hasRepeat) {
this.processRepeats();
this.processRowRepeats(row);
}
}
......
......@@ -72,7 +72,8 @@ export class DashNavCtrl {
}
addPanel() {
appEvents.emit('smooth-scroll-top');
appEvents.emit('dash-scroll', { animate: true, evt: 0 });
if (this.dashboard.panels.length > 0 && this.dashboard.panels[0].type === 'add-panel') {
return; // Return if the "Add panel" exists already
}
......
......@@ -24,6 +24,9 @@ export class SettingsCtrl {
this.$scope.$on('$destroy', () => {
this.dashboard.updateSubmenuVisibility();
this.$rootScope.$broadcast('refresh');
setTimeout(() => {
this.$rootScope.appEvent('dash-scroll', { restore: true });
});
});
this.canSaveAs = contextSrv.isEditor;
......@@ -33,7 +36,8 @@ export class SettingsCtrl {
this.buildSectionList();
this.onRouteUpdated();
$rootScope.onAppEvent('$routeUpdate', this.onRouteUpdated.bind(this), $scope);
this.$rootScope.onAppEvent('$routeUpdate', this.onRouteUpdated.bind(this), $scope);
this.$rootScope.appEvent('dash-scroll', { animate: false, pos: 0 });
}
buildSectionList() {
......
......@@ -629,4 +629,23 @@ describe('given dashboard with row and panel repeat', () => {
region: { text: 'reg2', value: 'reg2' },
});
});
it('should repeat panels when row is expanding', function() {
dashboard = new DashboardModel(dashboardJSON);
dashboard.processRepeats();
expect(dashboard.panels.length).toBe(6);
// toggle row
dashboard.toggleRow(dashboard.panels[0]);
dashboard.toggleRow(dashboard.panels[1]);
expect(dashboard.panels.length).toBe(2);
// change variable
dashboard.templating.list[1].current.value = ['se1', 'se2', 'se3'];
// toggle row back
dashboard.toggleRow(dashboard.panels[1]);
expect(dashboard.panels.length).toBe(4);
});
});
......@@ -150,6 +150,7 @@ export class DashboardViewState {
this.dashboard.setViewMode(ctrl.panel, false, false);
this.$scope.appEvent('panel-fullscreen-exit', { panelId: ctrl.panel.id });
this.$scope.appEvent('dash-scroll', { restore: true });
if (!render) {
return false;
......@@ -177,6 +178,7 @@ export class DashboardViewState {
this.dashboard.setViewMode(ctrl.panel, true, ctrl.editMode);
this.$scope.appEvent('panel-fullscreen-enter', { panelId: ctrl.panel.id });
this.$scope.appEvent('dash-scroll', { animate: false, pos: 0 });
}
registerPanel(panelScope) {
......
import angular from 'angular';
import locationUtil from 'app/core/utils/location_util';
import appEvents from 'app/core/app_events';
export class SoloPanelCtrl {
/** @ngInject */
......@@ -8,6 +9,7 @@ export class SoloPanelCtrl {
$scope.init = function() {
contextSrv.sidemenu = false;
appEvents.emit('toggle-sidemenu');
var params = $location.search();
panelId = parseInt(params.panelId);
......
import { PermissionsStore } from './PermissionsStore';
import { PermissionsStore, aclTypeValues } from './PermissionsStore';
import { backendSrv } from 'test/mocks/common';
describe('PermissionsStore', () => {
......@@ -47,21 +47,6 @@ describe('PermissionsStore', () => {
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
});
it('should save newly added permissions automatically', () => {
expect(store.items.length).toBe(3);
const newItem = {
userId: 10,
userLogin: 'tester1',
permission: 1,
};
store.addStoreItem(newItem);
expect(store.items.length).toBe(4);
expect(backendSrv.post.mock.calls.length).toBe(1);
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
});
it('should save removed permissions automatically', () => {
expect(store.items.length).toBe(3);
......@@ -72,35 +57,22 @@ describe('PermissionsStore', () => {
expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
});
describe('when duplicate user permissions are added', () => {
beforeEach(() => {
const newItem = {
userId: 10,
userLogin: 'tester1',
permission: 1,
dashboardId: 1,
};
store.addStoreItem(newItem);
store.addStoreItem(newItem);
});
it('should return a validation error', () => {
expect(store.items.length).toBe(4);
expect(store.error).toBe('This permission exists already.');
expect(backendSrv.post.mock.calls.length).toBe(1);
});
});
describe('when duplicate team permissions are added', () => {
beforeEach(() => {
const newItem = {
teamId: 1,
teamName: 'testerteam',
teamId: 10,
team: 'tester-team',
permission: 1,
dashboardId: 1,
};
store.addStoreItem(newItem);
store.addStoreItem(newItem);
store.resetNewType();
store.newItem.setTeam(newItem.teamId, newItem.team);
store.newItem.setPermission(newItem.permission);
store.addStoreItem();
store.newItem.setTeam(newItem.teamId, newItem.team);
store.newItem.setPermission(newItem.permission);
store.addStoreItem();
});
it('should return a validation error', () => {
......@@ -110,16 +82,23 @@ describe('PermissionsStore', () => {
});
});
describe('when duplicate role permissions are added', () => {
describe('when duplicate user permissions are added', () => {
beforeEach(() => {
expect(store.items.length).toBe(3);
const newItem = {
team: 'MyTestTeam',
teamId: 1,
userId: 10,
userLogin: 'tester1',
permission: 1,
dashboardId: 1,
};
store.addStoreItem(newItem);
store.addStoreItem(newItem);
store.setNewType(aclTypeValues.USER.value);
store.newItem.setUser(newItem.userId, newItem.userLogin);
store.newItem.setPermission(newItem.permission);
store.addStoreItem();
store.setNewType(aclTypeValues.USER.value);
store.newItem.setUser(newItem.userId, newItem.userLogin);
store.newItem.setPermission(newItem.permission);
store.addStoreItem();
});
it('should return a validation error', () => {
......@@ -131,20 +110,24 @@ describe('PermissionsStore', () => {
describe('when one inherited and one not inherited team permission are added', () => {
beforeEach(() => {
const teamItem = {
const overridingItemForChildDashboard = {
team: 'MyTestTeam',
dashboardId: 1,
teamId: 1,
permission: 2,
};
store.addStoreItem(teamItem);
store.resetNewType();
store.newItem.setTeam(overridingItemForChildDashboard.teamId, overridingItemForChildDashboard.team);
store.newItem.setPermission(overridingItemForChildDashboard.permission);
store.addStoreItem();
});
it('should not throw a validation error', () => {
it('should allowing overriding the inherited permission and not throw a validation error', () => {
expect(store.error).toBe(null);
});
it('should add both permissions', () => {
it('should add new overriding permission', () => {
expect(store.items.length).toBe(4);
});
});
......
import { types, getEnv, flow } from 'mobx-state-tree';
import { types, getEnv, flow } from 'mobx-state-tree';
import { PermissionsStoreItem } from './PermissionsStoreItem';
const duplicateError = 'This permission exists already.';
......@@ -13,15 +13,62 @@ export const permissionOptions = [
},
];
export const aclTypes = [
{ value: 'Group', text: 'Team' },
{ value: 'User', text: 'User' },
{ value: 'Viewer', text: 'Everyone With Viewer Role' },
{ value: 'Editor', text: 'Everyone With Editor Role' },
];
export const aclTypeValues = {
GROUP: { value: 'Group', text: 'Team' },
USER: { value: 'User', text: 'User' },
VIEWER: { value: 'Viewer', text: 'Everyone With Viewer Role' },
EDITOR: { value: 'Editor', text: 'Everyone With Editor Role' },
};
export const aclTypes = Object.keys(aclTypeValues).map(item => aclTypeValues[item]);
const defaultNewType = aclTypes[0].value;
export const NewPermissionsItem = types
.model('NewPermissionsItem', {
type: types.optional(
types.enumeration(Object.keys(aclTypeValues).map(item => aclTypeValues[item].value)),
defaultNewType
),
userId: types.maybe(types.number),
userLogin: types.maybe(types.string),
teamId: types.maybe(types.number),
team: types.maybe(types.string),
permission: types.optional(types.number, 1),
})
.views(self => ({
isValid: () => {
switch (self.type) {
case aclTypeValues.GROUP.value:
return self.teamId && self.team;
case aclTypeValues.USER.value:
return !!self.userId && !!self.userLogin;
case aclTypeValues.VIEWER.value:
case aclTypeValues.EDITOR.value:
return true;
default:
return false;
}
},
}))
.actions(self => ({
setUser(userId: number, userLogin: string) {
self.userId = userId;
self.userLogin = userLogin;
self.teamId = null;
self.team = null;
},
setTeam(teamId: number, team: string) {
self.userId = null;
self.userLogin = null;
self.teamId = teamId;
self.team = team;
},
setPermission(permission: number) {
self.permission = permission;
},
}));
export const PermissionsStore = types
.model('PermissionsStore', {
fetching: types.boolean,
......@@ -31,6 +78,8 @@ export const PermissionsStore = types
error: types.maybe(types.string),
originalItems: types.optional(types.array(PermissionsStoreItem), []),
newType: types.optional(types.string, defaultNewType),
newItem: types.maybe(NewPermissionsItem),
isAddPermissionsVisible: types.optional(types.boolean, false),
isInRoot: types.maybe(types.boolean),
})
.views(self => ({
......@@ -38,7 +87,6 @@ export const PermissionsStore = types
const dupe = self.items.find(it => {
return isDuplicate(it, item);
});
if (dupe) {
self.error = duplicateError;
return false;
......@@ -47,50 +95,94 @@ export const PermissionsStore = types
return true;
},
}))
.actions(self => ({
load: flow(function* load(dashboardId: number, isFolder: boolean, isInRoot: boolean) {
const backendSrv = getEnv(self).backendSrv;
self.fetching = true;
self.isFolder = isFolder;
self.isInRoot = isInRoot;
self.dashboardId = dashboardId;
const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`);
const items = prepareServerResponse(res, dashboardId, isFolder, isInRoot);
self.items = items;
self.originalItems = items;
self.fetching = false;
self.error = null;
}),
addStoreItem: flow(function* addStoreItem(item) {
.actions(self => {
const resetNewType = () => {
self.error = null;
if (!self.isValid(item)) {
return undefined;
}
self.newItem = NewPermissionsItem.create();
};
self.items.push(prepareItem(item, self.dashboardId, self.isFolder, self.isInRoot));
return updateItems(self);
}),
removeStoreItem: flow(function* removeStoreItem(idx: number) {
self.error = null;
self.items.splice(idx, 1);
return updateItems(self);
}),
updatePermissionOnIndex: flow(function* updatePermissionOnIndex(
idx: number,
permission: number,
permissionName: string
) {
self.error = null;
self.items[idx].updatePermission(permission, permissionName);
return updateItems(self);
}),
setNewType(newType: string) {
self.newType = newType;
},
resetNewType() {
self.newType = defaultNewType;
},
}));
return {
load: flow(function* load(dashboardId: number, isFolder: boolean, isInRoot: boolean) {
const backendSrv = getEnv(self).backendSrv;
self.fetching = true;
self.isFolder = isFolder;
self.isInRoot = isInRoot;
self.dashboardId = dashboardId;
const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`);
const items = prepareServerResponse(res, dashboardId, isFolder, isInRoot);
self.items = items;
self.originalItems = items;
self.fetching = false;
self.error = null;
}),
addStoreItem: flow(function* addStoreItem() {
self.error = null;
let item = {
type: self.newItem.type,
permission: self.newItem.permission,
dashboardId: self.dashboardId,
team: undefined,
teamId: undefined,
userLogin: undefined,
userId: undefined,
role: undefined,
};
switch (self.newItem.type) {
case aclTypeValues.GROUP.value:
item.team = self.newItem.team;
item.teamId = self.newItem.teamId;
break;
case aclTypeValues.USER.value:
item.userLogin = self.newItem.userLogin;
item.userId = self.newItem.userId;
break;
case aclTypeValues.VIEWER.value:
case aclTypeValues.EDITOR.value:
item.role = self.newItem.type;
break;
default:
throw Error('Unknown type: ' + self.newItem.type);
}
if (!self.isValid(item)) {
return undefined;
}
self.items.push(prepareItem(item, self.dashboardId, self.isFolder, self.isInRoot));
resetNewType();
return updateItems(self);
}),
removeStoreItem: flow(function* removeStoreItem(idx: number) {
self.error = null;
self.items.splice(idx, 1);
return updateItems(self);
}),
updatePermissionOnIndex: flow(function* updatePermissionOnIndex(
idx: number,
permission: number,
permissionName: string
) {
self.error = null;
self.items[idx].updatePermission(permission, permissionName);
return updateItems(self);
}),
setNewType(newType: string) {
self.newItem = NewPermissionsItem.create({ type: newType });
},
resetNewType() {
resetNewType();
},
toggleAddPermissions() {
self.isAddPermissionsVisible = !self.isAddPermissionsVisible;
},
showAddPermissions() {
self.isAddPermissionsVisible = true;
},
hideAddPermissions() {
self.isAddPermissionsVisible = false;
},
};
});
const updateItems = self => {
self.error = null;
......@@ -116,7 +208,7 @@ const updateItems = self => {
items: updated,
});
} catch (error) {
console.error(error);
self.error = error;
}
return res;
......
......@@ -18,8 +18,8 @@
// ---------------------
@include media-breakpoint-down(xs) {
input[type="text"],
input[type="number"],
input[type='text'],
input[type='number'],
textarea {
font-size: 16px;
}
......@@ -51,9 +51,15 @@
display: flex;
}
.navbar-page-btn {
max-width: none;
max-width: 450px;
}
.gf-timepicker-nav-btn {
max-width: none;
}
}
@include media-breakpoint-up(xl) {
.navbar-page-btn {
max-width: 600px;
}
}
......@@ -113,6 +113,10 @@
//border: 1px solid $tight-form-func-highlight-bg;
}
.btn-transparent {
background-color: transparent;
}
.btn-outline-primary {
@include button-outline-variant($btn-primary-bg);
}
......
......@@ -23,6 +23,7 @@
min-width: 0;
height: 100%;
padding: 30px;
max-width: 1100px;
}
.dashboard-settings__aside {
......
......@@ -274,6 +274,10 @@ $input-border: 1px solid $input-border-color;
}
}
.gf-form-input {
margin-right: 0;
}
select.gf-form-input {
text-indent: 0.01px;
text-overflow: '';
......@@ -392,3 +396,17 @@ select.gf-form-input ~ .gf-form-help-icon {
top: 10px;
color: $text-muted;
}
.cta-form {
position: relative;
padding: 1rem;
background-color: $empty-list-cta-bg;
margin-bottom: 1rem;
border-top: 3px solid $green;
}
.cta-form__close {
position: absolute;
right: 0;
top: 0;
}
......@@ -1604,6 +1604,10 @@ center-align@^0.1.1:
align-text "^0.1.3"
lazy-cache "^1.0.3"
chain-function@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.0.tgz#0d4ab37e7e18ead0bdc47b920764118ce58733dc"
chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3, chalk@~1.1.0, chalk@~1.1.1:
version "1.1.3"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
......@@ -2801,6 +2805,10 @@ dom-converter@~0.1:
dependencies:
utila "~0.3"
dom-helpers@^3.2.0:
version "3.3.1"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.3.1.tgz#fc1a4e15ffdf60ddde03a480a9c0fece821dd4a6"
dom-serialize@^2.2.0:
version "2.2.1"
resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b"
......@@ -8318,6 +8326,17 @@ react-test-renderer@^16.0.0, react-test-renderer@^16.0.0-0:
object-assign "^4.1.1"
prop-types "^15.6.0"
react-transition-group@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.2.1.tgz#e9fb677b79e6455fd391b03823afe84849df4a10"
dependencies:
chain-function "^1.0.0"
classnames "^2.2.5"
dom-helpers "^3.2.0"
loose-envify "^1.3.1"
prop-types "^15.5.8"
warning "^3.0.0"
react@^16.2.0:
version "16.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.2.0.tgz#a31bd2dab89bff65d42134fa187f24d054c273ba"
......@@ -10355,6 +10374,12 @@ walker@~1.0.5:
dependencies:
makeerror "1.0.x"
warning@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c"
dependencies:
loose-envify "^1.0.0"
watch@~0.18.0:
version "0.18.0"
resolved "https://registry.yarnpkg.com/watch/-/watch-0.18.0.tgz#28095476c6df7c90c963138990c0a5423eb4b986"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment