From 069012639af8ed873a776772af22ddf883ae1722 Mon Sep 17 00:00:00 2001 From: Jonathan McCall Date: Fri, 20 Apr 2018 12:17:17 -0400 Subject: [PATCH 1/7] Sort results from GetDashboardTags --- pkg/services/sqlstore/dashboard.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index c0848f08863..4999e40d15e 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -294,7 +294,8 @@ func GetDashboardTags(query *m.GetDashboardTagsQuery) error { FROM dashboard INNER JOIN dashboard_tag on dashboard_tag.dashboard_id = dashboard.id WHERE dashboard.org_id=? - GROUP BY term` + GROUP BY term + ORDER BY term` query.Result = make([]*m.DashboardTagCloudItem, 0) sess := x.Sql(sql, query.OrgId) From 55e42b5fffa7766a1d3aa7586ccf85d6174e529b Mon Sep 17 00:00:00 2001 From: Mike Sollanych Date: Mon, 10 Sep 2018 13:30:29 -0700 Subject: [PATCH 2/7] Adding Centrify configuration for Oauth Just some simple directions for configuring Centrify to enable oauth login! --- docs/sources/auth/generic-oauth.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/sources/auth/generic-oauth.md b/docs/sources/auth/generic-oauth.md index 802424f180b..0f8c2bd6856 100644 --- a/docs/sources/auth/generic-oauth.md +++ b/docs/sources/auth/generic-oauth.md @@ -174,6 +174,36 @@ allowed_organizations = allowed_organizations = ``` +## Set up OAuth2 with Centrify + +1. Create a new Custom OpenID Connect application configuration in the Centrify dashboard. + +2. Create a memorable unique Application ID, e.g. "grafana", "grafana_aws", etc. + +3. Put in other basic configuration (name, description, logo, category) + +4. On the Trust tab, generate a long password and put it into the OpenID Connect Client Secret field. + +5. Put the URL to the front page of your Grafana instance into the "Resource Application URL" field. + +6. Add an authorized Redirect URI like https://your-grafana-server/login/generic_oauth + +7. Set up permissions, policies, etc. just like any other Centrify app + +8. Configure Grafana as follows: + + ```bash + [auth.generic_oauth] + name = Centrify + enabled = true + allow_sign_up = true + client_id = + client_secret = .my.centrify.com/OAuth2/Authorize/ + token_url = https://.my.centrify.com/OAuth2/Token/ + ``` +
From b8a881646a3f8180b9336743e2c937d973caeb2d Mon Sep 17 00:00:00 2001 From: bergquist Date: Tue, 11 Sep 2018 09:25:08 +0200 Subject: [PATCH 3/7] changelog: note about closing #11681 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f14e187b48f..b3a9ffbec9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Minor * **OAuth**: Allow oauth email attribute name to be configurable [#12986](https://github.com/grafana/grafana/issues/12986), thx [@bobmshannon](https://github.com/bobmshannon) +* **Tags**: Default sort order for GetDashboardTags [#11681](https://github.com/grafana/grafana/pull/11681), thx [@Jonnymcc](https://github.com/Jonnymcc) # 5.3.0 (unreleased) From 2936e34d750ca8522c4dc3a4614fc015f1a111bd Mon Sep 17 00:00:00 2001 From: bergquist Date: Tue, 11 Sep 2018 10:53:21 +0200 Subject: [PATCH 4/7] removes protoc from makefile --- Makefile | 3 --- 1 file changed, 3 deletions(-) diff --git a/Makefile b/Makefile index c6915409ed7..c9e51d897f3 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,3 @@ test: test-go test-js run: ./bin/grafana-server - -protoc: - protoc -I pkg/tsdb/models pkg/tsdb/models/*.proto --go_out=plugins=grpc:pkg/tsdb/models/. From 28250271ccafccd5547af0f418729a911ce846c5 Mon Sep 17 00:00:00 2001 From: Mitsuhiro Tanda Date: Tue, 11 Sep 2018 19:53:39 +0900 Subject: [PATCH 5/7] fix nil pointer dereference (#13221) --- pkg/tsdb/cloudwatch/metric_find_query.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index ef1b53eaf1b..e1e131d9f3a 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -466,6 +466,9 @@ func (e *CloudWatchExecutor) handleGetEc2InstanceAttribute(ctx context.Context, return nil, errors.New("invalid attribute path") } v = v.FieldByName(key) + if !v.IsValid() { + return nil, errors.New("invalid attribute path") + } } if attr, ok := v.Interface().(*string); ok { data = *attr From 1ce900114109f89a0ea1d5cfdee2f3670f64dc5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 11 Sep 2018 13:48:13 +0200 Subject: [PATCH 6/7] upgrade of typescript and tslint and jest (#13223) --- jest.config.js | 7 +- package.json | 11 +- .../__snapshots__/TeamPicker.test.tsx.snap | 8 - .../__snapshots__/UserPicker.test.tsx.snap | 8 - .../__snapshots__/SignIn.test.tsx.snap | 4 +- .../__snapshots__/ServerStats.test.tsx.snap | 1 - yarn.lock | 730 ++++++++---------- 7 files changed, 342 insertions(+), 427 deletions(-) diff --git a/jest.config.js b/jest.config.js index a5cd3416f75..cac634fbf10 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,13 +1,8 @@ module.exports = { verbose: false, - "globals": { - "ts-jest": { - "tsConfigFile": "tsconfig.json" - } - }, "transform": { - "^.+\\.tsx?$": "/node_modules/ts-jest/preprocessor.js" + "^.+\\.(ts|tsx)$": "ts-jest" }, "moduleDirectories": ["node_modules", "public"], "roots": [ diff --git a/package.json b/package.json index 7afe10c0772..29196ffdf01 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "expect.js": "~0.2.0", "expose-loader": "^0.7.3", "file-loader": "^1.1.11", - "fork-ts-checker-webpack-plugin": "^0.4.2", + "fork-ts-checker-webpack-plugin": "^0.4.9", "gaze": "^1.1.2", "glob": "~7.0.0", "grunt": "1.0.1", @@ -56,7 +56,7 @@ "html-webpack-harddisk-plugin": "^0.2.0", "html-webpack-plugin": "^3.2.0", "husky": "^0.14.3", - "jest": "^22.0.4", + "jest": "^23.6.0", "lint-staged": "^6.0.0", "load-grunt-tasks": "3.5.2", "mini-css-extract-plugin": "^0.4.0", @@ -80,12 +80,12 @@ "style-loader": "^0.21.0", "systemjs": "0.20.19", "systemjs-plugin-css": "^0.1.36", - "ts-jest": "^22.4.6", - "ts-loader": "^4.3.0", + "ts-jest": "^23.1.4", + "ts-loader": "^5.1.0", "tslib": "^1.9.3", "tslint": "^5.8.0", "tslint-loader": "^3.5.3", - "typescript": "^2.6.2", + "typescript": "^3.0.3", "uglifyjs-webpack-plugin": "^1.2.7", "webpack": "^4.8.0", "webpack-bundle-analyzer": "^2.9.0", @@ -133,6 +133,7 @@ "angular-native-dragdrop": "1.2.2", "angular-route": "1.6.6", "angular-sanitize": "1.6.6", + "babel-jest": "^23.6.0", "babel-polyfill": "^6.26.0", "baron": "^3.0.3", "brace": "^0.10.0", diff --git a/public/app/core/components/Picker/__snapshots__/TeamPicker.test.tsx.snap b/public/app/core/components/Picker/__snapshots__/TeamPicker.test.tsx.snap index 67232d0ea5b..c63cc880900 100644 --- a/public/app/core/components/Picker/__snapshots__/TeamPicker.test.tsx.snap +++ b/public/app/core/components/Picker/__snapshots__/TeamPicker.test.tsx.snap @@ -6,7 +6,6 @@ exports[`TeamPicker renders correctly 1`] = ` >
    =0.10.3 <1" +sisteransi@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-0.1.1.tgz#5431447d5f7d1675aac667ccd0b865a4994cb3ce" + slash@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" @@ -10834,9 +10812,9 @@ source-map-support@^0.4.15: dependencies: source-map "^0.5.6" -source-map-support@^0.5.0, source-map-support@^0.5.5: - version "0.5.6" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.6.tgz#4435cee46b1aab62b8e8610ce60f788091c51c13" +source-map-support@^0.5.6: + version "0.5.9" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.9.tgz#41bc953b2534267ea2d605bccfa7bfa3111ced5f" dependencies: buffer-from "^1.0.0" source-map "^0.6.0" @@ -11163,12 +11141,6 @@ style-loader@^0.21.0: loader-utils "^1.1.0" schema-utils "^0.4.5" -subarg@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/subarg/-/subarg-1.0.0.tgz#f62cf17581e996b48fc965699f54c06ae268b8d2" - dependencies: - minimist "^1.1.0" - supports-color@4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.4.0.tgz#883f7ddabc165142b2a61427f3352ded195d1a3e" @@ -11492,25 +11464,18 @@ tryor@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/tryor/-/tryor-0.1.2.tgz#8145e4ca7caff40acde3ccf946e8b8bb75b4172b" -ts-jest@^22.4.6: - version "22.4.6" - resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-22.4.6.tgz#a5d7f5e8b809626d1f4143209d301287472ec344" +ts-jest@^23.1.4: + version "23.1.4" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-23.1.4.tgz#66ac1d8d3fbf8f9a98432b11aa377aa850664b2b" dependencies: - babel-core "^6.26.3" - babel-plugin-istanbul "^4.1.6" - babel-plugin-transform-es2015-modules-commonjs "^6.26.2" - babel-preset-jest "^22.4.3" - cpx "^1.5.0" - fs-extra "6.0.0" - jest-config "^22.4.3" + closest-file-data "^0.1.4" + fs-extra "6.0.1" + json5 "^0.5.0" lodash "^4.17.10" - pkg-dir "^2.0.0" - source-map-support "^0.5.5" - yargs "^11.0.0" -ts-loader@^4.3.0: - version "4.3.1" - resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-4.3.1.tgz#345298df9a5019be7a3e86cd7b8b1aefef4bbd79" +ts-loader@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-5.1.0.tgz#ac13facb9360af4a4b072c851a120d17cbcaf1fa" dependencies: chalk "^2.3.0" enhanced-resolve "^4.0.0" @@ -11614,9 +11579,9 @@ typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" -typescript@^2.6.2: - version "2.9.1" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.9.1.tgz#fdb19d2c67a15d11995fd15640e373e09ab09961" +typescript@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.0.3.tgz#4853b3e275ecdaa27f78fda46dc273a7eb7fc1c8" ua-parser-js@^0.7.9: version "0.7.18" @@ -11784,7 +11749,7 @@ unzip-response@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97" -upath@^1.0.0: +upath@^1.0.0, upath@^1.0.5: version "1.1.0" resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.0.tgz#35256597e46a581db4793d0ce47fa9aebfc9fabd" @@ -11981,12 +11946,6 @@ vm-browserify@0.0.4: dependencies: indexof "0.0.1" -vue-parser@^1.1.5: - version "1.1.6" - resolved "https://registry.yarnpkg.com/vue-parser/-/vue-parser-1.1.6.tgz#3063c8431795664ebe429c23b5506899706e6355" - dependencies: - parse5 "^3.0.3" - w3c-blob@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/w3c-blob/-/w3c-blob-0.0.1.tgz#b0cd352a1a50f515563420ffd5861f950f1d85b8" @@ -12387,12 +12346,6 @@ yargs-parser@^5.0.0: dependencies: camelcase "^3.0.0" -yargs-parser@^8.1.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-8.1.0.tgz#f1376a33b6629a5d063782944da732631e966950" - dependencies: - camelcase "^4.1.0" - yargs-parser@^9.0.2: version "9.0.2" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-9.0.2.tgz#9ccf6a43460fe4ed40a9bb68f48d43b8a68cc077" @@ -12416,23 +12369,6 @@ yargs@11.0.0, yargs@^11.0.0: y18n "^3.2.1" yargs-parser "^9.0.2" -yargs@^10.0.3: - version "10.1.2" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-10.1.2.tgz#454d074c2b16a51a43e2fb7807e4f9de69ccb5c5" - dependencies: - cliui "^4.0.0" - decamelize "^1.1.1" - find-up "^2.1.0" - get-caller-file "^1.0.1" - os-locale "^2.0.0" - require-directory "^2.1.1" - require-main-filename "^1.0.1" - set-blocking "^2.0.0" - string-width "^2.0.0" - which-module "^2.0.0" - y18n "^3.2.1" - yargs-parser "^8.1.0" - yargs@^11.1.0: version "11.1.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-11.1.0.tgz#90b869934ed6e871115ea2ff58b03f4724ed2d77" From 9f73f13091d5ebaf4ba4e7ad40d7bdb9556e88d1 Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Tue, 11 Sep 2018 14:14:03 +0200 Subject: [PATCH 7/7] Teams page replace mobx (#13219) * creating types, actions, reducer * load teams and store in redux * delete team * set search query action and tests * Teampages page * team members, bug in fetching team * flattened team state, tests for TeamMembers * test for team member selector * team settings * actions for group sync * tests for team groups * removed comment * remove old stores * fix: formating of datasource.go * fix: minor changes to imports * adding debounce and fixing issue in teamlist * refactoring: moving types to their own files --- public/app/containers/Teams/TeamPages.tsx | 77 ---- public/app/containers/Teams/TeamSettings.tsx | 69 ---- public/app/core/actions/index.ts | 3 +- public/app/core/actions/navModel.ts | 22 +- .../CustomScrollbar/CustomScrollbar.tsx | 1 - public/app/core/reducers/navModel.ts | 18 +- public/app/core/selectors/location.ts | 3 + public/app/features/alerting/state/actions.ts | 8 +- .../features/alerting/state/reducers.test.ts | 4 +- .../app/features/alerting/state/reducers.ts | 4 +- .../app/features/teams/TeamGroupSync.test.tsx | 63 ++++ .../teams}/TeamGroupSync.tsx | 95 +++-- public/app/features/teams/TeamList.test.tsx | 75 ++++ .../Teams => features/teams}/TeamList.tsx | 126 ++++--- .../app/features/teams/TeamMembers.test.tsx | 79 ++++ .../Teams => features/teams}/TeamMembers.tsx | 91 +++-- public/app/features/teams/TeamPages.test.tsx | 63 ++++ public/app/features/teams/TeamPages.tsx | 105 ++++++ .../app/features/teams/TeamSettings.test.tsx | 44 +++ public/app/features/teams/TeamSettings.tsx | 96 +++++ .../features/teams/__mocks__/navModelMock.ts | 59 +++ .../app/features/teams/__mocks__/teamMocks.ts | 65 ++++ .../__snapshots__/TeamGroupSync.test.tsx.snap | 281 ++++++++++++++ .../__snapshots__/TeamList.test.tsx.snap | 354 ++++++++++++++++++ .../__snapshots__/TeamMembers.test.tsx.snap | 317 ++++++++++++++++ .../__snapshots__/TeamPages.test.tsx.snap | 48 +++ .../__snapshots__/TeamSettings.test.tsx.snap | 57 +++ public/app/features/teams/state/actions.ts | 237 ++++++++++++ .../app/features/teams/state/reducers.test.ts | 72 ++++ public/app/features/teams/state/reducers.ts | 44 +++ .../features/teams/state/selectors.test.ts | 56 +++ public/app/features/teams/state/selectors.ts | 30 ++ public/app/routes/routes.ts | 4 +- public/app/stores/NavStore/NavStore.ts | 40 -- public/app/stores/RootStore/RootStore.ts | 4 - public/app/stores/TeamsStore/TeamsStore.ts | 156 -------- public/app/stores/configureStore.ts | 2 + public/app/types/alerting.ts | 35 ++ public/app/types/index.ts | 112 ++---- public/app/types/location.ts | 15 + public/app/types/navModel.ts | 22 ++ public/app/types/teams.ts | 32 ++ 42 files changed, 2493 insertions(+), 595 deletions(-) delete mode 100644 public/app/containers/Teams/TeamPages.tsx delete mode 100644 public/app/containers/Teams/TeamSettings.tsx create mode 100644 public/app/core/selectors/location.ts create mode 100644 public/app/features/teams/TeamGroupSync.test.tsx rename public/app/{containers/Teams => features/teams}/TeamGroupSync.tsx (73%) create mode 100644 public/app/features/teams/TeamList.test.tsx rename public/app/{containers/Teams => features/teams}/TeamList.tsx (63%) create mode 100644 public/app/features/teams/TeamMembers.test.tsx rename public/app/{containers/Teams => features/teams}/TeamMembers.tsx (66%) create mode 100644 public/app/features/teams/TeamPages.test.tsx create mode 100644 public/app/features/teams/TeamPages.tsx create mode 100644 public/app/features/teams/TeamSettings.test.tsx create mode 100644 public/app/features/teams/TeamSettings.tsx create mode 100644 public/app/features/teams/__mocks__/navModelMock.ts create mode 100644 public/app/features/teams/__mocks__/teamMocks.ts create mode 100644 public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap create mode 100644 public/app/features/teams/__snapshots__/TeamList.test.tsx.snap create mode 100644 public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap create mode 100644 public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap create mode 100644 public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap create mode 100644 public/app/features/teams/state/actions.ts create mode 100644 public/app/features/teams/state/reducers.test.ts create mode 100644 public/app/features/teams/state/reducers.ts create mode 100644 public/app/features/teams/state/selectors.test.ts create mode 100644 public/app/features/teams/state/selectors.ts delete mode 100644 public/app/stores/TeamsStore/TeamsStore.ts create mode 100644 public/app/types/alerting.ts create mode 100644 public/app/types/location.ts create mode 100644 public/app/types/navModel.ts create mode 100644 public/app/types/teams.ts diff --git a/public/app/containers/Teams/TeamPages.tsx b/public/app/containers/Teams/TeamPages.tsx deleted file mode 100644 index 2abc9c51535..00000000000 --- a/public/app/containers/Teams/TeamPages.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React from 'react'; -import _ from 'lodash'; -import { hot } from 'react-hot-loader'; -import { inject, observer } from 'mobx-react'; -import config from 'app/core/config'; -import PageHeader from 'app/core/components/PageHeader/PageHeader'; -import { NavStore } from 'app/stores/NavStore/NavStore'; -import { TeamsStore, Team } from 'app/stores/TeamsStore/TeamsStore'; -import { ViewStore } from 'app/stores/ViewStore/ViewStore'; -import TeamMembers from './TeamMembers'; -import TeamSettings from './TeamSettings'; -import TeamGroupSync from './TeamGroupSync'; - -interface Props { - nav: typeof NavStore.Type; - teams: typeof TeamsStore.Type; - view: typeof ViewStore.Type; -} - -@inject('nav', 'teams', 'view') -@observer -export class TeamPages extends React.Component { - isSyncEnabled: boolean; - currentPage: string; - - constructor(props) { - super(props); - - this.isSyncEnabled = config.buildInfo.isEnterprise; - this.currentPage = this.getCurrentPage(); - - this.loadTeam(); - } - - async loadTeam() { - const { teams, nav, view } = this.props; - - await teams.loadById(view.routeParams.get('id')); - - nav.initTeamPage(this.getCurrentTeam(), this.currentPage, this.isSyncEnabled); - } - - getCurrentTeam(): Team { - const { teams, view } = this.props; - return teams.map.get(view.routeParams.get('id')); - } - - getCurrentPage() { - const pages = ['members', 'settings', 'groupsync']; - const currentPage = this.props.view.routeParams.get('page'); - return _.includes(pages, currentPage) ? currentPage : pages[0]; - } - - render() { - const { nav } = this.props; - const currentTeam = this.getCurrentTeam(); - - if (!nav.main) { - return null; - } - - return ( -
    - - {currentTeam && ( -
    - {this.currentPage === 'members' && } - {this.currentPage === 'settings' && } - {this.currentPage === 'groupsync' && this.isSyncEnabled && } -
    - )} -
    - ); - } -} - -export default hot(module)(TeamPages); diff --git a/public/app/containers/Teams/TeamSettings.tsx b/public/app/containers/Teams/TeamSettings.tsx deleted file mode 100644 index 0de60a0b16c..00000000000 --- a/public/app/containers/Teams/TeamSettings.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react'; -import { hot } from 'react-hot-loader'; -import { observer } from 'mobx-react'; -import { Team } from 'app/stores/TeamsStore/TeamsStore'; -import { Label } from 'app/core/components/Forms/Forms'; - -interface Props { - team: Team; -} - -@observer -export class TeamSettings extends React.Component { - constructor(props) { - super(props); - } - - onChangeName = evt => { - this.props.team.setName(evt.target.value); - }; - - onChangeEmail = evt => { - this.props.team.setEmail(evt.target.value); - }; - - onUpdate = evt => { - evt.preventDefault(); - this.props.team.update(); - }; - - render() { - return ( -
    -

    Team Settings

    -
    -
    - - -
    -
    - - -
    - -
    - -
    -
    -
    - ); - } -} - -export default hot(module)(TeamSettings); diff --git a/public/app/core/actions/index.ts b/public/app/core/actions/index.ts index 74b61f845c0..451a13dae99 100644 --- a/public/app/core/actions/index.ts +++ b/public/app/core/actions/index.ts @@ -1,3 +1,4 @@ import { updateLocation } from './location'; +import { updateNavIndex, UpdateNavIndexAction } from './navModel'; -export { updateLocation }; +export { updateLocation, updateNavIndex, UpdateNavIndexAction }; diff --git a/public/app/core/actions/navModel.ts b/public/app/core/actions/navModel.ts index 56d129fd263..a40a0e880ee 100644 --- a/public/app/core/actions/navModel.ts +++ b/public/app/core/actions/navModel.ts @@ -1,13 +1,17 @@ -export type Action = UpdateNavIndexAction; +import { NavModelItem } from '../../types'; -// this action is not used yet -// kind of just a placeholder, will be need for dynamic pages -// like datasource edit, teams edit page - -export interface UpdateNavIndexAction { - type: 'UPDATE_NAV_INDEX'; +export enum ActionTypes { + UpdateNavIndex = 'UPDATE_NAV_INDEX', } -export const updateNavIndex = (): UpdateNavIndexAction => ({ - type: 'UPDATE_NAV_INDEX', +export type Action = UpdateNavIndexAction; + +export interface UpdateNavIndexAction { + type: ActionTypes.UpdateNavIndex; + payload: NavModelItem; +} + +export const updateNavIndex = (item: NavModelItem): UpdateNavIndexAction => ({ + type: ActionTypes.UpdateNavIndex, + payload: item, }); diff --git a/public/app/core/components/CustomScrollbar/CustomScrollbar.tsx b/public/app/core/components/CustomScrollbar/CustomScrollbar.tsx index 8be65249808..9b9a9c4d02a 100644 --- a/public/app/core/components/CustomScrollbar/CustomScrollbar.tsx +++ b/public/app/core/components/CustomScrollbar/CustomScrollbar.tsx @@ -13,7 +13,6 @@ interface Props { * Wraps component into component from `react-custom-scrollbars` */ class CustomScrollbar extends PureComponent { - static defaultProps: Partial = { customClassName: 'custom-scrollbars', autoHide: true, diff --git a/public/app/core/reducers/navModel.ts b/public/app/core/reducers/navModel.ts index 26acdb39a3d..ac0e51854e7 100644 --- a/public/app/core/reducers/navModel.ts +++ b/public/app/core/reducers/navModel.ts @@ -1,5 +1,5 @@ -import { Action } from 'app/core/actions/navModel'; -import { NavModelItem, NavIndex } from 'app/types'; +import { Action, ActionTypes } from 'app/core/actions/navModel'; +import { NavIndex, NavModelItem } from 'app/types'; import config from 'app/core/config'; export function buildInitialState(): NavIndex { @@ -25,5 +25,19 @@ function buildNavIndex(navIndex: NavIndex, children: NavModelItem[], parentItem? export const initialState: NavIndex = buildInitialState(); export const navIndexReducer = (state = initialState, action: Action): NavIndex => { + switch (action.type) { + case ActionTypes.UpdateNavIndex: + const newPages = {}; + const payload = action.payload; + + for (const node of payload.children) { + newPages[node.id] = { + ...node, + parentItem: payload, + }; + } + + return { ...state, ...newPages }; + } return state; }; diff --git a/public/app/core/selectors/location.ts b/public/app/core/selectors/location.ts new file mode 100644 index 00000000000..adc31f47e89 --- /dev/null +++ b/public/app/core/selectors/location.ts @@ -0,0 +1,3 @@ +export const getRouteParamsId = state => state.routeParams.id; + +export const getRouteParamsPage = state => state.routeParams.page; diff --git a/public/app/features/alerting/state/actions.ts b/public/app/features/alerting/state/actions.ts index ca50d9e1038..edd6fbb1da1 100644 --- a/public/app/features/alerting/state/actions.ts +++ b/public/app/features/alerting/state/actions.ts @@ -1,15 +1,15 @@ import { getBackendSrv } from 'app/core/services/backend_srv'; -import { AlertRuleApi, StoreState } from 'app/types'; +import { AlertRuleDTO, StoreState } from 'app/types'; import { ThunkAction } from 'redux-thunk'; export enum ActionTypes { LoadAlertRules = 'LOAD_ALERT_RULES', - SetSearchQuery = 'SET_SEARCH_QUERY', + SetSearchQuery = 'SET_ALERT_SEARCH_QUERY', } export interface LoadAlertRulesAction { type: ActionTypes.LoadAlertRules; - payload: AlertRuleApi[]; + payload: AlertRuleDTO[]; } export interface SetSearchQueryAction { @@ -17,7 +17,7 @@ export interface SetSearchQueryAction { payload: string; } -export const loadAlertRules = (rules: AlertRuleApi[]): LoadAlertRulesAction => ({ +export const loadAlertRules = (rules: AlertRuleDTO[]): LoadAlertRulesAction => ({ type: ActionTypes.LoadAlertRules, payload: rules, }); diff --git a/public/app/features/alerting/state/reducers.test.ts b/public/app/features/alerting/state/reducers.test.ts index 96ca7bacf6c..4f079a090cf 100644 --- a/public/app/features/alerting/state/reducers.test.ts +++ b/public/app/features/alerting/state/reducers.test.ts @@ -1,9 +1,9 @@ import { ActionTypes, Action } from './actions'; import { alertRulesReducer, initialState } from './reducers'; -import { AlertRuleApi } from '../../../types'; +import { AlertRuleDTO } from 'app/types'; describe('Alert rules', () => { - const payload: AlertRuleApi[] = [ + const payload: AlertRuleDTO[] = [ { id: 2, dashboardId: 7, diff --git a/public/app/features/alerting/state/reducers.ts b/public/app/features/alerting/state/reducers.ts index 73feb3cb260..c525885bc9c 100644 --- a/public/app/features/alerting/state/reducers.ts +++ b/public/app/features/alerting/state/reducers.ts @@ -1,5 +1,5 @@ import moment from 'moment'; -import { AlertRuleApi, AlertRule, AlertRulesState } from 'app/types'; +import { AlertRuleDTO, AlertRule, AlertRulesState } from 'app/types'; import { Action, ActionTypes } from './actions'; import alertDef from './alertDef'; @@ -29,7 +29,7 @@ function convertToAlertRule(rule, state): AlertRule { export const alertRulesReducer = (state = initialState, action: Action): AlertRulesState => { switch (action.type) { case ActionTypes.LoadAlertRules: { - const alertRules: AlertRuleApi[] = action.payload; + const alertRules: AlertRuleDTO[] = action.payload; const alertRulesViewModel: AlertRule[] = alertRules.map(rule => { return convertToAlertRule(rule, rule.state); diff --git a/public/app/features/teams/TeamGroupSync.test.tsx b/public/app/features/teams/TeamGroupSync.test.tsx new file mode 100644 index 00000000000..f3deb62c77b --- /dev/null +++ b/public/app/features/teams/TeamGroupSync.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { Props, TeamGroupSync } from './TeamGroupSync'; +import { TeamGroup } from '../../types'; +import { getMockTeamGroups } from './__mocks__/teamMocks'; + +const setup = (propOverrides?: object) => { + const props: Props = { + groups: [] as TeamGroup[], + loadTeamGroups: jest.fn(), + addTeamGroup: jest.fn(), + removeTeamGroup: jest.fn(), + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + const instance = wrapper.instance() as TeamGroupSync; + + return { + wrapper, + instance, + }; +}; + +describe('Render', () => { + it('should render component', () => { + const { wrapper } = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render groups table', () => { + const { wrapper } = setup({ + groups: getMockTeamGroups(3), + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); + +describe('Functions', () => { + it('should call add group', () => { + const { instance } = setup(); + + instance.setState({ newGroupId: 'some/group' }); + const mockEvent = { preventDefault: jest.fn() }; + + instance.onAddGroup(mockEvent); + + expect(instance.props.addTeamGroup).toHaveBeenCalledWith('some/group'); + }); + + it('should call remove group', () => { + const { instance } = setup(); + + const mockGroup: TeamGroup = { teamId: 1, groupId: 'some/group' }; + + instance.onRemoveGroup(mockGroup); + + expect(instance.props.removeTeamGroup).toHaveBeenCalledWith('some/group'); + }); +}); diff --git a/public/app/containers/Teams/TeamGroupSync.tsx b/public/app/features/teams/TeamGroupSync.tsx similarity index 73% rename from public/app/containers/Teams/TeamGroupSync.tsx rename to public/app/features/teams/TeamGroupSync.tsx index a3b2e4aed14..939dfcc8e31 100644 --- a/public/app/containers/Teams/TeamGroupSync.tsx +++ b/public/app/features/teams/TeamGroupSync.tsx @@ -1,12 +1,16 @@ -import React from 'react'; -import { hot } from 'react-hot-loader'; -import { observer } from 'mobx-react'; -import { Team, TeamGroup } from 'app/stores/TeamsStore/TeamsStore'; +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; import SlideDown from 'app/core/components/Animations/SlideDown'; import Tooltip from 'app/core/components/Tooltip/Tooltip'; +import { TeamGroup } from '../../types'; +import { addTeamGroup, loadTeamGroups, removeTeamGroup } from './state/actions'; +import { getTeamGroups } from './state/selectors'; -interface Props { - team: Team; +export interface Props { + groups: TeamGroup[]; + loadTeamGroups: typeof loadTeamGroups; + addTeamGroup: typeof addTeamGroup; + removeTeamGroup: typeof removeTeamGroup; } interface State { @@ -16,15 +20,40 @@ interface State { const headerTooltip = `Sync LDAP or OAuth groups with your Grafana teams.`; -@observer -export class TeamGroupSync extends React.Component { +export class TeamGroupSync extends PureComponent { constructor(props) { super(props); this.state = { isAdding: false, newGroupId: '' }; } componentDidMount() { - this.props.team.loadGroups(); + this.fetchTeamGroups(); + } + + async fetchTeamGroups() { + await this.props.loadTeamGroups(); + } + + onToggleAdding = () => { + this.setState({ isAdding: !this.state.isAdding }); + }; + + onNewGroupIdChanged = event => { + this.setState({ newGroupId: event.target.value }); + }; + + onAddGroup = event => { + event.preventDefault(); + this.props.addTeamGroup(this.state.newGroupId); + this.setState({ isAdding: false, newGroupId: '' }); + }; + + onRemoveGroup = (group: TeamGroup) => { + this.props.removeTeamGroup(group.groupId); + }; + + isNewGroupValid() { + return this.state.newGroupId.length > 1; } renderGroup(group: TeamGroup) { @@ -40,30 +69,9 @@ export class TeamGroupSync extends React.Component { ); } - onToggleAdding = () => { - this.setState({ isAdding: !this.state.isAdding }); - }; - - onNewGroupIdChanged = evt => { - this.setState({ newGroupId: evt.target.value }); - }; - - onAddGroup = () => { - this.props.team.addGroup(this.state.newGroupId); - this.setState({ isAdding: false, newGroupId: '' }); - }; - - onRemoveGroup = (group: TeamGroup) => { - this.props.team.removeGroup(group.groupId); - }; - - isNewGroupValid() { - return this.state.newGroupId.length > 1; - } - render() { const { isAdding, newGroupId } = this.state; - const groups = this.props.team.groups.values(); + const groups = this.props.groups; return (
    @@ -86,7 +94,7 @@ export class TeamGroupSync extends React.Component {
    Add External Group
    -
    +
    {
    -
    -
    +
    @@ -146,4 +149,16 @@ export class TeamGroupSync extends React.Component { } } -export default hot(module)(TeamGroupSync); +function mapStateToProps(state) { + return { + groups: getTeamGroups(state.team), + }; +} + +const mapDispatchToProps = { + loadTeamGroups, + addTeamGroup, + removeTeamGroup, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(TeamGroupSync); diff --git a/public/app/features/teams/TeamList.test.tsx b/public/app/features/teams/TeamList.test.tsx new file mode 100644 index 00000000000..6f84ca920d0 --- /dev/null +++ b/public/app/features/teams/TeamList.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { Props, TeamList } from './TeamList'; +import { NavModel, Team } from '../../types'; +import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks'; + +const setup = (propOverrides?: object) => { + const props: Props = { + navModel: {} as NavModel, + teams: [] as Team[], + loadTeams: jest.fn(), + deleteTeam: jest.fn(), + setSearchQuery: jest.fn(), + searchQuery: '', + teamsCount: 0, + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + const instance = wrapper.instance() as TeamList; + + return { + wrapper, + instance, + }; +}; + +describe('Render', () => { + it('should render component', () => { + const { wrapper } = setup(); + expect(wrapper).toMatchSnapshot(); + }); + + it('should render teams table', () => { + const { wrapper } = setup({ + teams: getMultipleMockTeams(5), + teamsCount: 5, + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); + +describe('Life cycle', () => { + it('should call loadTeams', () => { + const { instance } = setup(); + + instance.componentDidMount(); + + expect(instance.props.loadTeams).toHaveBeenCalled(); + }); +}); + +describe('Functions', () => { + describe('Delete team', () => { + it('should call delete team', () => { + const { instance } = setup(); + instance.deleteTeam(getMockTeam()); + + expect(instance.props.deleteTeam).toHaveBeenCalledWith(1); + }); + }); + + describe('on search query change', () => { + it('should call setSearchQuery', () => { + const { instance } = setup(); + const mockEvent = { target: { value: 'test' } }; + + instance.onSearchQueryChange(mockEvent); + + expect(instance.props.setSearchQuery).toHaveBeenCalledWith('test'); + }); + }); +}); diff --git a/public/app/containers/Teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx similarity index 63% rename from public/app/containers/Teams/TeamList.tsx rename to public/app/features/teams/TeamList.tsx index 2a5743bea96..985d73d9a52 100644 --- a/public/app/containers/Teams/TeamList.tsx +++ b/public/app/features/teams/TeamList.tsx @@ -1,42 +1,42 @@ -import React from 'react'; +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; import { hot } from 'react-hot-loader'; -import { inject, observer } from 'mobx-react'; import PageHeader from 'app/core/components/PageHeader/PageHeader'; -import { NavStore } from 'app/stores/NavStore/NavStore'; -import { TeamsStore, Team } from 'app/stores/TeamsStore/TeamsStore'; -import { BackendSrv } from 'app/core/services/backend_srv'; import DeleteButton from 'app/core/components/DeleteButton/DeleteButton'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; +import { NavModel, Team } from '../../types'; +import { loadTeams, deleteTeam, setSearchQuery } from './state/actions'; +import { getSearchQuery, getTeams, getTeamsCount } from './state/selectors'; +import { getNavModel } from 'app/core/selectors/navModel'; -interface Props { - nav: typeof NavStore.Type; - teams: typeof TeamsStore.Type; - backendSrv: BackendSrv; +export interface Props { + navModel: NavModel; + teams: Team[]; + searchQuery: string; + teamsCount: number; + loadTeams: typeof loadTeams; + deleteTeam: typeof deleteTeam; + setSearchQuery: typeof setSearchQuery; } -@inject('nav', 'teams') -@observer -export class TeamList extends React.Component { - constructor(props) { - super(props); - - this.props.nav.load('cfg', 'teams'); +export class TeamList extends PureComponent { + componentDidMount() { this.fetchTeams(); } - fetchTeams() { - this.props.teams.loadTeams(); + async fetchTeams() { + await this.props.loadTeams(); } - deleteTeam(team: Team) { - this.props.backendSrv.delete('/api/teams/' + team.id).then(this.fetchTeams.bind(this)); - } - - onSearchQueryChange = evt => { - this.props.teams.setSearchQuery(evt.target.value); + deleteTeam = (team: Team) => { + this.props.deleteTeam(team.id); }; - renderTeamMember(team: Team): JSX.Element { + onSearchQueryChange = event => { + this.props.setSearchQuery(event.target.value); + }; + + renderTeam(team: Team) { const teamUrl = `org/teams/edit/${team.id}`; return ( @@ -62,7 +62,28 @@ export class TeamList extends React.Component { ); } - renderTeamList(teams) { + renderEmptyList() { + return ( +
    + +
    + ); + } + + renderTeamList() { + const { teams, searchQuery } = this.props; + return (
    @@ -72,7 +93,7 @@ export class TeamList extends React.Component { type="text" className="gf-form-input" placeholder="Search teams" - value={teams.search} + value={searchQuery} onChange={this.onSearchQueryChange} /> @@ -97,49 +118,38 @@ export class TeamList extends React.Component { - {teams.filteredTeams.map(team => this.renderTeamMember(team))} + {teams.map(team => this.renderTeam(team))}
    ); } - renderEmptyList() { - return ( -
    - -
    - ); - } - render() { - const { nav, teams } = this.props; - let view; - - if (teams.filteredTeams.length > 0) { - view = this.renderTeamList(teams); - } else { - view = this.renderEmptyList(); - } + const { navModel, teamsCount } = this.props; return (
    - - {view} + + {teamsCount > 0 ? this.renderTeamList() : this.renderEmptyList()}
    ); } } -export default hot(module)(TeamList); +function mapStateToProps(state) { + return { + navModel: getNavModel(state.navIndex, 'teams'), + teams: getTeams(state.teams), + searchQuery: getSearchQuery(state.teams), + teamsCount: getTeamsCount(state.teams), + }; +} + +const mapDispatchToProps = { + loadTeams, + deleteTeam, + setSearchQuery, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(TeamList)); diff --git a/public/app/features/teams/TeamMembers.test.tsx b/public/app/features/teams/TeamMembers.test.tsx new file mode 100644 index 00000000000..cae37e184fb --- /dev/null +++ b/public/app/features/teams/TeamMembers.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { TeamMembers, Props } from './TeamMembers'; +import { TeamMember } from '../../types'; +import { getMockTeamMember, getMockTeamMembers } from './__mocks__/teamMocks'; + +const setup = (propOverrides?: object) => { + const props: Props = { + members: [] as TeamMember[], + searchMemberQuery: '', + setSearchMemberQuery: jest.fn(), + loadTeamMembers: jest.fn(), + addTeamMember: jest.fn(), + removeTeamMember: jest.fn(), + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + const instance = wrapper.instance() as TeamMembers; + + return { + wrapper, + instance, + }; +}; + +describe('Render', () => { + it('should render component', () => { + const { wrapper } = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render team members', () => { + const { wrapper } = setup({ + members: getMockTeamMembers(5), + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); + +describe('Functions', () => { + describe('on search member query change', () => { + it('it should call setSearchMemberQuery', () => { + const { instance } = setup(); + const mockEvent = { target: { value: 'member' } }; + + instance.onSearchQueryChange(mockEvent); + + expect(instance.props.setSearchMemberQuery).toHaveBeenCalledWith('member'); + }); + }); + + describe('on remove member', () => { + const { instance } = setup(); + const mockTeamMember = getMockTeamMember(); + + instance.onRemoveMember(mockTeamMember); + + expect(instance.props.removeTeamMember).toHaveBeenCalledWith(1); + }); + + describe('on add user to team', () => { + const { wrapper, instance } = setup(); + + wrapper.state().newTeamMember = { + id: 1, + label: '', + avatarUrl: '', + login: '', + }; + + instance.onAddUserToTeam(); + + expect(instance.props.addTeamMember).toHaveBeenCalledWith(1); + }); +}); diff --git a/public/app/containers/Teams/TeamMembers.tsx b/public/app/features/teams/TeamMembers.tsx similarity index 66% rename from public/app/containers/Teams/TeamMembers.tsx rename to public/app/features/teams/TeamMembers.tsx index b06a547063a..5ad688aabf8 100644 --- a/public/app/containers/Teams/TeamMembers.tsx +++ b/public/app/features/teams/TeamMembers.tsx @@ -1,13 +1,19 @@ -import React from 'react'; -import { hot } from 'react-hot-loader'; -import { observer } from 'mobx-react'; -import { Team, TeamMember } from 'app/stores/TeamsStore/TeamsStore'; +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; import SlideDown from 'app/core/components/Animations/SlideDown'; import { UserPicker, User } from 'app/core/components/Picker/UserPicker'; import DeleteButton from 'app/core/components/DeleteButton/DeleteButton'; +import { TeamMember } from '../../types'; +import { loadTeamMembers, addTeamMember, removeTeamMember, setSearchMemberQuery } from './state/actions'; +import { getSearchMemberQuery, getTeamMembers } from './state/selectors'; -interface Props { - team: Team; +export interface Props { + members: TeamMember[]; + searchMemberQuery: string; + loadTeamMembers: typeof loadTeamMembers; + addTeamMember: typeof addTeamMember; + removeTeamMember: typeof removeTeamMember; + setSearchMemberQuery: typeof setSearchMemberQuery; } interface State { @@ -15,42 +21,22 @@ interface State { newTeamMember?: User; } -@observer -export class TeamMembers extends React.Component { +export class TeamMembers extends PureComponent { constructor(props) { super(props); this.state = { isAdding: false, newTeamMember: null }; } componentDidMount() { - this.props.team.loadMembers(); + this.props.loadTeamMembers(); } - onSearchQueryChange = evt => { - this.props.team.setSearchQuery(evt.target.value); + onSearchQueryChange = event => { + this.props.setSearchMemberQuery(event.target.value); }; - removeMember(member: TeamMember) { - this.props.team.removeMember(member); - } - - removeMemberConfirmed(member: TeamMember) { - this.props.team.removeMember(member); - } - - renderMember(member: TeamMember) { - return ( - - - - - {member.login} - {member.email} - - this.removeMember(member)} /> - - - ); + onRemoveMember(member: TeamMember) { + this.props.removeTeamMember(member.userId); } onToggleAdding = () => { @@ -62,16 +48,29 @@ export class TeamMembers extends React.Component { }; onAddUserToTeam = async () => { - await this.props.team.addMember(this.state.newTeamMember.id); - await this.props.team.loadMembers(); + this.props.addTeamMember(this.state.newTeamMember.id); this.setState({ newTeamMember: null }); }; + renderMember(member: TeamMember) { + return ( + + + + + {member.login} + {member.email} + + this.onRemoveMember(member)} /> + + + ); + } + render() { const { newTeamMember, isAdding } = this.state; - const members = this.props.team.filteredMembers; + const { searchMemberQuery, members } = this.props; const newTeamMemberValue = newTeamMember && newTeamMember.id.toString(); - const { team } = this.props; return (
    @@ -82,7 +81,7 @@ export class TeamMembers extends React.Component { type="text" className="gf-form-input" placeholder="Search members" - value={team.search} + value={searchMemberQuery} onChange={this.onSearchQueryChange} /> @@ -124,7 +123,7 @@ export class TeamMembers extends React.Component { - {members.map(member => this.renderMember(member))} + {members && members.map(member => this.renderMember(member))}
@@ -132,4 +131,18 @@ export class TeamMembers extends React.Component { } } -export default hot(module)(TeamMembers); +function mapStateToProps(state) { + return { + members: getTeamMembers(state.team), + searchMemberQuery: getSearchMemberQuery(state.team), + }; +} + +const mapDispatchToProps = { + loadTeamMembers, + addTeamMember, + removeTeamMember, + setSearchMemberQuery, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(TeamMembers); diff --git a/public/app/features/teams/TeamPages.test.tsx b/public/app/features/teams/TeamPages.test.tsx new file mode 100644 index 00000000000..65084d0dc47 --- /dev/null +++ b/public/app/features/teams/TeamPages.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { TeamPages, Props } from './TeamPages'; +import { NavModel, Team } from '../../types'; +import { getMockTeam } from './__mocks__/teamMocks'; + +jest.mock('app/core/config', () => ({ + buildInfo: { isEnterprise: true }, +})); + +const setup = (propOverrides?: object) => { + const props: Props = { + navModel: {} as NavModel, + teamId: 1, + loadTeam: jest.fn(), + pageName: 'members', + team: {} as Team, + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + const instance = wrapper.instance(); + + return { + wrapper, + instance, + }; +}; + +describe('Render', () => { + it('should render component', () => { + const { wrapper } = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render member page if team not empty', () => { + const { wrapper } = setup({ + team: getMockTeam(), + }); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render settings page', () => { + const { wrapper } = setup({ + team: getMockTeam(), + pageName: 'settings', + }); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render group sync page', () => { + const { wrapper } = setup({ + team: getMockTeam(), + pageName: 'groupsync', + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/teams/TeamPages.tsx b/public/app/features/teams/TeamPages.tsx new file mode 100644 index 00000000000..f28bde518d2 --- /dev/null +++ b/public/app/features/teams/TeamPages.tsx @@ -0,0 +1,105 @@ +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; +import _ from 'lodash'; +import { hot } from 'react-hot-loader'; +import config from 'app/core/config'; +import PageHeader from 'app/core/components/PageHeader/PageHeader'; +import TeamMembers from './TeamMembers'; +import TeamSettings from './TeamSettings'; +import TeamGroupSync from './TeamGroupSync'; +import { NavModel, Team } from '../../types'; +import { loadTeam } from './state/actions'; +import { getTeam } from './state/selectors'; +import { getNavModel } from '../../core/selectors/navModel'; +import { getRouteParamsId, getRouteParamsPage } from '../../core/selectors/location'; + +export interface Props { + team: Team; + loadTeam: typeof loadTeam; + teamId: number; + pageName: string; + navModel: NavModel; +} + +interface State { + isSyncEnabled: boolean; +} + +enum PageTypes { + Members = 'members', + Settings = 'settings', + GroupSync = 'groupsync', +} + +export class TeamPages extends PureComponent { + constructor(props) { + super(props); + + this.state = { + isSyncEnabled: config.buildInfo.isEnterprise, + }; + } + + componentDidMount() { + this.fetchTeam(); + } + + async fetchTeam() { + const { loadTeam, teamId } = this.props; + + await loadTeam(teamId); + } + + getCurrentPage() { + const pages = ['members', 'settings', 'groupsync']; + const currentPage = this.props.pageName; + return _.includes(pages, currentPage) ? currentPage : pages[0]; + } + + renderPage() { + const { isSyncEnabled } = this.state; + const currentPage = this.getCurrentPage(); + + switch (currentPage) { + case PageTypes.Members: + return ; + + case PageTypes.Settings: + return ; + + case PageTypes.GroupSync: + return isSyncEnabled && ; + } + + return null; + } + + render() { + const { team, navModel } = this.props; + + return ( +
+ + {team && Object.keys(team).length !== 0 &&
{this.renderPage()}
} +
+ ); + } +} + +function mapStateToProps(state) { + const teamId = getRouteParamsId(state.location); + const pageName = getRouteParamsPage(state.location) || 'members'; + + return { + navModel: getNavModel(state.navIndex, `team-${pageName}-${teamId}`), + teamId: teamId, + pageName: pageName, + team: getTeam(state.team, teamId), + }; +} + +const mapDispatchToProps = { + loadTeam, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(TeamPages)); diff --git a/public/app/features/teams/TeamSettings.test.tsx b/public/app/features/teams/TeamSettings.test.tsx new file mode 100644 index 00000000000..2e40a0e3c44 --- /dev/null +++ b/public/app/features/teams/TeamSettings.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { Props, TeamSettings } from './TeamSettings'; +import { getMockTeam } from './__mocks__/teamMocks'; + +const setup = (propOverrides?: object) => { + const props: Props = { + team: getMockTeam(), + updateTeam: jest.fn(), + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + const instance = wrapper.instance() as TeamSettings; + + return { + wrapper, + instance, + }; +}; + +describe('Render', () => { + it('should render component', () => { + const { wrapper } = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); + +describe('Functions', () => { + it('should update team', () => { + const { instance } = setup(); + const mockEvent = { preventDefault: jest.fn() }; + + instance.setState({ + name: 'test11', + }); + + instance.onUpdate(mockEvent); + + expect(instance.props.updateTeam).toHaveBeenCalledWith('test11', 'test@test.com'); + }); +}); diff --git a/public/app/features/teams/TeamSettings.tsx b/public/app/features/teams/TeamSettings.tsx new file mode 100644 index 00000000000..ef9a5ae0b70 --- /dev/null +++ b/public/app/features/teams/TeamSettings.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { Label } from 'app/core/components/Forms/Forms'; +import { Team } from '../../types'; +import { updateTeam } from './state/actions'; +import { getRouteParamsId } from '../../core/selectors/location'; +import { getTeam } from './state/selectors'; + +export interface Props { + team: Team; + updateTeam: typeof updateTeam; +} + +interface State { + name: string; + email: string; +} + +export class TeamSettings extends React.Component { + constructor(props) { + super(props); + + this.state = { + name: props.team.name, + email: props.team.email, + }; + } + + onChangeName = event => { + this.setState({ name: event.target.value }); + }; + + onChangeEmail = event => { + this.setState({ email: event.target.value }); + }; + + onUpdate = event => { + const { name, email } = this.state; + event.preventDefault(); + this.props.updateTeam(name, email); + }; + + render() { + const { name, email } = this.state; + + return ( +
+

Team Settings

+
+
+ + +
+
+ + +
+ +
+ +
+
+
+ ); + } +} + +function mapStateToProps(state) { + const teamId = getRouteParamsId(state.location); + + return { + team: getTeam(state.team, teamId), + }; +} + +const mapDispatchToProps = { + updateTeam, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(TeamSettings); diff --git a/public/app/features/teams/__mocks__/navModelMock.ts b/public/app/features/teams/__mocks__/navModelMock.ts new file mode 100644 index 00000000000..7aa8515ee13 --- /dev/null +++ b/public/app/features/teams/__mocks__/navModelMock.ts @@ -0,0 +1,59 @@ +export const getMockNavModel = (pageName: string) => { + return { + node: { + active: false, + icon: 'gicon gicon-team', + id: `team-${pageName}-2`, + text: `${pageName}`, + url: 'org/teams/edit/2/members', + parentItem: { + img: '/avatar/b5695b61c91d13e7fa2fe71cfb95de9b', + id: 'team-2', + subTitle: 'Manage members & settings', + url: '', + text: 'test1', + breadcrumbs: [{ title: 'Teams', url: 'org/teams' }], + children: [ + { + active: false, + icon: 'gicon gicon-team', + id: 'team-members-2', + text: 'Members', + url: 'org/teams/edit/2/members', + }, + { + active: false, + icon: 'fa fa-fw fa-sliders', + id: 'team-settings-2', + text: 'Settings', + url: 'org/teams/edit/2/settings', + }, + ], + }, + }, + main: { + img: '/avatar/b5695b61c91d13e7fa2fe71cfb95de9b', + id: 'team-2', + subTitle: 'Manage members & settings', + url: '', + text: 'test1', + breadcrumbs: [{ title: 'Teams', url: 'org/teams' }], + children: [ + { + active: true, + icon: 'gicon gicon-team', + id: 'team-members-2', + text: 'Members', + url: 'org/teams/edit/2/members', + }, + { + active: false, + icon: 'fa fa-fw fa-sliders', + id: 'team-settings-2', + text: 'Settings', + url: 'org/teams/edit/2/settings', + }, + ], + }, + }; +}; diff --git a/public/app/features/teams/__mocks__/teamMocks.ts b/public/app/features/teams/__mocks__/teamMocks.ts new file mode 100644 index 00000000000..c9e9a27bee0 --- /dev/null +++ b/public/app/features/teams/__mocks__/teamMocks.ts @@ -0,0 +1,65 @@ +import { Team, TeamGroup, TeamMember } from '../../../types'; + +export const getMultipleMockTeams = (numberOfTeams: number): Team[] => { + const teams: Team[] = []; + for (let i = 1; i <= numberOfTeams; i++) { + teams.push({ + id: i, + name: `test-${i}`, + avatarUrl: 'some/url/', + email: `test-${i}@test.com`, + memberCount: i, + }); + } + + return teams; +}; + +export const getMockTeam = (): Team => { + return { + id: 1, + name: 'test', + avatarUrl: 'some/url/', + email: 'test@test.com', + memberCount: 1, + }; +}; + +export const getMockTeamMembers = (amount: number): TeamMember[] => { + const teamMembers: TeamMember[] = []; + + for (let i = 1; i <= amount; i++) { + teamMembers.push({ + userId: i, + teamId: 1, + avatarUrl: 'some/url/', + email: 'test@test.com', + login: `testUser-${i}`, + }); + } + + return teamMembers; +}; + +export const getMockTeamMember = (): TeamMember => { + return { + userId: 1, + teamId: 1, + avatarUrl: 'some/url/', + email: 'test@test.com', + login: 'testUser', + }; +}; + +export const getMockTeamGroups = (amount: number): TeamGroup[] => { + const groups: TeamGroup[] = []; + + for (let i = 1; i <= amount; i++) { + groups.push({ + groupId: `group-${i}`, + teamId: 1, + }); + } + + return groups; +}; diff --git a/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap new file mode 100644 index 00000000000..5a143f19038 --- /dev/null +++ b/public/app/features/teams/__snapshots__/TeamGroupSync.test.tsx.snap @@ -0,0 +1,281 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
+ +`; + +exports[`Render should render groups table 1`] = ` +
+
+

+ External group sync +

+ + + +
+ +
+ +
+ +
+ Add External Group +
+
+
+ +
+
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+ External Group ID + +
+ group-1 + + + + +
+ group-2 + + + + +
+ group-3 + + + + +
+
+
+`; diff --git a/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap new file mode 100644 index 00000000000..45d0f78126e --- /dev/null +++ b/public/app/features/teams/__snapshots__/TeamList.test.tsx.snap @@ -0,0 +1,354 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
+ +
+ +
+
+`; + +exports[`Render should render teams table 1`] = ` +
+ +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Name + + Email + + Members + +
+ + + + + + test-1 + + + + test-1@test.com + + + + 1 + + + +
+ + + + + + test-2 + + + + test-2@test.com + + + + 2 + + + +
+ + + + + + test-3 + + + + test-3@test.com + + + + 3 + + + +
+ + + + + + test-4 + + + + test-4@test.com + + + + 4 + + + +
+ + + + + + test-5 + + + + test-5@test.com + + + + 5 + + + +
+
+
+
+`; diff --git a/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap new file mode 100644 index 00000000000..2a42897e2b9 --- /dev/null +++ b/public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap @@ -0,0 +1,317 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
+
+
+ +
+
+ +
+ +
+ +
+ Add Team Member +
+
+ +
+
+
+
+ + + + + + + + +
+ + Name + + Email + +
+
+
+`; + +exports[`Render should render team members 1`] = ` +
+
+
+ +
+
+ +
+ +
+ +
+ Add Team Member +
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Name + + Email + +
+ + + testUser-1 + + test@test.com + + +
+ + + testUser-2 + + test@test.com + + +
+ + + testUser-3 + + test@test.com + + +
+ + + testUser-4 + + test@test.com + + +
+ + + testUser-5 + + test@test.com + + +
+
+
+`; diff --git a/public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap new file mode 100644 index 00000000000..4ce4df4acb2 --- /dev/null +++ b/public/app/features/teams/__snapshots__/TeamPages.test.tsx.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
+ +
+`; + +exports[`Render should render group sync page 1`] = ` +
+ +
+ +
+
+`; + +exports[`Render should render member page if team not empty 1`] = ` +
+ +
+ +
+
+`; + +exports[`Render should render settings page 1`] = ` +
+ +
+ +
+
+`; diff --git a/public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap b/public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap new file mode 100644 index 00000000000..0f6573ccf90 --- /dev/null +++ b/public/app/features/teams/__snapshots__/TeamSettings.test.tsx.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
+

+ Team Settings +

+
+
+ + Name + + +
+
+ + Email + + +
+
+ +
+
+
+`; diff --git a/public/app/features/teams/state/actions.ts b/public/app/features/teams/state/actions.ts new file mode 100644 index 00000000000..91aa899e171 --- /dev/null +++ b/public/app/features/teams/state/actions.ts @@ -0,0 +1,237 @@ +import { ThunkAction } from 'redux-thunk'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { NavModelItem, StoreState, Team, TeamGroup, TeamMember } from 'app/types'; +import { updateNavIndex, UpdateNavIndexAction } from 'app/core/actions'; +import config from 'app/core/config'; + +export enum ActionTypes { + LoadTeams = 'LOAD_TEAMS', + LoadTeam = 'LOAD_TEAM', + SetSearchQuery = 'SET_TEAM_SEARCH_QUERY', + SetSearchMemberQuery = 'SET_TEAM_MEMBER_SEARCH_QUERY', + LoadTeamMembers = 'TEAM_MEMBERS_LOADED', + LoadTeamGroups = 'TEAM_GROUPS_LOADED', +} + +export interface LoadTeamsAction { + type: ActionTypes.LoadTeams; + payload: Team[]; +} + +export interface LoadTeamAction { + type: ActionTypes.LoadTeam; + payload: Team; +} + +export interface LoadTeamMembersAction { + type: ActionTypes.LoadTeamMembers; + payload: TeamMember[]; +} + +export interface LoadTeamGroupsAction { + type: ActionTypes.LoadTeamGroups; + payload: TeamGroup[]; +} + +export interface SetSearchQueryAction { + type: ActionTypes.SetSearchQuery; + payload: string; +} + +export interface SetSearchMemberQueryAction { + type: ActionTypes.SetSearchMemberQuery; + payload: string; +} + +export type Action = + | LoadTeamsAction + | SetSearchQueryAction + | LoadTeamAction + | LoadTeamMembersAction + | SetSearchMemberQueryAction + | LoadTeamGroupsAction; + +type ThunkResult = ThunkAction; + +const teamsLoaded = (teams: Team[]): LoadTeamsAction => ({ + type: ActionTypes.LoadTeams, + payload: teams, +}); + +const teamLoaded = (team: Team): LoadTeamAction => ({ + type: ActionTypes.LoadTeam, + payload: team, +}); + +const teamMembersLoaded = (teamMembers: TeamMember[]): LoadTeamMembersAction => ({ + type: ActionTypes.LoadTeamMembers, + payload: teamMembers, +}); + +const teamGroupsLoaded = (teamGroups: TeamGroup[]): LoadTeamGroupsAction => ({ + type: ActionTypes.LoadTeamGroups, + payload: teamGroups, +}); + +export const setSearchMemberQuery = (searchQuery: string): SetSearchMemberQueryAction => ({ + type: ActionTypes.SetSearchMemberQuery, + payload: searchQuery, +}); + +export const setSearchQuery = (searchQuery: string): SetSearchQueryAction => ({ + type: ActionTypes.SetSearchQuery, + payload: searchQuery, +}); + +export function loadTeams(): ThunkResult { + return async dispatch => { + const response = await getBackendSrv().get('/api/teams/search', { perpage: 1000, page: 1 }); + dispatch(teamsLoaded(response.teams)); + }; +} + +function buildNavModel(team: Team): NavModelItem { + const navModel = { + img: team.avatarUrl, + id: 'team-' + team.id, + subTitle: 'Manage members & settings', + url: '', + text: team.name, + breadcrumbs: [{ title: 'Teams', url: 'org/teams' }], + children: [ + { + active: false, + icon: 'gicon gicon-team', + id: `team-members-${team.id}`, + text: 'Members', + url: `org/teams/edit/${team.id}/members`, + }, + { + active: false, + icon: 'fa fa-fw fa-sliders', + id: `team-settings-${team.id}`, + text: 'Settings', + url: `org/teams/edit/${team.id}/settings`, + }, + ], + }; + + if (config.buildInfo.isEnterprise) { + navModel.children.push({ + active: false, + icon: 'fa fa-fw fa-refresh', + id: `team-groupsync-${team.id}`, + text: 'External group sync', + url: `org/teams/edit/${team.id}/groupsync`, + }); + } + + return navModel; +} + +export function loadTeam(id: number): ThunkResult { + return async dispatch => { + await getBackendSrv() + .get(`/api/teams/${id}`) + .then(response => { + dispatch(teamLoaded(response)); + dispatch(updateNavIndex(buildNavModel(response))); + }); + }; +} + +export function loadTeamMembers(): ThunkResult { + return async (dispatch, getStore) => { + const team = getStore().team.team; + + await getBackendSrv() + .get(`/api/teams/${team.id}/members`) + .then(response => { + dispatch(teamMembersLoaded(response)); + }); + }; +} + +export function addTeamMember(id: number): ThunkResult { + return async (dispatch, getStore) => { + const team = getStore().team.team; + + await getBackendSrv() + .post(`/api/teams/${team.id}/members`, { userId: id }) + .then(() => { + dispatch(loadTeamMembers()); + }); + }; +} + +export function removeTeamMember(id: number): ThunkResult { + return async (dispatch, getStore) => { + const team = getStore().team.team; + + await getBackendSrv() + .delete(`/api/teams/${team.id}/members/${id}`) + .then(() => { + dispatch(loadTeamMembers()); + }); + }; +} + +export function updateTeam(name: string, email: string): ThunkResult { + return async (dispatch, getStore) => { + const team = getStore().team.team; + await getBackendSrv() + .put(`/api/teams/${team.id}`, { + name, + email, + }) + .then(() => { + dispatch(loadTeam(team.id)); + }); + }; +} + +export function loadTeamGroups(): ThunkResult { + return async (dispatch, getStore) => { + const team = getStore().team.team; + + await getBackendSrv() + .get(`/api/teams/${team.id}/groups`) + .then(response => { + dispatch(teamGroupsLoaded(response)); + }); + }; +} + +export function addTeamGroup(groupId: string): ThunkResult { + return async (dispatch, getStore) => { + const team = getStore().team.team; + + await getBackendSrv() + .post(`/api/teams/${team.id}/groups`, { groupId: groupId }) + .then(() => { + dispatch(loadTeamGroups()); + }); + }; +} + +export function removeTeamGroup(groupId: string): ThunkResult { + return async (dispatch, getStore) => { + const team = getStore().team.team; + + await getBackendSrv() + .delete(`/api/teams/${team.id}/groups/${groupId}`) + .then(() => { + dispatch(loadTeamGroups()); + }); + }; +} + +export function deleteTeam(id: number): ThunkResult { + return async dispatch => { + await getBackendSrv() + .delete(`/api/teams/${id}`) + .then(() => { + dispatch(loadTeams()); + }); + }; +} diff --git a/public/app/features/teams/state/reducers.test.ts b/public/app/features/teams/state/reducers.test.ts new file mode 100644 index 00000000000..7f7a33d60ac --- /dev/null +++ b/public/app/features/teams/state/reducers.test.ts @@ -0,0 +1,72 @@ +import { Action, ActionTypes } from './actions'; +import { initialTeamsState, initialTeamState, teamReducer, teamsReducer } from './reducers'; +import { getMockTeam, getMockTeamMember } from '../__mocks__/teamMocks'; + +describe('teams reducer', () => { + it('should set teams', () => { + const payload = [getMockTeam()]; + + const action: Action = { + type: ActionTypes.LoadTeams, + payload, + }; + + const result = teamsReducer(initialTeamsState, action); + + expect(result.teams).toEqual(payload); + }); + + it('should set search query', () => { + const payload = 'test'; + + const action: Action = { + type: ActionTypes.SetSearchQuery, + payload, + }; + + const result = teamsReducer(initialTeamsState, action); + + expect(result.searchQuery).toEqual('test'); + }); +}); + +describe('team reducer', () => { + it('should set team', () => { + const payload = getMockTeam(); + + const action: Action = { + type: ActionTypes.LoadTeam, + payload, + }; + + const result = teamReducer(initialTeamState, action); + + expect(result.team).toEqual(payload); + }); + + it('should set team members', () => { + const mockTeamMember = getMockTeamMember(); + + const action: Action = { + type: ActionTypes.LoadTeamMembers, + payload: [mockTeamMember], + }; + + const result = teamReducer(initialTeamState, action); + + expect(result.members).toEqual([mockTeamMember]); + }); + + it('should set member search query', () => { + const payload = 'member'; + + const action: Action = { + type: ActionTypes.SetSearchMemberQuery, + payload, + }; + + const result = teamReducer(initialTeamState, action); + + expect(result.searchMemberQuery).toEqual('member'); + }); +}); diff --git a/public/app/features/teams/state/reducers.ts b/public/app/features/teams/state/reducers.ts new file mode 100644 index 00000000000..8b76028b9cb --- /dev/null +++ b/public/app/features/teams/state/reducers.ts @@ -0,0 +1,44 @@ +import { Team, TeamGroup, TeamMember, TeamsState, TeamState } from 'app/types'; +import { Action, ActionTypes } from './actions'; + +export const initialTeamsState: TeamsState = { teams: [], searchQuery: '' }; +export const initialTeamState: TeamState = { + team: {} as Team, + members: [] as TeamMember[], + groups: [] as TeamGroup[], + searchMemberQuery: '', +}; + +export const teamsReducer = (state = initialTeamsState, action: Action): TeamsState => { + switch (action.type) { + case ActionTypes.LoadTeams: + return { ...state, teams: action.payload }; + + case ActionTypes.SetSearchQuery: + return { ...state, searchQuery: action.payload }; + } + return state; +}; + +export const teamReducer = (state = initialTeamState, action: Action): TeamState => { + switch (action.type) { + case ActionTypes.LoadTeam: + return { ...state, team: action.payload }; + + case ActionTypes.LoadTeamMembers: + return { ...state, members: action.payload }; + + case ActionTypes.SetSearchMemberQuery: + return { ...state, searchMemberQuery: action.payload }; + + case ActionTypes.LoadTeamGroups: + return { ...state, groups: action.payload }; + } + + return state; +}; + +export default { + teams: teamsReducer, + team: teamReducer, +}; diff --git a/public/app/features/teams/state/selectors.test.ts b/public/app/features/teams/state/selectors.test.ts new file mode 100644 index 00000000000..5f338069bbb --- /dev/null +++ b/public/app/features/teams/state/selectors.test.ts @@ -0,0 +1,56 @@ +import { getTeam, getTeamMembers, getTeams } from './selectors'; +import { getMockTeam, getMockTeamMembers, getMultipleMockTeams } from '../__mocks__/teamMocks'; +import { Team, TeamGroup, TeamsState, TeamState } from '../../../types'; + +describe('Teams selectors', () => { + describe('Get teams', () => { + const mockTeams = getMultipleMockTeams(5); + + it('should return teams if no search query', () => { + const mockState: TeamsState = { teams: mockTeams, searchQuery: '' }; + + const teams = getTeams(mockState); + + expect(teams).toEqual(mockTeams); + }); + + it('Should filter teams if search query', () => { + const mockState: TeamsState = { teams: mockTeams, searchQuery: '5' }; + + const teams = getTeams(mockState); + + expect(teams.length).toEqual(1); + }); + }); +}); + +describe('Team selectors', () => { + describe('Get team', () => { + const mockTeam = getMockTeam(); + + it('should return team if matching with location team', () => { + const mockState: TeamState = { team: mockTeam, searchMemberQuery: '', members: [], groups: [] }; + + const team = getTeam(mockState, '1'); + + expect(team).toEqual(mockTeam); + }); + }); + + describe('Get members', () => { + const mockTeamMembers = getMockTeamMembers(5); + + it('should return team members', () => { + const mockState: TeamState = { + team: {} as Team, + searchMemberQuery: '', + members: mockTeamMembers, + groups: [] as TeamGroup[], + }; + + const members = getTeamMembers(mockState); + + expect(members).toEqual(mockTeamMembers); + }); + }); +}); diff --git a/public/app/features/teams/state/selectors.ts b/public/app/features/teams/state/selectors.ts new file mode 100644 index 00000000000..9201993bf0d --- /dev/null +++ b/public/app/features/teams/state/selectors.ts @@ -0,0 +1,30 @@ +import { Team, TeamsState, TeamState } from 'app/types'; + +export const getSearchQuery = (state: TeamsState) => state.searchQuery; +export const getSearchMemberQuery = (state: TeamState) => state.searchMemberQuery; +export const getTeamGroups = (state: TeamState) => state.groups; +export const getTeamsCount = (state: TeamsState) => state.teams.length; + +export const getTeam = (state: TeamState, currentTeamId): Team | null => { + if (state.team.id === parseInt(currentTeamId, 10)) { + return state.team; + } + + return null; +}; + +export const getTeams = (state: TeamsState) => { + const regex = RegExp(state.searchQuery, 'i'); + + return state.teams.filter(team => { + return regex.test(team.name); + }); +}; + +export const getTeamMembers = (state: TeamState) => { + const regex = RegExp(state.searchMemberQuery, 'i'); + + return state.members.filter(member => { + return regex.test(member.login) || regex.test(member.email); + }); +}; diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index a0b070cbcb4..519008d70f5 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -4,9 +4,9 @@ import './ReactContainer'; import ServerStats from 'app/features/admin/ServerStats'; import AlertRuleList from 'app/features/alerting/AlertRuleList'; import FolderPermissions from 'app/containers/ManageDashboards/FolderPermissions'; +import TeamPages from 'app/features/teams/TeamPages'; +import TeamList from 'app/features/teams/TeamList'; import FolderSettings from 'app/containers/ManageDashboards/FolderSettings'; -import TeamPages from 'app/containers/Teams/TeamPages'; -import TeamList from 'app/containers/Teams/TeamList'; /** @ngInject */ export function setupAngularRoutes($routeProvider, $locationProvider) { diff --git a/public/app/stores/NavStore/NavStore.ts b/public/app/stores/NavStore/NavStore.ts index d869b0f740d..f87cc486b41 100644 --- a/public/app/stores/NavStore/NavStore.ts +++ b/public/app/stores/NavStore/NavStore.ts @@ -1,7 +1,6 @@ import _ from 'lodash'; import { types, getEnv } from 'mobx-state-tree'; import { NavItem } from './NavItem'; -import { Team } from '../TeamsStore/TeamsStore'; export const NavStore = types .model('NavStore', { @@ -116,43 +115,4 @@ export const NavStore = types self.main = NavItem.create(main); }, - - initTeamPage(team: Team, tab: string, isSyncEnabled: boolean) { - const main = { - img: team.avatarUrl, - id: 'team-' + team.id, - subTitle: 'Manage members & settings', - url: '', - text: team.name, - breadcrumbs: [{ title: 'Teams', url: 'org/teams' }], - children: [ - { - active: tab === 'members', - icon: 'gicon gicon-team', - id: 'team-members', - text: 'Members', - url: `org/teams/edit/${team.id}/members`, - }, - { - active: tab === 'settings', - icon: 'fa fa-fw fa-sliders', - id: 'team-settings', - text: 'Settings', - url: `org/teams/edit/${team.id}/settings`, - }, - ], - }; - - if (isSyncEnabled) { - main.children.splice(1, 0, { - active: tab === 'groupsync', - icon: 'fa fa-fw fa-refresh', - id: 'team-settings', - text: 'External group sync', - url: `org/teams/edit/${team.id}/groupsync`, - }); - } - - self.main = NavItem.create(main); - }, })); diff --git a/public/app/stores/RootStore/RootStore.ts b/public/app/stores/RootStore/RootStore.ts index fba25e5f015..37c13f48c61 100644 --- a/public/app/stores/RootStore/RootStore.ts +++ b/public/app/stores/RootStore/RootStore.ts @@ -3,7 +3,6 @@ import { NavStore } from './../NavStore/NavStore'; import { ViewStore } from './../ViewStore/ViewStore'; import { FolderStore } from './../FolderStore/FolderStore'; import { PermissionsStore } from './../PermissionsStore/PermissionsStore'; -import { TeamsStore } from './../TeamsStore/TeamsStore'; export const RootStore = types.model({ nav: types.optional(NavStore, {}), @@ -17,9 +16,6 @@ export const RootStore = types.model({ routeParams: {}, }), folder: types.optional(FolderStore, {}), - teams: types.optional(TeamsStore, { - map: {}, - }), }); type RootStoreType = typeof RootStore.Type; diff --git a/public/app/stores/TeamsStore/TeamsStore.ts b/public/app/stores/TeamsStore/TeamsStore.ts deleted file mode 100644 index bc8af218def..00000000000 --- a/public/app/stores/TeamsStore/TeamsStore.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { types, getEnv, flow } from 'mobx-state-tree'; - -export const TeamMemberModel = types.model('TeamMember', { - userId: types.identifier(types.number), - teamId: types.number, - avatarUrl: types.string, - email: types.string, - login: types.string, -}); - -type TeamMemberType = typeof TeamMemberModel.Type; -export interface TeamMember extends TeamMemberType {} - -export const TeamGroupModel = types.model('TeamGroup', { - groupId: types.identifier(types.string), - teamId: types.number, -}); - -type TeamGroupType = typeof TeamGroupModel.Type; -export interface TeamGroup extends TeamGroupType {} - -export const TeamModel = types - .model('Team', { - id: types.identifier(types.number), - name: types.string, - avatarUrl: types.string, - email: types.string, - memberCount: types.number, - search: types.optional(types.string, ''), - members: types.optional(types.map(TeamMemberModel), {}), - groups: types.optional(types.map(TeamGroupModel), {}), - }) - .views(self => ({ - get filteredMembers(this: Team) { - const members = this.members.values(); - const regex = new RegExp(self.search, 'i'); - return members.filter(member => { - return regex.test(member.login) || regex.test(member.email); - }); - }, - })) - .actions(self => ({ - setName(name: string) { - self.name = name; - }, - - setEmail(email: string) { - self.email = email; - }, - - setSearchQuery(query: string) { - self.search = query; - }, - - update: flow(function* load() { - const backendSrv = getEnv(self).backendSrv; - - yield backendSrv.put(`/api/teams/${self.id}`, { - name: self.name, - email: self.email, - }); - }), - - loadMembers: flow(function* load() { - const backendSrv = getEnv(self).backendSrv; - const rsp = yield backendSrv.get(`/api/teams/${self.id}/members`); - self.members.clear(); - - for (const member of rsp) { - self.members.set(member.userId.toString(), TeamMemberModel.create(member)); - } - }), - - removeMember: flow(function* load(member: TeamMember) { - const backendSrv = getEnv(self).backendSrv; - yield backendSrv.delete(`/api/teams/${self.id}/members/${member.userId}`); - // remove from store map - self.members.delete(member.userId.toString()); - }), - - addMember: flow(function* load(userId: number) { - const backendSrv = getEnv(self).backendSrv; - yield backendSrv.post(`/api/teams/${self.id}/members`, { userId: userId }); - }), - - loadGroups: flow(function* load() { - const backendSrv = getEnv(self).backendSrv; - const rsp = yield backendSrv.get(`/api/teams/${self.id}/groups`); - self.groups.clear(); - - for (const group of rsp) { - self.groups.set(group.groupId, TeamGroupModel.create(group)); - } - }), - - addGroup: flow(function* load(groupId: string) { - const backendSrv = getEnv(self).backendSrv; - yield backendSrv.post(`/api/teams/${self.id}/groups`, { groupId: groupId }); - self.groups.set( - groupId, - TeamGroupModel.create({ - teamId: self.id, - groupId: groupId, - }) - ); - }), - - removeGroup: flow(function* load(groupId: string) { - const backendSrv = getEnv(self).backendSrv; - yield backendSrv.delete(`/api/teams/${self.id}/groups/${groupId}`); - self.groups.delete(groupId); - }), - })); - -type TeamType = typeof TeamModel.Type; -export interface Team extends TeamType {} - -export const TeamsStore = types - .model('TeamsStore', { - map: types.map(TeamModel), - search: types.optional(types.string, ''), - }) - .views(self => ({ - get filteredTeams(this: any) { - const teams = this.map.values(); - const regex = new RegExp(self.search, 'i'); - return teams.filter(team => { - return regex.test(team.name); - }); - }, - })) - .actions(self => ({ - loadTeams: flow(function* load() { - const backendSrv = getEnv(self).backendSrv; - const rsp = yield backendSrv.get('/api/teams/search/', { perpage: 50, page: 1 }); - self.map.clear(); - - for (const team of rsp.teams) { - self.map.set(team.id.toString(), TeamModel.create(team)); - } - }), - - setSearchQuery(query: string) { - self.search = query; - }, - - loadById: flow(function* load(id: string) { - if (self.map.has(id)) { - return; - } - - const backendSrv = getEnv(self).backendSrv; - const team = yield backendSrv.get(`/api/teams/${id}`); - self.map.set(id, TeamModel.create(team)); - }), - })); diff --git a/public/app/stores/configureStore.ts b/public/app/stores/configureStore.ts index b101b31a2a2..0cdc07fd31a 100644 --- a/public/app/stores/configureStore.ts +++ b/public/app/stores/configureStore.ts @@ -3,10 +3,12 @@ import thunk from 'redux-thunk'; import { createLogger } from 'redux-logger'; import sharedReducers from 'app/core/reducers'; import alertingReducers from 'app/features/alerting/state/reducers'; +import teamsReducers from 'app/features/teams/state/reducers'; const rootReducer = combineReducers({ ...sharedReducers, ...alertingReducers, + ...teamsReducers, }); export let store; diff --git a/public/app/types/alerting.ts b/public/app/types/alerting.ts new file mode 100644 index 00000000000..3a696d3a8d6 --- /dev/null +++ b/public/app/types/alerting.ts @@ -0,0 +1,35 @@ +export interface AlertRuleDTO { + id: number; + dashboardId: number; + dashboardUid: string; + dashboardSlug: string; + panelId: number; + name: string; + state: string; + newStateDate: string; + evalDate: string; + evalData?: object; + executionError: string; + url: string; +} + +export interface AlertRule { + id: number; + dashboardId: number; + panelId: number; + name: string; + state: string; + stateText: string; + stateIcon: string; + stateClass: string; + stateAge: string; + url: string; + info?: string; + executionError?: string; + evalData?: { noData: boolean }; +} + +export interface AlertRulesState { + items: AlertRule[]; + searchQuery: string; +} diff --git a/public/app/types/index.ts b/public/app/types/index.ts index debfcf58ac8..92bcdb32836 100644 --- a/public/app/types/index.ts +++ b/public/app/types/index.ts @@ -1,96 +1,30 @@ -// -// Location -// +import { Team, TeamsState, TeamState, TeamGroup, TeamMember } from './teams'; +import { AlertRuleDTO, AlertRule, AlertRulesState } from './alerting'; +import { LocationState, LocationUpdate, UrlQueryMap, UrlQueryValue } from './location'; +import { NavModel, NavModelItem, NavIndex } from './navModel'; -export interface LocationUpdate { - path?: string; - query?: UrlQueryMap; - routeParams?: UrlQueryMap; -} - -export interface LocationState { - url: string; - path: string; - query: UrlQueryMap; - routeParams: UrlQueryMap; -} - -export type UrlQueryValue = string | number | boolean | string[] | number[] | boolean[]; -export type UrlQueryMap = { [s: string]: UrlQueryValue }; - -// -// Alerting -// - -export interface AlertRuleApi { - id: number; - dashboardId: number; - dashboardUid: string; - dashboardSlug: string; - panelId: number; - name: string; - state: string; - newStateDate: string; - evalDate: string; - evalData?: object; - executionError: string; - url: string; -} - -export interface AlertRule { - id: number; - dashboardId: number; - panelId: number; - name: string; - state: string; - stateText: string; - stateIcon: string; - stateClass: string; - stateAge: string; - url: string; - info?: string; - executionError?: string; - evalData?: { noData: boolean }; -} - -// -// NavModel -// - -export interface NavModelItem { - text: string; - url: string; - subTitle?: string; - icon?: string; - img?: string; - id: string; - active?: boolean; - hideFromTabs?: boolean; - divider?: boolean; - children?: NavModelItem[]; - breadcrumbs?: NavModelItem[]; - target?: string; - parentItem?: NavModelItem; -} - -export interface NavModel { - main: NavModelItem; - node: NavModelItem; -} - -export type NavIndex = { [s: string]: NavModelItem }; - -// -// Store -// - -export interface AlertRulesState { - items: AlertRule[]; - searchQuery: string; -} +export { + Team, + TeamsState, + TeamState, + TeamGroup, + TeamMember, + AlertRuleDTO, + AlertRule, + AlertRulesState, + LocationState, + LocationUpdate, + NavModel, + NavModelItem, + NavIndex, + UrlQueryMap, + UrlQueryValue, +}; export interface StoreState { navIndex: NavIndex; location: LocationState; alertRules: AlertRulesState; + teams: TeamsState; + team: TeamState; } diff --git a/public/app/types/location.ts b/public/app/types/location.ts new file mode 100644 index 00000000000..4a7f51523a7 --- /dev/null +++ b/public/app/types/location.ts @@ -0,0 +1,15 @@ +export interface LocationUpdate { + path?: string; + query?: UrlQueryMap; + routeParams?: UrlQueryMap; +} + +export interface LocationState { + url: string; + path: string; + query: UrlQueryMap; + routeParams: UrlQueryMap; +} + +export type UrlQueryValue = string | number | boolean | string[] | number[] | boolean[]; +export type UrlQueryMap = { [s: string]: UrlQueryValue }; diff --git a/public/app/types/navModel.ts b/public/app/types/navModel.ts new file mode 100644 index 00000000000..aae4a030cb4 --- /dev/null +++ b/public/app/types/navModel.ts @@ -0,0 +1,22 @@ +export interface NavModelItem { + text: string; + url: string; + subTitle?: string; + icon?: string; + img?: string; + id: string; + active?: boolean; + hideFromTabs?: boolean; + divider?: boolean; + children?: NavModelItem[]; + breadcrumbs?: Array<{ title: string; url: string }>; + target?: string; + parentItem?: NavModelItem; +} + +export interface NavModel { + main: NavModelItem; + node: NavModelItem; +} + +export type NavIndex = { [s: string]: NavModelItem }; diff --git a/public/app/types/teams.ts b/public/app/types/teams.ts new file mode 100644 index 00000000000..bcf752c86c0 --- /dev/null +++ b/public/app/types/teams.ts @@ -0,0 +1,32 @@ +export interface Team { + id: number; + name: string; + avatarUrl: string; + email: string; + memberCount: number; +} + +export interface TeamMember { + userId: number; + teamId: number; + avatarUrl: string; + email: string; + login: string; +} + +export interface TeamGroup { + groupId: string; + teamId: number; +} + +export interface TeamsState { + teams: Team[]; + searchQuery: string; +} + +export interface TeamState { + team: Team; + members: TeamMember[]; + groups: TeamGroup[]; + searchMemberQuery: string; +}