From 370d6a6f7b23ab4ff867a9455b590e4ea4174871 Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Tue, 21 Jun 2022 11:26:23 +0200 Subject: [PATCH] Grafana/UI: Add SecretTextArea component (#51021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add secret TextArea Co-authored-by: Zoltán Bedi --- .../SecretTextArea/SecretTextArea.story.tsx | 65 +++++++++++++++++ .../SecretTextArea/SecretTextArea.test.tsx | 72 +++++++++++++++++++ .../SecretTextArea/SecretTextArea.tsx | 50 +++++++++++++ .../src/components/SecretTextArea/index.tsx | 1 + 4 files changed, 188 insertions(+) create mode 100644 packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.story.tsx create mode 100644 packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.test.tsx create mode 100644 packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx create mode 100644 packages/grafana-ui/src/components/SecretTextArea/index.tsx diff --git a/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.story.tsx b/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.story.tsx new file mode 100644 index 00000000000..e4bbc2599c3 --- /dev/null +++ b/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.story.tsx @@ -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 = (args) => { + const [secret, setSecret] = useState(''); + + return ( + ) => 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, +}; diff --git a/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.test.tsx b/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.test.tsx new file mode 100644 index 00000000000..7290dcf6454 --- /dev/null +++ b/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.test.tsx @@ -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('', () => { + it('should render an input if the secret is not configured', () => { + render( + {}} 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( + {}} 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( {}} 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( + {}} placeholder={PLACEHOLDER_TEXT} /> + ); + + const textArea = screen.getByPlaceholderText(PLACEHOLDER_TEXT); + + expect(textArea).toHaveValue(''); + + await userEvent.type(textArea, 'Foo'); + + expect(onChange).toHaveBeenCalled(); + expect(textArea).toHaveValue('Foo'); + }); +}); diff --git a/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx b/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx new file mode 100644 index 00000000000..71eb10b74cb --- /dev/null +++ b/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx @@ -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 & { + /** 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 ( + + {!isConfigured &&