diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index a261cc18ea9..55f3641a015 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -178,4 +178,7 @@ export const Components = { dropDown: 'Dashboard link dropdown', link: 'Dashboard link', }, + CallToActionCard: { + button: (name: string) => `Call to action button ${name}`, + }, }; diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 15ec6237c16..ee74bd4fd48 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -54,12 +54,12 @@ export const Pages = { }, Annotations: { List: { - addAnnotationCTA: 'Call to action button Add Annotation Query', + addAnnotationCTA: Components.CallToActionCard.button('Add Annotation Query'), }, }, Variables: { List: { - addVariableCTA: 'Call to action button Add variable', + addVariableCTA: Components.CallToActionCard.button('Add variable'), newButton: 'Variable editor New variable button', table: 'Variable editor Table', tableRowNameFields: (variableName: string) => `Variable editor Table Name field ${variableName}`, diff --git a/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx b/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx index c022cdb1eb4..cb4fa175b12 100644 --- a/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx +++ b/public/app/core/components/EmptyListCTA/EmptyListCTA.tsx @@ -1,6 +1,7 @@ import React, { MouseEvent, useContext } from 'react'; -import { CallToActionCard, LinkButton, ThemeContext, Icon, IconName } from '@grafana/ui'; import { css } from 'emotion'; +import { CallToActionCard, Icon, IconName, LinkButton, ThemeContext } from '@grafana/ui'; +import { selectors } from '@grafana/e2e-selectors'; export interface Props { title: string; @@ -79,7 +80,7 @@ const EmptyListCTA: React.FunctionComponent = ({ href={buttonLink} icon={buttonIcon} className={ctaElementClassName} - aria-label={`Call to action button ${buttonTitle}`} + aria-label={selectors.components.CallToActionCard.button(buttonTitle)} > {buttonTitle} diff --git a/public/app/features/api-keys/ApiKeysActionBar.tsx b/public/app/features/api-keys/ApiKeysActionBar.tsx new file mode 100644 index 00000000000..706470c75f6 --- /dev/null +++ b/public/app/features/api-keys/ApiKeysActionBar.tsx @@ -0,0 +1,31 @@ +import React, { FC } from 'react'; + +import { FilterInput } from '../../core/components/FilterInput/FilterInput'; + +interface Props { + searchQuery: string; + disabled: boolean; + onAddClick: () => void; + onSearchChange: (value: string) => void; +} + +export const ApiKeysActionBar: FC = ({ searchQuery, disabled, onAddClick, onSearchChange }) => { + return ( +
+
+ +
+ +
+ +
+ ); +}; diff --git a/public/app/features/api-keys/ApiKeysController.tsx b/public/app/features/api-keys/ApiKeysController.tsx new file mode 100644 index 00000000000..69ac05514cb --- /dev/null +++ b/public/app/features/api-keys/ApiKeysController.tsx @@ -0,0 +1,19 @@ +import { FC, useCallback, useState } from 'react'; + +interface Api { + isAdding: boolean; + toggleIsAdding: () => void; +} + +interface Props { + children: (props: Api) => JSX.Element; +} + +export const ApiKeysController: FC = ({ children }) => { + const [isAdding, setIsAdding] = useState(false); + const toggleIsAdding = useCallback(() => { + setIsAdding(!isAdding); + }, [isAdding]); + + return children({ isAdding, toggleIsAdding }); +}; diff --git a/public/app/features/api-keys/ApiKeysForm.tsx b/public/app/features/api-keys/ApiKeysForm.tsx new file mode 100644 index 00000000000..ae60d38d83e --- /dev/null +++ b/public/app/features/api-keys/ApiKeysForm.tsx @@ -0,0 +1,111 @@ +import React, { ChangeEvent, FC, FormEvent, useEffect, useState } from 'react'; +import { EventsWithValidation, Icon, InlineFormLabel, LegacyForms, ValidationEvents } from '@grafana/ui'; +import { NewApiKey, OrgRole } from '../../types'; +import { rangeUtil } from '@grafana/data'; +import { SlideDown } from '../../core/components/Animations/SlideDown'; + +const { Input } = LegacyForms; + +interface Props { + show: boolean; + onClose: () => void; + onKeyAdded: (apiKey: NewApiKey) => void; +} + +function isValidInterval(value: string): boolean { + if (!value) { + return true; + } + try { + rangeUtil.intervalToSeconds(value); + return true; + } catch {} + return false; +} + +const timeRangeValidationEvents: ValidationEvents = { + [EventsWithValidation.onBlur]: [ + { + rule: isValidInterval, + errorMessage: 'Not a valid duration', + }, + ], +}; + +const tooltipText = + 'The api key life duration. For example 1d if your key is going to last for one day. All the supported units are: s,m,h,d,w,M,y'; + +export const ApiKeysForm: FC = ({ show, onClose, onKeyAdded }) => { + const [name, setName] = useState(''); + const [role, setRole] = useState(OrgRole.Viewer); + const [secondsToLive, setSecondsToLive] = useState(''); + useEffect(() => { + setName(''); + setRole(OrgRole.Viewer); + setSecondsToLive(''); + }, [show]); + + const onSubmit = (event: FormEvent) => { + event.preventDefault(); + if (isValidInterval(secondsToLive)) { + onKeyAdded({ name, role, secondsToLive }); + onClose(); + } + }; + const onNameChange = (event: ChangeEvent) => { + setName(event.currentTarget.value); + }; + const onRoleChange = (event: ChangeEvent) => { + setRole(event.currentTarget.value as OrgRole); + }; + const onSecondsToLiveChange = (event: ChangeEvent) => { + setSecondsToLive(event.currentTarget.value); + }; + + return ( + +
+ +
+
Add API Key
+
+
+ Key name + +
+
+ Role + + + +
+
+ Time to live + +
+
+ +
+
+
+
+
+ ); +}; diff --git a/public/app/features/api-keys/ApiKeysPage.test.tsx b/public/app/features/api-keys/ApiKeysPage.test.tsx index 47e06af8dd2..9aa088f95d0 100644 --- a/public/app/features/api-keys/ApiKeysPage.test.tsx +++ b/public/app/features/api-keys/ApiKeysPage.test.tsx @@ -1,13 +1,20 @@ import React from 'react'; -import { shallow } from 'enzyme'; -import { ApiKeysPage, Props } from './ApiKeysPage'; -import { ApiKey } from 'app/types'; -import { getMockKey, getMultipleMockKeys } from './__mocks__/apiKeysMock'; +import { render, screen, within } from '@testing-library/react'; +import { ApiKeysPageUnconnected, Props } from './ApiKeysPage'; +import { ApiKey, OrgRole } from 'app/types'; import { NavModel } from '@grafana/data'; import { setSearchQuery } from './state/reducers'; import { mockToolkitActionCreator } from '../../../test/core/redux/mocks'; +import { getMultipleMockKeys } from './__mocks__/apiKeysMock'; +import { selectors } from '@grafana/e2e-selectors'; +import userEvent from '@testing-library/user-event'; +import { silenceConsoleOutput } from '../../../test/core/utils/silenceConsoleOutput'; -const setup = (propOverrides?: object) => { +const setup = (propOverrides: Partial) => { + const loadApiKeysMock = jest.fn(); + const deleteApiKeyMock = jest.fn(); + const addApiKeyMock = jest.fn(); + const setSearchQueryMock = mockToolkitActionCreator(setSearchQuery); const props: Props = { navModel: { main: { @@ -20,72 +27,184 @@ const setup = (propOverrides?: object) => { apiKeys: [] as ApiKey[], searchQuery: '', hasFetched: false, - loadApiKeys: jest.fn(), - deleteApiKey: jest.fn(), - setSearchQuery: mockToolkitActionCreator(setSearchQuery), - addApiKey: jest.fn(), + loadApiKeys: loadApiKeysMock, + deleteApiKey: deleteApiKeyMock, + setSearchQuery: setSearchQueryMock, + addApiKey: addApiKeyMock, apiKeysCount: 0, - includeExpired: false, + timeZone: 'utc', }; Object.assign(props, propOverrides); - const wrapper = shallow(); - const instance = wrapper.instance() as ApiKeysPage; - - return { - wrapper, - instance, - }; + const { rerender } = render(); + return { rerender, props, loadApiKeysMock, setSearchQueryMock, deleteApiKeyMock, addApiKeyMock }; }; -describe('Render', () => { - it('should render API keys table if there are any keys', () => { - const { wrapper } = setup({ - apiKeys: getMultipleMockKeys(5), - apiKeysCount: 5, - }); - - expect(wrapper).toMatchSnapshot(); - }); - - it('should render CTA if there are no API keys', () => { - const { wrapper } = setup({ - apiKeys: getMultipleMockKeys(0), - apiKeysCount: 0, - hasFetched: true, - }); - - expect(wrapper).toMatchSnapshot(); - }); -}); - -describe('Life cycle', () => { - it('should call loadApiKeys', () => { - const { instance } = setup(); - - instance.componentDidMount(); - - expect(instance.props.loadApiKeys).toHaveBeenCalledWith(false); - }); -}); - -describe('Functions', () => { - describe('Delete team', () => { - it('should call delete team', () => { - const { instance } = setup(); - instance.onDeleteApiKey(getMockKey()); - expect(instance.props.deleteApiKey).toHaveBeenCalledWith(1, false); +describe('ApiKeysPage', () => { + silenceConsoleOutput(); + describe('when mounted', () => { + it('then it should call loadApiKeys without expired', () => { + const { loadApiKeysMock } = setup({}); + expect(loadApiKeysMock).toHaveBeenCalledTimes(1); + expect(loadApiKeysMock).toHaveBeenCalledWith(false); }); }); - describe('on search query change', () => { - it('should call setSearchQuery', () => { - const { instance } = setup(); + describe('when loading', () => { + it('then should show Loading message', () => { + setup({ hasFetched: false }); + expect(screen.getByText(/loading \.\.\./i)).toBeInTheDocument(); + }); + }); - instance.onSearchQueryChange('test'); + describe('when there are no API keys', () => { + it('then it should render CTA', () => { + setup({ apiKeys: getMultipleMockKeys(0), apiKeysCount: 0, hasFetched: true }); + expect(screen.getByLabelText(selectors.components.CallToActionCard.button('New API Key'))).toBeInTheDocument(); + }); + }); - expect(instance.props.setSearchQuery).toHaveBeenCalledWith('test'); + describe('when there are API keys', () => { + it('then it should render API keys table', async () => { + const apiKeys = [ + { id: 1, name: 'First', role: OrgRole.Admin, secondsToLive: 60, expiration: '2021-01-01' }, + { id: 2, name: 'Second', role: OrgRole.Editor, secondsToLive: 60, expiration: '2021-01-02' }, + { id: 3, name: 'Third', role: OrgRole.Viewer, secondsToLive: 0, expiration: undefined }, + ]; + setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.getAllByRole('row').length).toBe(4); + expect(screen.getByRole('row', { name: /first admin 2021-01-01 00:00:00 cancel delete/i })).toBeInTheDocument(); + expect(screen.getByRole('row', { name: /second editor 2021-01-02 00:00:00 cancel delete/i })).toBeInTheDocument(); + expect(screen.getByRole('row', { name: /third viewer no expiration date cancel delete/i })).toBeInTheDocument(); + }); + }); + + describe('when a user toggles the Show expired toggle', () => { + it('then it should call loadApiKeys with correct parameters', async () => { + const apiKeys = getMultipleMockKeys(3); + const { loadApiKeysMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + + loadApiKeysMock.mockClear(); + toggleShowExpired(); + expect(loadApiKeysMock).toHaveBeenCalledTimes(1); + expect(loadApiKeysMock).toHaveBeenCalledWith(true); + + loadApiKeysMock.mockClear(); + toggleShowExpired(); + expect(loadApiKeysMock).toHaveBeenCalledTimes(1); + expect(loadApiKeysMock).toHaveBeenCalledWith(false); + }); + }); + + describe('when a user searches for an api key', () => { + it('then it should dispatch setSearchQuery with correct parameters', async () => { + const apiKeys = getMultipleMockKeys(3); + const { setSearchQueryMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + + setSearchQueryMock.mockClear(); + expect(screen.getByPlaceholderText(/search keys/i)).toBeInTheDocument(); + await userEvent.type(screen.getByPlaceholderText(/search keys/i), 'First'); + expect(setSearchQueryMock).toHaveBeenCalledTimes(5); + }); + }); + + describe('when a user deletes an api key', () => { + it('then it should dispatch deleteApi with correct parameters', async () => { + const apiKeys = [ + { id: 1, name: 'First', role: OrgRole.Admin, secondsToLive: 60, expiration: '2021-01-01' }, + { id: 2, name: 'Second', role: OrgRole.Editor, secondsToLive: 60, expiration: '2021-01-02' }, + { id: 3, name: 'Third', role: OrgRole.Viewer, secondsToLive: 0, expiration: undefined }, + ]; + const { deleteApiKeyMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + const firstRow = screen.getByRole('row', { name: /first admin 2021-01-01 00:00:00 cancel delete/i }); + const secondRow = screen.getByRole('row', { name: /second editor 2021-01-02 00:00:00 cancel delete/i }); + + deleteApiKeyMock.mockClear(); + expect(within(firstRow).getByRole('cell', { name: /cancel delete/i })).toBeInTheDocument(); + userEvent.click(within(firstRow).getByRole('cell', { name: /cancel delete/i })); + expect(within(firstRow).getByRole('button', { name: /delete/i })).toBeInTheDocument(); + userEvent.click(within(firstRow).getByRole('button', { name: /delete/i })); + expect(deleteApiKeyMock).toHaveBeenCalledTimes(1); + expect(deleteApiKeyMock).toHaveBeenCalledWith(1, false); + + toggleShowExpired(); + + deleteApiKeyMock.mockClear(); + expect(within(secondRow).getByRole('cell', { name: /cancel delete/i })).toBeInTheDocument(); + userEvent.click(within(secondRow).getByRole('cell', { name: /cancel delete/i })); + expect(within(secondRow).getByRole('button', { name: /delete/i })).toBeInTheDocument(); + userEvent.click(within(secondRow).getByRole('button', { name: /delete/i })); + expect(deleteApiKeyMock).toHaveBeenCalledTimes(1); + expect(deleteApiKeyMock).toHaveBeenCalledWith(2, true); + }); + }); + + describe('when a user adds an api key from CTA', () => { + it('then it should call addApiKey with correct parameters', async () => { + const apiKeys: any[] = []; + const { addApiKeyMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + + addApiKeyMock.mockClear(); + userEvent.click(screen.getByLabelText(selectors.components.CallToActionCard.button('New API Key'))); + await addAndVerifyApiKey(addApiKeyMock, false); + }); + }); + + describe('when a user adds an api key from Add Api Key', () => { + it('then it should call addApiKey with correct parameters', async () => { + const apiKeys = getMultipleMockKeys(1); + const { addApiKeyMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + + addApiKeyMock.mockClear(); + userEvent.click(screen.getByRole('button', { name: /add api key/i })); + await addAndVerifyApiKey(addApiKeyMock, false); + + toggleShowExpired(); + + addApiKeyMock.mockClear(); + userEvent.click(screen.getByRole('button', { name: /add api key/i })); + await addAndVerifyApiKey(addApiKeyMock, true); + }); + }); + + describe('when a user adds an api key with an invalid expiration', () => { + it('then it should display a message', async () => { + const apiKeys = getMultipleMockKeys(1); + const { addApiKeyMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); + + addApiKeyMock.mockClear(); + userEvent.click(screen.getByRole('button', { name: /add api key/i })); + await userEvent.type(screen.getByPlaceholderText(/name/i), 'Test'); + await userEvent.type(screen.getByPlaceholderText(/1d/i), '60x'); + expect(screen.queryByText(/not a valid duration/i)).not.toBeInTheDocument(); + userEvent.click(screen.getByRole('button', { name: /^add$/i })); + expect(screen.getByText(/not a valid duration/i)).toBeInTheDocument(); + expect(addApiKeyMock).toHaveBeenCalledTimes(0); }); }); }); + +function toggleShowExpired() { + expect(screen.getByText(/show expired/i)).toBeInTheDocument(); + userEvent.click(screen.getByText(/show expired/i)); +} + +async function addAndVerifyApiKey(addApiKeyMock: jest.Mock, includeExpired: boolean) { + expect(screen.getByRole('heading', { name: /add api key/i })).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/name/i)).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/1d/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^add$/i })).toBeInTheDocument(); + + await userEvent.type(screen.getByPlaceholderText(/name/i), 'Test'); + await userEvent.type(screen.getByPlaceholderText(/1d/i), '60s'); + userEvent.click(screen.getByRole('button', { name: /^add$/i })); + expect(addApiKeyMock).toHaveBeenCalledTimes(1); + expect(addApiKeyMock).toHaveBeenCalledWith( + { name: 'Test', role: 'Viewer', secondsToLive: 60 }, + expect.anything(), + includeExpired + ); +} diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx index 905d7e0a883..04156869649 100644 --- a/public/app/features/api-keys/ApiKeysPage.tsx +++ b/public/app/features/api-keys/ApiKeysPage.tsx @@ -1,321 +1,36 @@ import React, { PureComponent } from 'react'; import ReactDOMServer from 'react-dom/server'; -import { connect } from 'react-redux'; +import { connect, ConnectedProps } from 'react-redux'; import { hot } from 'react-hot-loader'; // Utils -import { ApiKey, CoreEvents, NewApiKey, OrgRole } from 'app/types'; +import { ApiKey, CoreEvents, NewApiKey, StoreState } from 'app/types'; import { getNavModel } from 'app/core/selectors/navModel'; import { getApiKeys, getApiKeysCount } from './state/selectors'; import { addApiKey, deleteApiKey, loadApiKeys } from './state/actions'; import Page from 'app/core/components/Page/Page'; -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'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; -import { DeleteButton, EventsWithValidation, InlineFormLabel, LegacyForms, ValidationEvents, Icon } from '@grafana/ui'; -const { Input, Switch } = LegacyForms; -import { NavModel, dateTimeFormat, rangeUtil } from '@grafana/data'; -import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; -import { store } from 'app/store/store'; +import { LegacyForms } from '@grafana/ui'; +import { rangeUtil } from '@grafana/data'; import { getTimeZone } from 'app/features/profile/state/selectors'; import { setSearchQuery } from './state/reducers'; +import { ApiKeysForm } from './ApiKeysForm'; +import { ApiKeysActionBar } from './ApiKeysActionBar'; +import { ApiKeysTable } from './ApiKeysTable'; +import { ApiKeysController } from './ApiKeysController'; -const timeRangeValidationEvents: ValidationEvents = { - [EventsWithValidation.onBlur]: [ - { - rule: (value) => { - if (!value) { - return true; - } - try { - rangeUtil.intervalToSeconds(value); - return true; - } catch { - return false; - } - }, - errorMessage: 'Not a valid duration', - }, - ], -}; +const { Switch } = LegacyForms; -export interface Props { - navModel: NavModel; - apiKeys: ApiKey[]; - searchQuery: string; - hasFetched: boolean; - loadApiKeys: typeof loadApiKeys; - deleteApiKey: typeof deleteApiKey; - setSearchQuery: typeof setSearchQuery; - addApiKey: typeof addApiKey; - apiKeysCount: number; - includeExpired: boolean; -} - -export interface State { - isAdding: boolean; - newApiKey: NewApiKey; -} - -enum ApiKeyStateProps { - Name = 'name', - Role = 'role', - SecondsToLive = 'secondsToLive', -} - -const initialApiKeyState = { - name: '', - role: OrgRole.Viewer, - secondsToLive: '', -}; - -const tooltipText = - 'The api key life duration. For example 1d if your key is going to last for one day. All the supported units are: s,m,h,d,w,M,y'; - -export class ApiKeysPage extends PureComponent { - constructor(props: Props) { - super(props); - this.state = { isAdding: false, newApiKey: initialApiKeyState, includeExpired: false }; - } - - componentDidMount() { - this.fetchApiKeys(); - } - - async fetchApiKeys() { - await this.props.loadApiKeys(this.state.includeExpired); - } - - onDeleteApiKey(key: ApiKey) { - this.props.deleteApiKey(key.id, this.props.includeExpired); - } - - onSearchQueryChange = (value: string) => { - this.props.setSearchQuery(value); - }; - - onIncludeExpiredChange = (value: boolean) => { - this.setState({ hasFetched: false, includeExpired: value }, this.fetchApiKeys); - }; - - onToggleAdding = () => { - this.setState({ isAdding: !this.state.isAdding }); - }; - - onAddApiKey = async (evt: any) => { - evt.preventDefault(); - - const openModal = (apiKey: string) => { - const rootPath = window.location.origin + config.appSubUrl; - const modalTemplate = ReactDOMServer.renderToString(); - - appEvents.emit(CoreEvents.showModal, { - templateHtml: modalTemplate, - }); - }; - - // make sure that secondsToLive is number or null - const secondsToLive = this.state.newApiKey['secondsToLive']; - this.state.newApiKey['secondsToLive'] = secondsToLive ? rangeUtil.intervalToSeconds(secondsToLive) : null; - this.props.addApiKey(this.state.newApiKey, openModal, this.props.includeExpired); - this.setState((prevState: State) => { - return { - ...prevState, - newApiKey: initialApiKeyState, - isAdding: false, - }; - }); - }; - - onApiKeyStateUpdate = (evt: any, prop: string) => { - const value = evt.currentTarget.value; - this.setState((prevState: State) => { - const newApiKey: any = { - ...prevState.newApiKey, - }; - newApiKey[prop] = value; - - return { - ...prevState, - newApiKey: newApiKey, - }; - }); - }; - - renderEmptyList() { - const { isAdding } = this.state; - return ( - <> - {!isAdding && ( - - )} - {this.renderAddApiKeyForm()} - - ); - } - - formatDate(date: any, format?: string) { - if (!date) { - return 'No expiration date'; - } - const timeZone = getTimeZone(store.getState().user); - return dateTimeFormat(date, { format, timeZone }); - } - - renderAddApiKeyForm() { - const { newApiKey, isAdding } = this.state; - - return ( - -
- -
-
Add API Key
-
-
- Key name - this.onApiKeyStateUpdate(evt, ApiKeyStateProps.Name)} - /> -
-
- Role - - - -
-
- Time to live - this.onApiKeyStateUpdate(evt, ApiKeyStateProps.SecondsToLive)} - /> -
-
- -
-
-
-
-
- ); - } - - renderApiKeyList() { - const { isAdding } = this.state; - const { apiKeys, searchQuery, includeExpired } = this.props; - - return ( - <> -
-
- -
- -
- -
- - {this.renderAddApiKeyForm()} - -

Existing Keys

- { - // @ts-ignore - this.onIncludeExpiredChange(event.target.checked); - }} - /> - - - - - - - - - {apiKeys.length > 0 ? ( - - {apiKeys.map((key) => { - return ( - - - - - - - ); - })} - - ) : null} -
NameRoleExpires -
{key.name}{key.role}{this.formatDate(key.expiration)} - this.onDeleteApiKey(key)} /> -
- - ); - } - - render() { - const { hasFetched, navModel, apiKeysCount } = this.props; - - return ( - - - {hasFetched && (apiKeysCount > 0 ? this.renderApiKeyList() : this.renderEmptyList())} - - - ); - } -} - -function mapStateToProps(state: any) { +function mapStateToProps(state: StoreState) { return { navModel: getNavModel(state.navIndex, 'apikeys'), apiKeys: getApiKeys(state.apiKeys), searchQuery: state.apiKeys.searchQuery, - includeExpired: state.includeExpired, apiKeysCount: getApiKeysCount(state.apiKeys), hasFetched: state.apiKeys.hasFetched, + timeZone: getTimeZone(state.user), }; } @@ -326,4 +41,128 @@ const mapDispatchToProps = { addApiKey, }; -export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(ApiKeysPage)); +const connector = connect(mapStateToProps, mapDispatchToProps); + +interface OwnProps {} + +export type Props = OwnProps & ConnectedProps; + +interface State { + includeExpired: boolean; + hasFetched: boolean; +} + +export class ApiKeysPageUnconnected extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { includeExpired: false, hasFetched: false }; + } + + componentDidMount() { + this.fetchApiKeys(); + } + + async fetchApiKeys() { + await this.props.loadApiKeys(this.state.includeExpired); + } + + onDeleteApiKey = (key: ApiKey) => { + this.props.deleteApiKey(key.id!, this.state.includeExpired); + }; + + onSearchQueryChange = (value: string) => { + this.props.setSearchQuery(value); + }; + + onIncludeExpiredChange = (event: React.SyntheticEvent) => { + this.setState({ hasFetched: false, includeExpired: event.currentTarget.checked }, this.fetchApiKeys); + }; + + onAddApiKey = (newApiKey: NewApiKey) => { + const openModal = (apiKey: string) => { + const rootPath = window.location.origin + config.appSubUrl; + const modalTemplate = ReactDOMServer.renderToString(); + + appEvents.emit(CoreEvents.showModal, { + templateHtml: modalTemplate, + }); + }; + + const secondsToLive = newApiKey.secondsToLive; + try { + const secondsToLiveAsNumber = secondsToLive ? rangeUtil.intervalToSeconds(secondsToLive) : null; + const apiKey: ApiKey = { + ...newApiKey, + secondsToLive: secondsToLiveAsNumber, + }; + this.props.addApiKey(apiKey, openModal, this.state.includeExpired); + this.setState((prevState: State) => { + return { + ...prevState, + isAdding: false, + }; + }); + } catch (err) { + console.error(err); + } + }; + + render() { + const { hasFetched, navModel, apiKeysCount, apiKeys, searchQuery, timeZone } = this.props; + const { includeExpired } = this.state; + + if (!hasFetched) { + return ( + + {} + + ); + } + + return ( + + + + {({ isAdding, toggleIsAdding }) => { + const showCTA = !isAdding && apiKeysCount === 0; + const showTable = apiKeysCount > 0; + return ( + <> + {showCTA ? ( + + ) : null} + {showTable ? ( + + ) : null} + + {showTable ? ( + <> +

Existing Keys

+ + + + ) : null} + + ); + }} +
+
+
+ ); + } +} + +const ApiKeysPage = connector(ApiKeysPageUnconnected); +export default hot(module)(ApiKeysPage); diff --git a/public/app/features/api-keys/ApiKeysTable.tsx b/public/app/features/api-keys/ApiKeysTable.tsx new file mode 100644 index 00000000000..56203850d87 --- /dev/null +++ b/public/app/features/api-keys/ApiKeysTable.tsx @@ -0,0 +1,49 @@ +import React, { FC } from 'react'; +import { DeleteButton } from '@grafana/ui'; +import { dateTimeFormat, TimeZone } from '@grafana/data'; + +import { ApiKey } from '../../types'; + +interface Props { + apiKeys: ApiKey[]; + timeZone: TimeZone; + onDelete: (apiKey: ApiKey) => void; +} + +export const ApiKeysTable: FC = ({ apiKeys, timeZone, onDelete }) => { + return ( + + + + + + + + + {apiKeys.length > 0 ? ( + + {apiKeys.map((key) => { + return ( + + + + + + + ); + })} + + ) : null} +
NameRoleExpires +
{key.name}{key.role}{formatDate(key.expiration, timeZone)} + onDelete(key)} /> +
+ ); +}; + +function formatDate(expiration: string | undefined, timeZone: TimeZone): string { + if (!expiration) { + return 'No expiration date'; + } + return dateTimeFormat(expiration, { timeZone }); +} diff --git a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap b/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap deleted file mode 100644 index 586d1c01cb5..00000000000 --- a/public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap +++ /dev/null @@ -1,167 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Render should render API keys table if there are any keys 1`] = ` - - - -`; - -exports[`Render should render CTA if there are no API keys 1`] = ` - - - - -
- -
-
- Add API Key -
-
-
- - Key name - - -
-
- - Role - - - - -
-
- - Time to live - - -
-
- -
-
-
-
-
-
-
-`; diff --git a/public/app/features/api-keys/state/reducers.ts b/public/app/features/api-keys/state/reducers.ts index 23e84ad0ff2..b76d268c4c6 100644 --- a/public/app/features/api-keys/state/reducers.ts +++ b/public/app/features/api-keys/state/reducers.ts @@ -6,7 +6,6 @@ export const initialApiKeysState: ApiKeysState = { keys: [], searchQuery: '', hasFetched: false, - includeExpired: false, }; const apiKeysSlice = createSlice({ diff --git a/public/app/features/api-keys/state/selectors.test.ts b/public/app/features/api-keys/state/selectors.test.ts index 308c1044b6c..5e9ba51462f 100644 --- a/public/app/features/api-keys/state/selectors.test.ts +++ b/public/app/features/api-keys/state/selectors.test.ts @@ -7,7 +7,7 @@ describe('API Keys selectors', () => { const mockKeys = getMultipleMockKeys(5); it('should return all keys if no search query', () => { - const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '', hasFetched: false, includeExpired: false }; + const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '', hasFetched: false }; const keys = getApiKeys(mockState); @@ -15,7 +15,7 @@ describe('API Keys selectors', () => { }); it('should filter keys if search query exists', () => { - const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5', hasFetched: false, includeExpired: false }; + const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5', hasFetched: false }; const keys = getApiKeys(mockState); diff --git a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx index 7e6598ca0cd..178273ce105 100644 --- a/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx @@ -3,6 +3,8 @@ import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/react'; import { within } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; +import { selectors } from '@grafana/e2e-selectors'; + import { LinksSettings } from './LinksSettings'; describe('LinksSettings', () => { @@ -61,7 +63,9 @@ describe('LinksSettings', () => { render(); expect(screen.getByRole('heading', { name: 'Dashboard Links' })).toBeInTheDocument(); - expect(screen.getByLabelText('Call to action button Add Dashboard Link')).toBeInTheDocument(); + expect( + screen.getByLabelText(selectors.components.CallToActionCard.button('Add Dashboard Link')) + ).toBeInTheDocument(); expect(screen.queryByRole('table')).not.toBeInTheDocument(); }); @@ -72,7 +76,9 @@ describe('LinksSettings', () => { const tableBodyRows = within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row'); expect(tableBodyRows.length).toBe(links.length); - expect(screen.queryByLabelText('Call to action button Add Dashboard Link')).not.toBeInTheDocument(); + expect( + screen.queryByLabelText(selectors.components.CallToActionCard.button('Add Dashboard Link')) + ).not.toBeInTheDocument(); }); test('it rearranges the order of dashboard links', () => { diff --git a/public/app/types/apiKeys.ts b/public/app/types/apiKeys.ts index 380aaabe7c2..693852c34b0 100644 --- a/public/app/types/apiKeys.ts +++ b/public/app/types/apiKeys.ts @@ -1,22 +1,21 @@ import { OrgRole } from './acl'; export interface ApiKey { - id: number; + id?: number; name: string; role: OrgRole; - secondsToLive: number; - expiration: string; + secondsToLive: number | null; + expiration?: string; } export interface NewApiKey { name: string; role: OrgRole; - secondsToLive: number; + secondsToLive: string; } export interface ApiKeysState { keys: ApiKey[]; searchQuery: string; hasFetched: boolean; - includeExpired: boolean; }