mirror of
https://github.com/grafana/grafana.git
synced 2024-12-26 08:51:33 -06:00
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:
parent
a1e227638c
commit
e87d48921e
@ -178,4 +178,7 @@ export const Components = {
|
||||
dropDown: 'Dashboard link dropdown',
|
||||
link: 'Dashboard link',
|
||||
},
|
||||
CallToActionCard: {
|
||||
button: (name: string) => `Call to action button ${name}`,
|
||||
},
|
||||
};
|
||||
|
@ -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}`,
|
||||
|
@ -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>
|
||||
|
31
public/app/features/api-keys/ApiKeysActionBar.tsx
Normal file
31
public/app/features/api-keys/ApiKeysActionBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
19
public/app/features/api-keys/ApiKeysController.tsx
Normal file
19
public/app/features/api-keys/ApiKeysController.tsx
Normal 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 });
|
||||
};
|
111
public/app/features/api-keys/ApiKeysForm.tsx
Normal file
111
public/app/features/api-keys/ApiKeysForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
|
49
public/app/features/api-keys/ApiKeysTable.tsx
Normal file
49
public/app/features/api-keys/ApiKeysTable.tsx
Normal 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 });
|
||||
}
|
@ -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>
|
||||
`;
|
@ -6,7 +6,6 @@ export const initialApiKeysState: ApiKeysState = {
|
||||
keys: [],
|
||||
searchQuery: '',
|
||||
hasFetched: false,
|
||||
includeExpired: false,
|
||||
};
|
||||
|
||||
const apiKeysSlice = createSlice({
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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', () => {
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user