From 353a836128b588e365ddc04b1f17b278f7921814 Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Tue, 25 Sep 2018 16:23:43 +0200 Subject: [PATCH 01/10] wip: Reactify the api keys page #13411 --- public/app/features/api-keys/ApiKeysPage.tsx | 94 +++++++++++++++++++ public/app/features/api-keys/state/actions.ts | 37 ++++++++ .../app/features/api-keys/state/reducers.ts | 16 ++++ public/app/routes/routes.ts | 8 ++ public/app/store/configureStore.ts | 2 + public/app/types/apiKeys.ts | 11 +++ public/app/types/index.ts | 3 + 7 files changed, 171 insertions(+) create mode 100644 public/app/features/api-keys/ApiKeysPage.tsx create mode 100644 public/app/features/api-keys/state/actions.ts create mode 100644 public/app/features/api-keys/state/reducers.ts create mode 100644 public/app/types/apiKeys.ts diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx new file mode 100644 index 00000000000..e0b4da28c40 --- /dev/null +++ b/public/app/features/api-keys/ApiKeysPage.tsx @@ -0,0 +1,94 @@ +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; +import { hot } from 'react-hot-loader'; +import { NavModel, ApiKey } from '../../types'; +import { getNavModel } from 'app/core/selectors/navModel'; +// import { getSearchQuery, getTeams, getTeamsCount } from './state/selectors'; +import PageHeader from 'app/core/components/PageHeader/PageHeader'; +import { loadApiKeys, deleteApiKey } from './state/actions'; +import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; + +export interface Props { + navModel: NavModel; + apiKeys: ApiKey[]; + searchQuery: string; + loadApiKeys: typeof loadApiKeys; + deleteApiKey: typeof deleteApiKey; + // loadTeams: typeof loadTeams; + // deleteTeam: typeof deleteTeam; + // setSearchQuery: typeof setSearchQuery; +} + +export class ApiKeysPage extends PureComponent { + componentDidMount() { + this.fetchApiKeys(); + } + + async fetchApiKeys() { + await this.props.loadApiKeys(); + } + + deleteApiKey(id: number) { + return () => { + this.props.deleteApiKey(id); + }; + } + + render() { + const { navModel, apiKeys } = this.props; + + return ( +
+ +
+

Existing Keys

+ + + + + + + + {apiKeys.length > 0 ? ( + + {apiKeys.map(key => { + // id, name, role + return ( + + + + + + ); + })} + + ) : null} +
NameRole +
{key.name}{key.role} + + + +
+
+
+ ); + } +} + +function mapStateToProps(state) { + return { + navModel: getNavModel(state.navIndex, 'apikeys'), + apiKeys: state.apiKeys.keys, + // searchQuery: getSearchQuery(state.teams), + }; +} + +const mapDispatchToProps = { + loadApiKeys, + deleteApiKey, + // loadTeams, + // deleteTeam, + // setSearchQuery, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(ApiKeysPage)); diff --git a/public/app/features/api-keys/state/actions.ts b/public/app/features/api-keys/state/actions.ts new file mode 100644 index 00000000000..494b562b3c9 --- /dev/null +++ b/public/app/features/api-keys/state/actions.ts @@ -0,0 +1,37 @@ +import { ThunkAction } from 'redux-thunk'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { StoreState, ApiKey } from 'app/types'; +import { updateNavIndex, UpdateNavIndexAction } from 'app/core/actions'; + +export enum ActionTypes { + LoadApiKeys = 'LOAD_API_KEYS', +} + +export interface LoadApiKeysAction { + type: ActionTypes.LoadApiKeys; + payload: ApiKey[]; +} + +export type Action = LoadApiKeysAction; + +type ThunkResult = ThunkAction; + +const apiKeysLoaded = (apiKeys: ApiKey[]): LoadApiKeysAction => ({ + type: ActionTypes.LoadApiKeys, + payload: apiKeys, +}); + +export function loadApiKeys(): ThunkResult { + return async dispatch => { + const response = await getBackendSrv().get('/api/auth/keys'); + dispatch(apiKeysLoaded(response)); + }; +} + +export function deleteApiKey(id: number): ThunkResult { + return async dispatch => { + getBackendSrv() + .delete('/api/auth/keys/' + id) + .then(dispatch(loadApiKeys())); + }; +} diff --git a/public/app/features/api-keys/state/reducers.ts b/public/app/features/api-keys/state/reducers.ts new file mode 100644 index 00000000000..6d45ccbfa03 --- /dev/null +++ b/public/app/features/api-keys/state/reducers.ts @@ -0,0 +1,16 @@ +import { ApiKeysState } from 'app/types'; +import { Action, ActionTypes } from './actions'; + +export const initialApiKeysState: ApiKeysState = { keys: [] }; + +export const apiKeysReducer = (state = initialApiKeysState, action: Action): ApiKeysState => { + switch (action.type) { + case ActionTypes.LoadApiKeys: + return { ...state, keys: action.payload }; + } + return state; +}; + +export default { + apiKeys: apiKeysReducer, +}; diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index 015b4ae0b51..9b90e374769 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -5,6 +5,7 @@ import ServerStats from 'app/features/admin/ServerStats'; import AlertRuleList from 'app/features/alerting/AlertRuleList'; import TeamPages from 'app/features/teams/TeamPages'; import TeamList from 'app/features/teams/TeamList'; +import ApiKeys from 'app/features/api-keys/ApiKeysPage'; import FolderSettingsPage from 'app/features/folders/FolderSettingsPage'; import FolderPermissions from 'app/features/folders/FolderPermissions'; @@ -141,6 +142,13 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { templateUrl: 'public/app/features/org/partials/orgApiKeys.html', controller: 'OrgApiKeysCtrl', }) + .when('/org/apikeys2', { + template: '', + resolve: { + roles: () => ['Editor', 'Admin'], + component: () => ApiKeys, + }, + }) .when('/org/teams', { template: '', resolve: { diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index 8f6cf25043d..3988dad0cd8 100644 --- a/public/app/store/configureStore.ts +++ b/public/app/store/configureStore.ts @@ -4,6 +4,7 @@ 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'; +import apiKeysReducers from 'app/features/api-keys/state/reducers'; import foldersReducers from 'app/features/folders/state/reducers'; import dashboardReducers from 'app/features/dashboard/state/reducers'; @@ -11,6 +12,7 @@ const rootReducer = combineReducers({ ...sharedReducers, ...alertingReducers, ...teamsReducers, + ...apiKeysReducers, ...foldersReducers, ...dashboardReducers, }); diff --git a/public/app/types/apiKeys.ts b/public/app/types/apiKeys.ts new file mode 100644 index 00000000000..56d3e930504 --- /dev/null +++ b/public/app/types/apiKeys.ts @@ -0,0 +1,11 @@ +import { OrgRole } from './acl'; + +export interface ApiKey { + id: number; + name: string; + role: OrgRole; +} + +export interface ApiKeysState { + keys: ApiKey[]; +} diff --git a/public/app/types/index.ts b/public/app/types/index.ts index 778a1b21b55..8c50ea88782 100644 --- a/public/app/types/index.ts +++ b/public/app/types/index.ts @@ -7,6 +7,7 @@ import { DashboardState } from './dashboard'; import { DashboardAcl, OrgRole, PermissionLevel } from './acl'; import { DataSource } from './datasources'; import { PluginMeta } from './plugins'; +import { ApiKey, ApiKeysState } from './apiKeys'; export { Team, @@ -33,6 +34,8 @@ export { PermissionLevel, DataSource, PluginMeta, + ApiKey, + ApiKeysState, }; export interface StoreState { From e8ba35ab2d5d56f13a2eb6cdcb0bad14518cb7dd Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Wed, 26 Sep 2018 10:31:43 +0200 Subject: [PATCH 02/10] Move User type out of UserPicker and into app/types --- public/app/core/components/Picker/UserPicker.tsx | 8 +------- public/app/types/index.ts | 2 ++ public/app/types/user.ts | 6 ++++++ 3 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 public/app/types/user.ts diff --git a/public/app/core/components/Picker/UserPicker.tsx b/public/app/core/components/Picker/UserPicker.tsx index e50513c44e1..8f48ba8f66a 100644 --- a/public/app/core/components/Picker/UserPicker.tsx +++ b/public/app/core/components/Picker/UserPicker.tsx @@ -3,6 +3,7 @@ import Select from 'react-select'; import PickerOption from './PickerOption'; import { debounce } from 'lodash'; import { getBackendSrv } from 'app/core/services/backend_srv'; +import { User } from 'app/types'; export interface Props { onSelected: (user: User) => void; @@ -14,13 +15,6 @@ export interface State { isLoading: boolean; } -export interface User { - id: number; - label: string; - avatarUrl: string; - login: string; -} - export class UserPicker extends Component { debouncedSearch: any; diff --git a/public/app/types/index.ts b/public/app/types/index.ts index 8c50ea88782..bd219282f52 100644 --- a/public/app/types/index.ts +++ b/public/app/types/index.ts @@ -8,6 +8,7 @@ import { DashboardAcl, OrgRole, PermissionLevel } from './acl'; import { DataSource } from './datasources'; import { PluginMeta } from './plugins'; import { ApiKey, ApiKeysState } from './apiKeys'; +import { User } from './user'; export { Team, @@ -36,6 +37,7 @@ export { PluginMeta, ApiKey, ApiKeysState, + User, }; export interface StoreState { diff --git a/public/app/types/user.ts b/public/app/types/user.ts new file mode 100644 index 00000000000..9c13e6b027b --- /dev/null +++ b/public/app/types/user.ts @@ -0,0 +1,6 @@ +export interface User { + id: number; + label: string; + avatarUrl: string; + login: string; +} From 97d718f87a07854982d04622eff541a35c5b72eb Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Wed, 26 Sep 2018 13:39:16 +0200 Subject: [PATCH 03/10] Pick up the type from app/types --- public/app/core/components/PermissionList/AddPermission.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/app/core/components/PermissionList/AddPermission.tsx b/public/app/core/components/PermissionList/AddPermission.tsx index 77ac6953b74..fc062ce63e4 100644 --- a/public/app/core/components/PermissionList/AddPermission.tsx +++ b/public/app/core/components/PermissionList/AddPermission.tsx @@ -1,7 +1,8 @@ import React, { Component } from 'react'; -import { UserPicker, User } from 'app/core/components/Picker/UserPicker'; +import { UserPicker } 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 { User } from 'app/types'; import { dashboardPermissionLevels, dashboardAclTargets, From cc0802cc39f2049e2994309e2601bb6fcc46200b Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Wed, 26 Sep 2018 13:40:56 +0200 Subject: [PATCH 04/10] Pick up the type from app/types --- public/app/features/teams/TeamMembers.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/features/teams/TeamMembers.tsx b/public/app/features/teams/TeamMembers.tsx index cda175f4395..588745eea37 100644 --- a/public/app/features/teams/TeamMembers.tsx +++ b/public/app/features/teams/TeamMembers.tsx @@ -1,10 +1,10 @@ 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 { UserPicker } from 'app/core/components/Picker/UserPicker'; import DeleteButton from 'app/core/components/DeleteButton/DeleteButton'; import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; -import { TeamMember } from '../../types'; +import { TeamMember, User } from 'app/types'; import { loadTeamMembers, addTeamMember, removeTeamMember, setSearchMemberQuery } from './state/actions'; import { getSearchMemberQuery, getTeamMembers } from './state/selectors'; From e3d579e410fe6a1f93bad62ca9edf52300e73d47 Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Wed, 26 Sep 2018 13:45:04 +0200 Subject: [PATCH 05/10] Add "search box" and a "add new" box to the new API Keys page #13411 --- public/app/features/api-keys/ApiKeysPage.tsx | 147 ++++++++++++++++-- public/app/features/api-keys/state/actions.ts | 23 ++- .../app/features/api-keys/state/reducers.ts | 7 +- .../app/features/api-keys/state/selectors.ts | 9 ++ public/app/types/apiKeys.ts | 6 + public/app/types/index.ts | 3 +- 6 files changed, 177 insertions(+), 18 deletions(-) create mode 100644 public/app/features/api-keys/state/selectors.ts diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx index e0b4da28c40..5ad292c7ba3 100644 --- a/public/app/features/api-keys/ApiKeysPage.tsx +++ b/public/app/features/api-keys/ApiKeysPage.tsx @@ -1,12 +1,13 @@ import React, { PureComponent } from 'react'; import { connect } from 'react-redux'; import { hot } from 'react-hot-loader'; -import { NavModel, ApiKey } from '../../types'; +import { NavModel, ApiKey, NewApiKey, OrgRole } from 'app/types'; import { getNavModel } from 'app/core/selectors/navModel'; +import { getApiKeys } from './state/selectors'; +import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions'; // import { getSearchQuery, getTeams, getTeamsCount } from './state/selectors'; import PageHeader from 'app/core/components/PageHeader/PageHeader'; -import { loadApiKeys, deleteApiKey } from './state/actions'; -import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; +import SlideDown from 'app/core/components/Animations/SlideDown'; export interface Props { navModel: NavModel; @@ -14,12 +15,31 @@ export interface Props { searchQuery: string; loadApiKeys: typeof loadApiKeys; deleteApiKey: typeof deleteApiKey; - // loadTeams: typeof loadTeams; - // deleteTeam: typeof deleteTeam; - // setSearchQuery: typeof setSearchQuery; + setSearchQuery: typeof setSearchQuery; + addApiKey: typeof addApiKey; } +export interface State { + isAdding: boolean; + newApiKey: NewApiKey; +} + +enum ApiKeyStateProps { + Name = 'name', + Role = 'role', +} + +const initialApiKeyState = { + name: '', + role: OrgRole.Viewer, +}; + export class ApiKeysPage extends PureComponent { + constructor(props) { + super(props); + this.state = { isAdding: false, newApiKey: initialApiKeyState }; + } + componentDidMount() { this.fetchApiKeys(); } @@ -28,19 +48,120 @@ export class ApiKeysPage extends PureComponent { await this.props.loadApiKeys(); } - deleteApiKey(id: number) { + onDeleteApiKey(id: number) { return () => { this.props.deleteApiKey(id); }; } + onSearchQueryChange = evt => { + this.props.setSearchQuery(evt.target.value); + }; + + onToggleAdding = () => { + this.setState({ isAdding: !this.state.isAdding }); + }; + + onAddApiKey = async evt => { + evt.preventDefault(); + this.props.addApiKey(this.state.newApiKey); + this.setState((prevState: State) => { + return { + ...prevState, + newApiKey: initialApiKeyState, + }; + }); + }; + + onApiKeyStateUpdate = (evt, prop: string) => { + const value = evt.currentTarget.value; + this.setState((prevState: State) => { + const newApiKey = { + ...prevState.newApiKey, + }; + newApiKey[prop] = value; + + return { + ...prevState, + newApiKey: newApiKey, + }; + }); + }; + render() { - const { navModel, apiKeys } = this.props; + const { newApiKey, isAdding } = this.state; + const { navModel, apiKeys, searchQuery } = this.props; return (
+
+
+ +
+ +
+ + {/* +
+ + +
+ +
Add API Key
+
+
+
+ Key name + this.onApiKeyStateUpdate(evt, ApiKeyStateProps.Name)} + /> +
+
+ Role + + + +
+
+ +
+
+
+
+
+

Existing Keys

@@ -59,7 +180,7 @@ export class ApiKeysPage extends PureComponent { @@ -78,7 +199,8 @@ export class ApiKeysPage extends PureComponent { function mapStateToProps(state) { return { navModel: getNavModel(state.navIndex, 'apikeys'), - apiKeys: state.apiKeys.keys, + apiKeys: getApiKeys(state.apiKeys), + searchQuery: state.apiKeys.searchQuery, // searchQuery: getSearchQuery(state.teams), }; } @@ -86,9 +208,8 @@ function mapStateToProps(state) { const mapDispatchToProps = { loadApiKeys, deleteApiKey, - // loadTeams, - // deleteTeam, - // setSearchQuery, + setSearchQuery, + addApiKey, }; export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(ApiKeysPage)); diff --git a/public/app/features/api-keys/state/actions.ts b/public/app/features/api-keys/state/actions.ts index 494b562b3c9..934852e1b19 100644 --- a/public/app/features/api-keys/state/actions.ts +++ b/public/app/features/api-keys/state/actions.ts @@ -1,10 +1,10 @@ import { ThunkAction } from 'redux-thunk'; import { getBackendSrv } from 'app/core/services/backend_srv'; import { StoreState, ApiKey } from 'app/types'; -import { updateNavIndex, UpdateNavIndexAction } from 'app/core/actions'; export enum ActionTypes { LoadApiKeys = 'LOAD_API_KEYS', + SetApiKeysSearchQuery = 'SET_API_KEYS_SEARCH_QUERY', } export interface LoadApiKeysAction { @@ -12,15 +12,27 @@ export interface LoadApiKeysAction { payload: ApiKey[]; } -export type Action = LoadApiKeysAction; +export interface SetSearchQueryAction { + type: ActionTypes.SetApiKeysSearchQuery; + payload: string; +} -type ThunkResult = ThunkAction; +export type Action = LoadApiKeysAction | SetSearchQueryAction; + +type ThunkResult = ThunkAction; const apiKeysLoaded = (apiKeys: ApiKey[]): LoadApiKeysAction => ({ type: ActionTypes.LoadApiKeys, payload: apiKeys, }); +export function addApiKey(apiKey: ApiKey): ThunkResult { + return async dispatch => { + await getBackendSrv().post('/api/auth/keys', apiKey); + dispatch(loadApiKeys()); + }; +} + export function loadApiKeys(): ThunkResult { return async dispatch => { const response = await getBackendSrv().get('/api/auth/keys'); @@ -35,3 +47,8 @@ export function deleteApiKey(id: number): ThunkResult { .then(dispatch(loadApiKeys())); }; } + +export const setSearchQuery = (searchQuery: string): SetSearchQueryAction => ({ + type: ActionTypes.SetApiKeysSearchQuery, + payload: searchQuery, +}); diff --git a/public/app/features/api-keys/state/reducers.ts b/public/app/features/api-keys/state/reducers.ts index 6d45ccbfa03..a21aa55dbf7 100644 --- a/public/app/features/api-keys/state/reducers.ts +++ b/public/app/features/api-keys/state/reducers.ts @@ -1,12 +1,17 @@ import { ApiKeysState } from 'app/types'; import { Action, ActionTypes } from './actions'; -export const initialApiKeysState: ApiKeysState = { keys: [] }; +export const initialApiKeysState: ApiKeysState = { + keys: [], + searchQuery: '', +}; export const apiKeysReducer = (state = initialApiKeysState, action: Action): ApiKeysState => { switch (action.type) { case ActionTypes.LoadApiKeys: return { ...state, keys: action.payload }; + case ActionTypes.SetApiKeysSearchQuery: + return { ...state, searchQuery: action.payload }; } return state; }; diff --git a/public/app/features/api-keys/state/selectors.ts b/public/app/features/api-keys/state/selectors.ts new file mode 100644 index 00000000000..8065c252e85 --- /dev/null +++ b/public/app/features/api-keys/state/selectors.ts @@ -0,0 +1,9 @@ +import { ApiKeysState } from 'app/types'; + +export const getApiKeys = (state: ApiKeysState) => { + const regex = RegExp(state.searchQuery, 'i'); + + return state.keys.filter(key => { + return regex.test(key.name) || regex.test(key.role); + }); +}; diff --git a/public/app/types/apiKeys.ts b/public/app/types/apiKeys.ts index 56d3e930504..6288f5165ad 100644 --- a/public/app/types/apiKeys.ts +++ b/public/app/types/apiKeys.ts @@ -6,6 +6,12 @@ export interface ApiKey { role: OrgRole; } +export interface NewApiKey { + name: string; + role: OrgRole; +} + export interface ApiKeysState { keys: ApiKey[]; + searchQuery: string; } diff --git a/public/app/types/index.ts b/public/app/types/index.ts index bd219282f52..42460ecb9c6 100644 --- a/public/app/types/index.ts +++ b/public/app/types/index.ts @@ -7,7 +7,7 @@ import { DashboardState } from './dashboard'; import { DashboardAcl, OrgRole, PermissionLevel } from './acl'; import { DataSource } from './datasources'; import { PluginMeta } from './plugins'; -import { ApiKey, ApiKeysState } from './apiKeys'; +import { ApiKey, ApiKeysState, NewApiKey } from './apiKeys'; import { User } from './user'; export { @@ -37,6 +37,7 @@ export { PluginMeta, ApiKey, ApiKeysState, + NewApiKey, User, }; From 60866d16b1bcf9674e3e22b3fe98d854a2de5267 Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Wed, 26 Sep 2018 14:58:27 +0200 Subject: [PATCH 06/10] Add tests for ApiKeysPage #13411 --- .../features/api-keys/ApiKeysPage.test.tsx | 73 +++ public/app/features/api-keys/ApiKeysPage.tsx | 10 +- .../api-keys/__mocks__/apiKeysMock.ts | 22 + .../__snapshots__/ApiKeysPage.test.tsx.snap | 430 ++++++++++++++++++ .../app/features/teams/__mocks__/teamMocks.ts | 2 +- 5 files changed, 529 insertions(+), 8 deletions(-) create mode 100644 public/app/features/api-keys/ApiKeysPage.test.tsx create mode 100644 public/app/features/api-keys/__mocks__/apiKeysMock.ts create mode 100644 public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap diff --git a/public/app/features/api-keys/ApiKeysPage.test.tsx b/public/app/features/api-keys/ApiKeysPage.test.tsx new file mode 100644 index 00000000000..518180fc424 --- /dev/null +++ b/public/app/features/api-keys/ApiKeysPage.test.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { Props, ApiKeysPage } from './ApiKeysPage'; +import { NavModel, ApiKey } from 'app/types'; +import { getMultipleMockKeys, getMockKey } from './__mocks__/apiKeysMock'; + +const setup = (propOverrides?: object) => { + const props: Props = { + navModel: {} as NavModel, + apiKeys: [] as ApiKey[], + searchQuery: '', + loadApiKeys: jest.fn(), + deleteApiKey: jest.fn(), + setSearchQuery: jest.fn(), + addApiKey: jest.fn(), + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + const instance = wrapper.instance() as ApiKeysPage; + + return { + wrapper, + instance, + }; +}; + +describe('Render', () => { + it('should render component', () => { + const { wrapper } = setup(); + expect(wrapper).toMatchSnapshot(); + }); + + it('should render API keys table', () => { + const { wrapper } = setup({ + apiKeys: getMultipleMockKeys(5), + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); + +describe('Life cycle', () => { + it('should call loadApiKeys', () => { + const { instance } = setup(); + + instance.componentDidMount(); + + expect(instance.props.loadApiKeys).toHaveBeenCalled(); + }); +}); + +describe('Functions', () => { + describe('Delete team', () => { + it('should call delete team', () => { + const { instance } = setup(); + instance.onDeleteApiKey(getMockKey()); + expect(instance.props.deleteApiKey).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/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx index 5ad292c7ba3..25077c59a62 100644 --- a/public/app/features/api-keys/ApiKeysPage.tsx +++ b/public/app/features/api-keys/ApiKeysPage.tsx @@ -48,10 +48,8 @@ export class ApiKeysPage extends PureComponent { await this.props.loadApiKeys(); } - onDeleteApiKey(id: number) { - return () => { - this.props.deleteApiKey(id); - }; + onDeleteApiKey(key: ApiKey) { + this.props.deleteApiKey(key.id); } onSearchQueryChange = evt => { @@ -111,8 +109,6 @@ export class ApiKeysPage extends PureComponent {
- - {/* @@ -180,7 +176,7 @@ export class ApiKeysPage extends PureComponent {
diff --git a/public/app/features/api-keys/__mocks__/apiKeysMock.ts b/public/app/features/api-keys/__mocks__/apiKeysMock.ts new file mode 100644 index 00000000000..117f0d6d0c6 --- /dev/null +++ b/public/app/features/api-keys/__mocks__/apiKeysMock.ts @@ -0,0 +1,22 @@ +import { ApiKey, OrgRole } from 'app/types'; + +export const getMultipleMockKeys = (numberOfKeys: number): ApiKey[] => { + const keys: ApiKey[] = []; + for (let i = 1; i <= numberOfKeys; i++) { + keys.push({ + id: i, + name: `test-${i}`, + role: OrgRole.Viewer, + }); + } + + return keys; +}; + +export const getMockKey = (): ApiKey => { + return { + id: 1, + name: 'test', + role: OrgRole.Admin, + }; +}; diff --git a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap new file mode 100644 index 00000000000..92f27d701d9 --- /dev/null +++ b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap @@ -0,0 +1,430 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render API keys table 1`] = ` +
+ +
+
+
+ +
+
+ +
+ +
+ +
+ Add API Key +
+
+
+
+ + Key name + + +
+
+ + Role + + + + +
+
+ +
+
+ +
+
+

+ Existing Keys +

+
{key.name} {key.role} - + {key.name} {key.role} - +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Name + + Role + +
+ test-1 + + Viewer + + + + +
+ test-2 + + Viewer + + + + +
+ test-3 + + Viewer + + + + +
+ test-4 + + Viewer + + + + +
+ test-5 + + Viewer + + + + +
+
+
+`; + +exports[`Render should render component 1`] = ` +
+ +
+
+
+ +
+
+ +
+ +
+ +
+ Add API Key +
+
+
+
+ + Key name + + +
+
+ + Role + + + + +
+
+ +
+
+
+
+
+

+ Existing Keys +

+ + + + + + + +
+ Name + + Role + +
+
+
+`; diff --git a/public/app/features/teams/__mocks__/teamMocks.ts b/public/app/features/teams/__mocks__/teamMocks.ts index 34fa06b2d09..339f227c081 100644 --- a/public/app/features/teams/__mocks__/teamMocks.ts +++ b/public/app/features/teams/__mocks__/teamMocks.ts @@ -1,4 +1,4 @@ -import { Team, TeamGroup, TeamMember } from '../../../types'; +import { Team, TeamGroup, TeamMember } from 'app/types'; export const getMultipleMockTeams = (numberOfTeams: number): Team[] => { const teams: Team[] = []; From 32fb24f248180c8e3d6b05783c59f00080ded9e8 Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Wed, 26 Sep 2018 15:03:22 +0200 Subject: [PATCH 07/10] Update test-snapshot, remove dead code #13411 --- public/app/features/api-keys/ApiKeysPage.tsx | 3 +-- .../api-keys/__snapshots__/ApiKeysPage.test.tsx.snap | 5 +++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx index 25077c59a62..b2aefcb1fa0 100644 --- a/public/app/features/api-keys/ApiKeysPage.tsx +++ b/public/app/features/api-keys/ApiKeysPage.tsx @@ -176,7 +176,7 @@ export class ApiKeysPage extends PureComponent { {key.name} {key.role} - + this.onDeleteApiKey(key)} className="btn btn-danger btn-mini"> @@ -197,7 +197,6 @@ function mapStateToProps(state) { navModel: getNavModel(state.navIndex, 'apikeys'), apiKeys: getApiKeys(state.apiKeys), searchQuery: state.apiKeys.searchQuery, - // searchQuery: getSearchQuery(state.teams), }; } diff --git a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap index 92f27d701d9..77c7f620173 100644 --- a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap +++ b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap @@ -174,6 +174,7 @@ exports[`Render should render API keys table 1`] = ` Date: Thu, 27 Sep 2018 09:31:05 +0200 Subject: [PATCH 08/10] Add tests for the reducers & selectors for API keys #13411 --- public/app/features/api-keys/ApiKeysPage.tsx | 1 - .../features/api-keys/state/reducers.test.ts | 31 +++++++++++++++++++ .../features/api-keys/state/selectors.test.ts | 25 +++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 public/app/features/api-keys/state/reducers.test.ts create mode 100644 public/app/features/api-keys/state/selectors.test.ts diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx index b2aefcb1fa0..3225fd7d2b5 100644 --- a/public/app/features/api-keys/ApiKeysPage.tsx +++ b/public/app/features/api-keys/ApiKeysPage.tsx @@ -170,7 +170,6 @@ export class ApiKeysPage extends PureComponent { {apiKeys.length > 0 ? ( {apiKeys.map(key => { - // id, name, role return ( {key.name} diff --git a/public/app/features/api-keys/state/reducers.test.ts b/public/app/features/api-keys/state/reducers.test.ts new file mode 100644 index 00000000000..3b2c831a5a3 --- /dev/null +++ b/public/app/features/api-keys/state/reducers.test.ts @@ -0,0 +1,31 @@ +import { Action, ActionTypes } from './actions'; +import { initialApiKeysState, apiKeysReducer } from './reducers'; +import { getMultipleMockKeys } from '../__mocks__/apiKeysMock'; + +describe('API Keys reducer', () => { + it('should set keys', () => { + const payload = getMultipleMockKeys(4); + + const action: Action = { + type: ActionTypes.LoadApiKeys, + payload, + }; + + const result = apiKeysReducer(initialApiKeysState, action); + + expect(result.keys).toEqual(payload); + }); + + it('should set search query', () => { + const payload = 'test query'; + + const action: Action = { + type: ActionTypes.SetApiKeysSearchQuery, + payload, + }; + + const result = apiKeysReducer(initialApiKeysState, action); + + expect(result.searchQuery).toEqual('test query'); + }); +}); diff --git a/public/app/features/api-keys/state/selectors.test.ts b/public/app/features/api-keys/state/selectors.test.ts new file mode 100644 index 00000000000..7d8f3122ce6 --- /dev/null +++ b/public/app/features/api-keys/state/selectors.test.ts @@ -0,0 +1,25 @@ +import { getApiKeys } from './selectors'; +import { getMultipleMockKeys } from '../__mocks__/apiKeysMock'; +import { ApiKeysState } from 'app/types'; + +describe('API Keys selectors', () => { + describe('Get API Keys', () => { + const mockKeys = getMultipleMockKeys(5); + + it('should return all keys if no search query', () => { + const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '' }; + + const keys = getApiKeys(mockState); + + expect(keys).toEqual(mockKeys); + }); + + it('should filter keys if search query exists', () => { + const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5' }; + + const keys = getApiKeys(mockState); + + expect(keys.length).toEqual(1); + }); + }); +}); From c7fb6916b9292420a1589c595be1e184e0a0699b Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Thu, 27 Sep 2018 11:26:47 +0200 Subject: [PATCH 09/10] Open modal with API key information after key is added #13411 --- .../api-keys/ApiKeysAddedModal.test.tsx | 25 ++++++ .../features/api-keys/ApiKeysAddedModal.tsx | 46 +++++++++++ public/app/features/api-keys/ApiKeysPage.tsx | 17 +++- .../ApiKeysAddedModal.test.tsx.snap | 78 +++++++++++++++++++ public/app/features/api-keys/state/actions.ts | 6 +- 5 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 public/app/features/api-keys/ApiKeysAddedModal.test.tsx create mode 100644 public/app/features/api-keys/ApiKeysAddedModal.tsx create mode 100644 public/app/features/api-keys/__snapshots__/ApiKeysAddedModal.test.tsx.snap diff --git a/public/app/features/api-keys/ApiKeysAddedModal.test.tsx b/public/app/features/api-keys/ApiKeysAddedModal.test.tsx new file mode 100644 index 00000000000..160418a7ab8 --- /dev/null +++ b/public/app/features/api-keys/ApiKeysAddedModal.test.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ApiKeysAddedModal, Props } from './ApiKeysAddedModal'; + +const setup = (propOverrides?: object) => { + const props: Props = { + apiKey: 'api key test', + rootPath: 'test/path', + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + + return { + wrapper, + }; +}; + +describe('Render', () => { + it('should render component', () => { + const { wrapper } = setup(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/api-keys/ApiKeysAddedModal.tsx b/public/app/features/api-keys/ApiKeysAddedModal.tsx new file mode 100644 index 00000000000..995aa46c773 --- /dev/null +++ b/public/app/features/api-keys/ApiKeysAddedModal.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +export interface Props { + apiKey: string; + rootPath: string; +} + +export const ApiKeysAddedModal = (props: Props) => { + return ( + + ); +}; + +export default ApiKeysAddedModal; diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx index 3225fd7d2b5..2f19250e835 100644 --- a/public/app/features/api-keys/ApiKeysPage.tsx +++ b/public/app/features/api-keys/ApiKeysPage.tsx @@ -1,13 +1,16 @@ import React, { PureComponent } from 'react'; +import ReactDOMServer from 'react-dom/server'; import { connect } from 'react-redux'; import { hot } from 'react-hot-loader'; import { NavModel, ApiKey, NewApiKey, OrgRole } from 'app/types'; import { getNavModel } from 'app/core/selectors/navModel'; import { getApiKeys } from './state/selectors'; import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions'; -// import { getSearchQuery, getTeams, getTeamsCount } from './state/selectors'; import PageHeader from 'app/core/components/PageHeader/PageHeader'; import SlideDown from 'app/core/components/Animations/SlideDown'; +import ApiKeysAddedModal from './ApiKeysAddedModal'; +import config from 'app/core/config'; +import appEvents from 'app/core/app_events'; export interface Props { navModel: NavModel; @@ -62,7 +65,17 @@ export class ApiKeysPage extends PureComponent { onAddApiKey = async evt => { evt.preventDefault(); - this.props.addApiKey(this.state.newApiKey); + + const openModal = (apiKey: string) => { + const rootPath = window.location.origin + config.appSubUrl; + const modalTemplate = ReactDOMServer.renderToString(); + + appEvents.emit('show-modal', { + templateHtml: modalTemplate, + }); + }; + + this.props.addApiKey(this.state.newApiKey, openModal); this.setState((prevState: State) => { return { ...prevState, diff --git a/public/app/features/api-keys/__snapshots__/ApiKeysAddedModal.test.tsx.snap b/public/app/features/api-keys/__snapshots__/ApiKeysAddedModal.test.tsx.snap new file mode 100644 index 00000000000..0fcb13308eb --- /dev/null +++ b/public/app/features/api-keys/__snapshots__/ApiKeysAddedModal.test.tsx.snap @@ -0,0 +1,78 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
+
+

+ + + API Key Created + +

+ + + +
+
+
+
+ + Key + + + api key test + +
+
+
+ You will only be able to view this key here once! It is not stored in this form. So be sure to copy it now. +
+
+ You can authenticate request using the Authorization HTTP header, example: +
+
+
+        curl -H "Authorization: Bearer 
+        api key test
+        " 
+        test/path
+        /api/dashboards/home
+      
+
+
+
+`; diff --git a/public/app/features/api-keys/state/actions.ts b/public/app/features/api-keys/state/actions.ts index 934852e1b19..63e91088476 100644 --- a/public/app/features/api-keys/state/actions.ts +++ b/public/app/features/api-keys/state/actions.ts @@ -26,10 +26,12 @@ const apiKeysLoaded = (apiKeys: ApiKey[]): LoadApiKeysAction => ({ payload: apiKeys, }); -export function addApiKey(apiKey: ApiKey): ThunkResult { +export function addApiKey(apiKey: ApiKey, openModal: (key: string) => void): ThunkResult { return async dispatch => { - await getBackendSrv().post('/api/auth/keys', apiKey); + const result = await getBackendSrv().post('/api/auth/keys', apiKey); + dispatch(setSearchQuery('')); dispatch(loadApiKeys()); + openModal(result.key); }; } From 362010c43816c426e97534a50f1bcbda44f737f5 Mon Sep 17 00:00:00 2001 From: Johannes Schill Date: Thu, 27 Sep 2018 11:34:28 +0200 Subject: [PATCH 10/10] Remove angular code related to API Keys and point the route to the React component #13411 --- public/app/features/org/all.ts | 1 - public/app/features/org/org_api_keys_ctrl.ts | 44 ----------------- .../features/org/partials/apikeyModal.html | 37 -------------- .../app/features/org/partials/orgApiKeys.html | 49 ------------------- public/app/routes/routes.ts | 4 -- 5 files changed, 135 deletions(-) delete mode 100644 public/app/features/org/org_api_keys_ctrl.ts delete mode 100644 public/app/features/org/partials/apikeyModal.html delete mode 100644 public/app/features/org/partials/orgApiKeys.html diff --git a/public/app/features/org/all.ts b/public/app/features/org/all.ts index 8872450e3ab..1cbca483138 100644 --- a/public/app/features/org/all.ts +++ b/public/app/features/org/all.ts @@ -6,6 +6,5 @@ import './change_password_ctrl'; import './new_org_ctrl'; import './user_invite_ctrl'; import './create_team_ctrl'; -import './org_api_keys_ctrl'; import './org_details_ctrl'; import './prefs_control'; diff --git a/public/app/features/org/org_api_keys_ctrl.ts b/public/app/features/org/org_api_keys_ctrl.ts deleted file mode 100644 index 1ead0a350b9..00000000000 --- a/public/app/features/org/org_api_keys_ctrl.ts +++ /dev/null @@ -1,44 +0,0 @@ -import angular from 'angular'; - -export class OrgApiKeysCtrl { - /** @ngInject */ - constructor($scope, $http, backendSrv, navModelSrv) { - $scope.navModel = navModelSrv.getNav('cfg', 'apikeys', 0); - - $scope.roleTypes = ['Viewer', 'Editor', 'Admin']; - $scope.token = { role: 'Viewer' }; - - $scope.init = () => { - $scope.getTokens(); - }; - - $scope.getTokens = () => { - backendSrv.get('/api/auth/keys').then(tokens => { - $scope.tokens = tokens; - }); - }; - - $scope.removeToken = id => { - backendSrv.delete('/api/auth/keys/' + id).then($scope.getTokens); - }; - - $scope.addToken = () => { - backendSrv.post('/api/auth/keys', $scope.token).then(result => { - const modalScope = $scope.$new(true); - modalScope.key = result.key; - modalScope.rootPath = window.location.origin + $scope.$root.appSubUrl; - - $scope.appEvent('show-modal', { - src: 'public/app/features/org/partials/apikeyModal.html', - scope: modalScope, - }); - - $scope.getTokens(); - }); - }; - - $scope.init(); - } -} - -angular.module('grafana.controllers').controller('OrgApiKeysCtrl', OrgApiKeysCtrl); diff --git a/public/app/features/org/partials/apikeyModal.html b/public/app/features/org/partials/apikeyModal.html deleted file mode 100644 index eeefcafc634..00000000000 --- a/public/app/features/org/partials/apikeyModal.html +++ /dev/null @@ -1,37 +0,0 @@ - - diff --git a/public/app/features/org/partials/orgApiKeys.html b/public/app/features/org/partials/orgApiKeys.html deleted file mode 100644 index a2b4ceb6670..00000000000 --- a/public/app/features/org/partials/orgApiKeys.html +++ /dev/null @@ -1,49 +0,0 @@ - - -
- -

Add new

- -
-
-
- Key name - -
-
- Role - - - -
-
- -
-
-
- -

Existing Keys

- - - - - - - - - - - - - - - -
NameRole
{{t.name}}{{t.role}} - - - -
-
- - - diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index 9b90e374769..470153f5dd1 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -139,10 +139,6 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { controllerAs: 'ctrl', }) .when('/org/apikeys', { - templateUrl: 'public/app/features/org/partials/orgApiKeys.html', - controller: 'OrgApiKeysCtrl', - }) - .when('/org/apikeys2', { template: '', resolve: { roles: () => ['Editor', 'Admin'],
+ + +
+
+
+ Key + {props.apiKey} +
+
+ +
+ You will only be able to view this key here once! It is not stored in this form. So be sure to copy it now. +
+
+ You can authenticate request using the Authorization HTTP header, example: +
+
+
+            curl -H "Authorization: Bearer {props.apiKey}" {props.rootPath}/api/dashboards/home
+          
+
+
+