Admin: Keeps expired api keys visible in table after delete (#31636)

* Admin: Keeps expired keys visible in table after delete

* Chore: covers component in tests before refactor

* Refactor: splitting up into smaller components

* Chore: fixes a small issue with the validation

* Chore: forgot to export type
This commit is contained in:
Hugo Häggmark 2021-03-04 13:20:38 +01:00 committed by GitHub
parent a1e227638c
commit e87d48921e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 546 additions and 537 deletions

View File

@ -178,4 +178,7 @@ export const Components = {
dropDown: 'Dashboard link dropdown',
link: 'Dashboard link',
},
CallToActionCard: {
button: (name: string) => `Call to action button ${name}`,
},
};

View File

@ -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}`,

View File

@ -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<Props> = ({
href={buttonLink}
icon={buttonIcon}
className={ctaElementClassName}
aria-label={`Call to action button ${buttonTitle}`}
aria-label={selectors.components.CallToActionCard.button(buttonTitle)}
>
{buttonTitle}
</LinkButton>

View File

@ -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<Props> = ({ searchQuery, disabled, onAddClick, onSearchChange }) => {
return (
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<FilterInput
labelClassName="gf-form--has-input-icon gf-form--grow"
inputClassName="gf-form-input"
placeholder="Search keys"
value={searchQuery}
onChange={onSearchChange}
/>
</div>
<div className="page-action-bar__spacer" />
<button className="btn btn-primary pull-right" onClick={onAddClick} disabled={disabled}>
Add API key
</button>
</div>
);
};

View File

@ -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<Props> = ({ children }) => {
const [isAdding, setIsAdding] = useState<boolean>(false);
const toggleIsAdding = useCallback(() => {
setIsAdding(!isAdding);
}, [isAdding]);
return children({ isAdding, toggleIsAdding });
};

View File

@ -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<Props> = ({ show, onClose, onKeyAdded }) => {
const [name, setName] = useState<string>('');
const [role, setRole] = useState<OrgRole>(OrgRole.Viewer);
const [secondsToLive, setSecondsToLive] = useState<string>('');
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<HTMLInputElement>) => {
setName(event.currentTarget.value);
};
const onRoleChange = (event: ChangeEvent<HTMLSelectElement>) => {
setRole(event.currentTarget.value as OrgRole);
};
const onSecondsToLiveChange = (event: ChangeEvent<HTMLInputElement>) => {
setSecondsToLive(event.currentTarget.value);
};
return (
<SlideDown in={show}>
<div className="gf-form-inline cta-form">
<button className="cta-form__close btn btn-transparent" onClick={onClose}>
<Icon name="times" />
</button>
<form className="gf-form-group" onSubmit={onSubmit}>
<h5>Add API Key</h5>
<div className="gf-form-inline">
<div className="gf-form max-width-21">
<span className="gf-form-label">Key name</span>
<Input type="text" className="gf-form-input" value={name} placeholder="Name" onChange={onNameChange} />
</div>
<div className="gf-form">
<span className="gf-form-label">Role</span>
<span className="gf-form-select-wrapper">
<select className="gf-form-input gf-size-auto" value={role} onChange={onRoleChange}>
{Object.keys(OrgRole).map((role) => {
return (
<option key={role} label={role} value={role}>
{role}
</option>
);
})}
</select>
</span>
</div>
<div className="gf-form max-width-21">
<InlineFormLabel tooltip={tooltipText}>Time to live</InlineFormLabel>
<Input
type="text"
className="gf-form-input"
placeholder="1d"
validationEvents={timeRangeValidationEvents}
value={secondsToLive}
onChange={onSecondsToLiveChange}
/>
</div>
<div className="gf-form">
<button className="btn gf-form-btn btn-primary">Add</button>
</div>
</div>
</form>
</div>
</SlideDown>
);
};

View File

@ -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<Props>) => {
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(<ApiKeysPage {...props} />);
const instance = wrapper.instance() as ApiKeysPage;
return {
wrapper,
instance,
};
const { rerender } = render(<ApiKeysPageUnconnected {...props} />);
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
);
}

View File

@ -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<Props, any> {
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(<ApiKeysAddedModal apiKey={apiKey} rootPath={rootPath} />);
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 && (
<EmptyListCTA
title="You haven't added any API Keys yet."
buttonIcon="key-skeleton-alt"
buttonLink="#"
onClick={this.onToggleAdding}
buttonTitle=" New API Key"
proTip="Remember you can provide view-only API access to other applications."
/>
)}
{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 (
<SlideDown in={isAdding}>
<div className="gf-form-inline cta-form">
<button className="cta-form__close btn btn-transparent" onClick={this.onToggleAdding}>
<Icon name="times" />
</button>
<form className="gf-form-group" onSubmit={this.onAddApiKey}>
<h5>Add API Key</h5>
<div className="gf-form-inline">
<div className="gf-form max-width-21">
<span className="gf-form-label">Key name</span>
<Input
type="text"
className="gf-form-input"
value={newApiKey.name}
placeholder="Name"
onChange={(evt) => this.onApiKeyStateUpdate(evt, ApiKeyStateProps.Name)}
/>
</div>
<div className="gf-form">
<span className="gf-form-label">Role</span>
<span className="gf-form-select-wrapper">
<select
className="gf-form-input gf-size-auto"
value={newApiKey.role}
onChange={(evt) => this.onApiKeyStateUpdate(evt, ApiKeyStateProps.Role)}
>
{Object.keys(OrgRole).map((role) => {
return (
<option key={role} label={role} value={role}>
{role}
</option>
);
})}
</select>
</span>
</div>
<div className="gf-form max-width-21">
<InlineFormLabel tooltip={tooltipText}>Time to live</InlineFormLabel>
<Input
type="text"
className="gf-form-input"
placeholder="1d"
validationEvents={timeRangeValidationEvents}
value={newApiKey.secondsToLive}
onChange={(evt) => this.onApiKeyStateUpdate(evt, ApiKeyStateProps.SecondsToLive)}
/>
</div>
<div className="gf-form">
<button className="btn gf-form-btn btn-primary">Add</button>
</div>
</div>
</form>
</div>
</SlideDown>
);
}
renderApiKeyList() {
const { isAdding } = this.state;
const { apiKeys, searchQuery, includeExpired } = this.props;
return (
<>
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<FilterInput
labelClassName="gf-form--has-input-icon gf-form--grow"
inputClassName="gf-form-input"
placeholder="Search keys"
value={searchQuery}
onChange={this.onSearchQueryChange}
/>
</div>
<div className="page-action-bar__spacer" />
<button className="btn btn-primary pull-right" onClick={this.onToggleAdding} disabled={isAdding}>
Add API key
</button>
</div>
{this.renderAddApiKeyForm()}
<h3 className="page-heading">Existing Keys</h3>
<Switch
label="Show expired"
checked={includeExpired}
onChange={(event) => {
// @ts-ignore
this.onIncludeExpiredChange(event.target.checked);
}}
/>
<table className="filter-table">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th>Expires</th>
<th style={{ width: '34px' }} />
</tr>
</thead>
{apiKeys.length > 0 ? (
<tbody>
{apiKeys.map((key) => {
return (
<tr key={key.id}>
<td>{key.name}</td>
<td>{key.role}</td>
<td>{this.formatDate(key.expiration)}</td>
<td>
<DeleteButton size="sm" onConfirm={() => this.onDeleteApiKey(key)} />
</td>
</tr>
);
})}
</tbody>
) : null}
</table>
</>
);
}
render() {
const { hasFetched, navModel, apiKeysCount } = this.props;
return (
<Page navModel={navModel}>
<Page.Contents isLoading={!hasFetched}>
{hasFetched && (apiKeysCount > 0 ? this.renderApiKeyList() : this.renderEmptyList())}
</Page.Contents>
</Page>
);
}
}
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<typeof connector>;
interface State {
includeExpired: boolean;
hasFetched: boolean;
}
export class ApiKeysPageUnconnected extends PureComponent<Props, State> {
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<HTMLInputElement>) => {
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(<ApiKeysAddedModal apiKey={apiKey} rootPath={rootPath} />);
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 (
<Page navModel={navModel}>
<Page.Contents isLoading={true}>{}</Page.Contents>
</Page>
);
}
return (
<Page navModel={navModel}>
<Page.Contents isLoading={false}>
<ApiKeysController>
{({ isAdding, toggleIsAdding }) => {
const showCTA = !isAdding && apiKeysCount === 0;
const showTable = apiKeysCount > 0;
return (
<>
{showCTA ? (
<EmptyListCTA
title="You haven't added any API Keys yet."
buttonIcon="key-skeleton-alt"
buttonLink="#"
onClick={toggleIsAdding}
buttonTitle="New API Key"
proTip="Remember you can provide view-only API access to other applications."
/>
) : null}
{showTable ? (
<ApiKeysActionBar
searchQuery={searchQuery}
disabled={isAdding}
onAddClick={toggleIsAdding}
onSearchChange={this.onSearchQueryChange}
/>
) : null}
<ApiKeysForm show={isAdding} onClose={toggleIsAdding} onKeyAdded={this.onAddApiKey} />
{showTable ? (
<>
<h3 className="page-heading">Existing Keys</h3>
<Switch label="Show expired" checked={includeExpired} onChange={this.onIncludeExpiredChange} />
<ApiKeysTable apiKeys={apiKeys} timeZone={timeZone} onDelete={this.onDeleteApiKey} />
</>
) : null}
</>
);
}}
</ApiKeysController>
</Page.Contents>
</Page>
);
}
}
const ApiKeysPage = connector(ApiKeysPageUnconnected);
export default hot(module)(ApiKeysPage);

View File

@ -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<Props> = ({ apiKeys, timeZone, onDelete }) => {
return (
<table className="filter-table">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th>Expires</th>
<th style={{ width: '34px' }} />
</tr>
</thead>
{apiKeys.length > 0 ? (
<tbody>
{apiKeys.map((key) => {
return (
<tr key={key.id}>
<td>{key.name}</td>
<td>{key.role}</td>
<td>{formatDate(key.expiration, timeZone)}</td>
<td>
<DeleteButton size="sm" onConfirm={() => onDelete(key)} />
</td>
</tr>
);
})}
</tbody>
) : null}
</table>
);
};
function formatDate(expiration: string | undefined, timeZone: TimeZone): string {
if (!expiration) {
return 'No expiration date';
}
return dateTimeFormat(expiration, { timeZone });
}

View File

@ -1,167 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render API keys table if there are any keys 1`] = `
<Page
navModel={
Object {
"main": Object {
"text": "Configuration",
},
"node": Object {
"text": "Api Keys",
},
}
}
>
<PageContents
isLoading={true}
/>
</Page>
`;
exports[`Render should render CTA if there are no API keys 1`] = `
<Page
navModel={
Object {
"main": Object {
"text": "Configuration",
},
"node": Object {
"text": "Api Keys",
},
}
}
>
<PageContents
isLoading={false}
>
<EmptyListCTA
buttonIcon="key-skeleton-alt"
buttonLink="#"
buttonTitle=" New API Key"
onClick={[Function]}
proTip="Remember you can provide view-only API access to other applications."
title="You haven't added any API Keys yet."
/>
<SlideDown
in={false}
>
<div
className="gf-form-inline cta-form"
>
<button
className="cta-form__close btn btn-transparent"
onClick={[Function]}
>
<Icon
name="times"
/>
</button>
<form
className="gf-form-group"
onSubmit={[Function]}
>
<h5>
Add API Key
</h5>
<div
className="gf-form-inline"
>
<div
className="gf-form max-width-21"
>
<span
className="gf-form-label"
>
Key name
</span>
<Input
className="gf-form-input"
onChange={[Function]}
placeholder="Name"
type="text"
value=""
/>
</div>
<div
className="gf-form"
>
<span
className="gf-form-label"
>
Role
</span>
<span
className="gf-form-select-wrapper"
>
<select
className="gf-form-input gf-size-auto"
onChange={[Function]}
value="Viewer"
>
<option
key="Viewer"
label="Viewer"
value="Viewer"
>
Viewer
</option>
<option
key="Editor"
label="Editor"
value="Editor"
>
Editor
</option>
<option
key="Admin"
label="Admin"
value="Admin"
>
Admin
</option>
</select>
</span>
</div>
<div
className="gf-form max-width-21"
>
<FormLabel
tooltip="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"
>
Time to live
</FormLabel>
<Input
className="gf-form-input"
onChange={[Function]}
placeholder="1d"
type="text"
validationEvents={
Object {
"onBlur": Array [
Object {
"errorMessage": "Not a valid duration",
"rule": [Function],
},
],
}
}
value=""
/>
</div>
<div
className="gf-form"
>
<button
className="btn gf-form-btn btn-primary"
>
Add
</button>
</div>
</div>
</form>
</div>
</SlideDown>
</PageContents>
</Page>
`;

View File

@ -6,7 +6,6 @@ export const initialApiKeysState: ApiKeysState = {
keys: [],
searchQuery: '',
hasFetched: false,
includeExpired: false,
};
const apiKeysSlice = createSlice({

View File

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

View File

@ -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(<LinksSettings dashboard={linklessDashboard} />);
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', () => {

View File

@ -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;
}