mirror of https://github.com/grafana/grafana
Grafana/UI: Add SecretTextArea component (#51021)
* Add secret TextArea Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>pull/51159/head
parent
a0ffb9093c
commit
370d6a6f7b
@ -0,0 +1,65 @@ |
||||
import { Story, Meta } from '@storybook/react'; |
||||
import React, { useState, ChangeEvent } from 'react'; |
||||
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; |
||||
|
||||
import { SecretTextArea, Props } from './SecretTextArea'; |
||||
|
||||
export default { |
||||
title: 'Forms/SecretTextArea', |
||||
component: SecretTextArea, |
||||
decorators: [withCenteredStory], |
||||
parameters: { |
||||
controls: { |
||||
exclude: [ |
||||
'prefix', |
||||
'suffix', |
||||
'addonBefore', |
||||
'addonAfter', |
||||
'type', |
||||
'disabled', |
||||
'invalid', |
||||
'loading', |
||||
'before', |
||||
'after', |
||||
], |
||||
}, |
||||
}, |
||||
args: { |
||||
rows: 3, |
||||
cols: 30, |
||||
placeholder: 'Enter your secret...', |
||||
}, |
||||
argTypes: { |
||||
rows: { control: { type: 'range', min: 1, max: 50, step: 1 } }, |
||||
cols: { control: { type: 'range', min: 1, max: 200, step: 10 } }, |
||||
}, |
||||
} as Meta; |
||||
|
||||
const Template: Story<Props> = (args) => { |
||||
const [secret, setSecret] = useState(''); |
||||
|
||||
return ( |
||||
<SecretTextArea |
||||
rows={args.rows} |
||||
cols={args.cols} |
||||
value={secret} |
||||
isConfigured={args.isConfigured} |
||||
placeholder={args.placeholder} |
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setSecret(event.target.value.trim())} |
||||
onReset={() => setSecret('')} |
||||
/> |
||||
); |
||||
}; |
||||
|
||||
export const basic = Template.bind({}); |
||||
|
||||
basic.args = { |
||||
isConfigured: false, |
||||
}; |
||||
|
||||
export const secretIsConfigured = Template.bind({}); |
||||
|
||||
secretIsConfigured.args = { |
||||
isConfigured: true, |
||||
}; |
@ -0,0 +1,72 @@ |
||||
import { render, screen } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import React from 'react'; |
||||
|
||||
import { SecretTextArea, RESET_BUTTON_TEXT, CONFIGURED_TEXT } from './SecretTextArea'; |
||||
|
||||
const PLACEHOLDER_TEXT = 'Your secret...'; |
||||
|
||||
describe('<SecretTextArea />', () => { |
||||
it('should render an input if the secret is not configured', () => { |
||||
render( |
||||
<SecretTextArea isConfigured={false} onChange={() => {}} onReset={() => {}} placeholder={PLACEHOLDER_TEXT} /> |
||||
); |
||||
|
||||
const input = screen.getByPlaceholderText(PLACEHOLDER_TEXT); |
||||
|
||||
// Should show an enabled input
|
||||
expect(input).toBeInTheDocument(); |
||||
expect(input).not.toBeDisabled(); |
||||
|
||||
// Should not show a "Reset" button
|
||||
expect(screen.queryByRole('button', { name: RESET_BUTTON_TEXT })).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should render a disabled textarea with a reset button if the secret is already configured', () => { |
||||
render( |
||||
<SecretTextArea isConfigured={true} onChange={() => {}} onReset={() => {}} placeholder={PLACEHOLDER_TEXT} /> |
||||
); |
||||
|
||||
const textArea = screen.getByPlaceholderText(PLACEHOLDER_TEXT); |
||||
|
||||
// Should show a disabled input
|
||||
expect(textArea).toBeInTheDocument(); |
||||
expect(textArea).toBeDisabled(); |
||||
expect(textArea).toHaveValue(CONFIGURED_TEXT); |
||||
|
||||
// Should show a reset button
|
||||
expect(screen.queryByRole('button', { name: RESET_BUTTON_TEXT })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should be possible to reset a configured secret', async () => { |
||||
const onReset = jest.fn(); |
||||
|
||||
render(<SecretTextArea isConfigured={true} onChange={() => {}} onReset={onReset} placeholder={PLACEHOLDER_TEXT} />); |
||||
|
||||
// Should show a reset button and a disabled input
|
||||
expect(screen.queryByPlaceholderText(PLACEHOLDER_TEXT)).toBeDisabled(); |
||||
expect(screen.queryByRole('button', { name: RESET_BUTTON_TEXT })).toBeInTheDocument(); |
||||
|
||||
// Click on "Reset"
|
||||
await userEvent.click(screen.getByRole('button', { name: RESET_BUTTON_TEXT })); |
||||
|
||||
expect(onReset).toHaveBeenCalledTimes(1); |
||||
}); |
||||
|
||||
it('should be possible to change the value of the secret', async () => { |
||||
const onChange = jest.fn(); |
||||
|
||||
render( |
||||
<SecretTextArea isConfigured={false} onChange={onChange} onReset={() => {}} placeholder={PLACEHOLDER_TEXT} /> |
||||
); |
||||
|
||||
const textArea = screen.getByPlaceholderText(PLACEHOLDER_TEXT); |
||||
|
||||
expect(textArea).toHaveValue(''); |
||||
|
||||
await userEvent.type(textArea, 'Foo'); |
||||
|
||||
expect(onChange).toHaveBeenCalled(); |
||||
expect(textArea).toHaveValue('Foo'); |
||||
}); |
||||
}); |
@ -0,0 +1,50 @@ |
||||
import { css, cx } from '@emotion/css'; |
||||
import * as React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
|
||||
import { useStyles2 } from '../../themes/ThemeContext'; |
||||
import { Button } from '../Button'; |
||||
import { HorizontalGroup } from '../Layout/Layout'; |
||||
import { TextArea } from '../TextArea/TextArea'; |
||||
|
||||
export type Props = React.ComponentProps<typeof TextArea> & { |
||||
/** TRUE if the secret was already configured. (It is needed as often the backend doesn't send back the actual secret, only the information that it was configured) */ |
||||
isConfigured: boolean; |
||||
/** Called when the user clicks on the "Reset" button in order to clear the secret */ |
||||
onReset: () => void; |
||||
}; |
||||
|
||||
export const CONFIGURED_TEXT = 'configured'; |
||||
export const RESET_BUTTON_TEXT = 'Reset'; |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
configuredStyle: css` |
||||
min-height: ${theme.spacing(theme.components.height.md)}; |
||||
padding-top: ${theme.spacing(0.5) /** Needed to mimic vertically centered text in an input box */}; |
||||
resize: none; |
||||
`,
|
||||
}; |
||||
}; |
||||
|
||||
/** |
||||
* Text area that does not disclose an already configured value but lets the user reset the current value and enter a new one. |
||||
* Typically useful for asymmetric cryptography keys. |
||||
*/ |
||||
export const SecretTextArea = ({ isConfigured, onReset, ...props }: Props) => { |
||||
const styles = useStyles2(getStyles); |
||||
return ( |
||||
<HorizontalGroup> |
||||
{!isConfigured && <TextArea {...props} />} |
||||
{isConfigured && ( |
||||
<TextArea {...props} rows={1} disabled={true} value={CONFIGURED_TEXT} className={cx(styles.configuredStyle)} /> |
||||
)} |
||||
{isConfigured && ( |
||||
<Button onClick={onReset} variant="secondary"> |
||||
{RESET_BUTTON_TEXT} |
||||
</Button> |
||||
)} |
||||
</HorizontalGroup> |
||||
); |
||||
}; |
@ -0,0 +1 @@ |
||||
export { SecretTextArea } from './SecretTextArea'; |
Loading…
Reference in new issue