From 364b79ec80dbf8d6b20682466fd7da3803e3f035 Mon Sep 17 00:00:00 2001 From: Bartosz Leper Date: Thu, 24 Oct 2024 17:27:27 +0200 Subject: [PATCH] Add a multi-value input component (#47804) * Add Kubernetes access section to the role editor * Add a multi-value input component * Review * Update the k8s operator docs This also removes the mention of valid values of the Kind field, as I don't want the external documentation to point to Teleport source files. * Update operator CRDs and Terraform resources * Lint, licenses --- .../FieldMultiInput/FieldMultiInput.story.tsx | 37 +++++ .../FieldMultiInput/FieldMultiInput.test.tsx | 71 +++++++++ .../FieldMultiInput/FieldMultiInput.tsx | 139 ++++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 web/packages/shared/components/FieldMultiInput/FieldMultiInput.story.tsx create mode 100644 web/packages/shared/components/FieldMultiInput/FieldMultiInput.test.tsx create mode 100644 web/packages/shared/components/FieldMultiInput/FieldMultiInput.tsx diff --git a/web/packages/shared/components/FieldMultiInput/FieldMultiInput.story.tsx b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.story.tsx new file mode 100644 index 0000000000000..5362236a8b24d --- /dev/null +++ b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.story.tsx @@ -0,0 +1,37 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React, { useState } from 'react'; + +import Box from 'design/Box'; + +import { FieldMultiInput } from './FieldMultiInput'; + +export default { + title: 'Shared', +}; + +export function Story() { + const [items, setItems] = useState([]); + return ( + + + + ); +} +Story.storyName = 'FieldMultiInput'; diff --git a/web/packages/shared/components/FieldMultiInput/FieldMultiInput.test.tsx b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.test.tsx new file mode 100644 index 0000000000000..ce023a071053a --- /dev/null +++ b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.test.tsx @@ -0,0 +1,71 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import userEvent from '@testing-library/user-event'; +import React, { useState } from 'react'; + +import { render, screen } from 'design/utils/testing'; + +import { FieldMultiInput, FieldMultiInputProps } from './FieldMultiInput'; + +const TestFieldMultiInput = ({ + onChange, + ...rest +}: Partial) => { + const [items, setItems] = useState([]); + const handleChange = (it: string[]) => { + setItems(it); + onChange?.(it); + }; + return ; +}; + +test('adding, editing, and removing items', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + + await user.type(screen.getByRole('textbox'), 'apples'); + expect(onChange).toHaveBeenLastCalledWith(['apples']); + + await user.click(screen.getByRole('button', { name: 'Add More' })); + expect(onChange).toHaveBeenLastCalledWith(['apples', '']); + + await user.type(screen.getAllByRole('textbox')[1], 'oranges'); + expect(onChange).toHaveBeenLastCalledWith(['apples', 'oranges']); + + await user.click(screen.getAllByRole('button', { name: 'Remove Item' })[0]); + expect(onChange).toHaveBeenLastCalledWith(['oranges']); + + await user.click(screen.getAllByRole('button', { name: 'Remove Item' })[0]); + expect(onChange).toHaveBeenLastCalledWith([]); +}); + +test('keyboard handling', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + + await user.click(screen.getByRole('textbox')); + await user.keyboard('apples{Enter}oranges'); + expect(onChange).toHaveBeenLastCalledWith(['apples', 'oranges']); + + await user.click(screen.getAllByRole('textbox')[0]); + await user.keyboard('{Enter}bananas'); + expect(onChange).toHaveBeenLastCalledWith(['apples', 'bananas', 'oranges']); +}); diff --git a/web/packages/shared/components/FieldMultiInput/FieldMultiInput.tsx b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.tsx new file mode 100644 index 0000000000000..eaa98ef0a6511 --- /dev/null +++ b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.tsx @@ -0,0 +1,139 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import Box from 'design/Box'; +import { ButtonSecondary } from 'design/Button'; +import ButtonIcon from 'design/ButtonIcon'; +import Flex from 'design/Flex'; +import * as Icon from 'design/Icon'; +import Input from 'design/Input'; +import { useRef } from 'react'; +import styled, { useTheme } from 'styled-components'; + +export type FieldMultiInputProps = { + label?: string; + value: string[]; + disabled?: boolean; + onChange?(val: string[]): void; +}; + +/** + * Allows editing a list of strings, one value per row. Use instead of + * `FieldSelectCreatable` when: + * + * - There are no predefined values to be picked from. + * - Values are expected to be relatively long and would be unreadable after + * being truncated. + */ +export function FieldMultiInput({ + label, + value, + disabled, + onChange, +}: FieldMultiInputProps) { + if (value.length === 0) { + value = ['']; + } + + const theme = useTheme(); + // Index of the input to be focused after the next rendering. + const toFocus = useRef(); + + const setFocus = element => { + element?.focus(); + toFocus.current = undefined; + }; + + function insertItem(index: number) { + onChange?.(value.toSpliced(index, 0, '')); + } + + function removeItem(index: number) { + onChange?.(value.toSpliced(index, 1)); + } + + function handleKeyDown(index: number, e: React.KeyboardEvent) { + if (e.key === 'Enter') { + insertItem(index + 1); + toFocus.current = index + 1; + } + } + + return ( + +
+ {label && {label}} + {value.map((val, i) => ( + // Note on keys: using index as a key is an anti-pattern in general, + // but here, we can safely assume that even though the list is + // editable, we don't rely on any unmanaged HTML element state other + // than focus, which we deal with separately anyway. The alternatives + // would be either to require an array with keys generated + // synthetically and injected from outside (which would make the API + // difficult to use) or to keep the array with generated IDs as local + // state (which would require us to write a prop/state reconciliation + // procedure whose complexity would probably outweigh the benefits). + + + + onChange?.( + value.map((v, j) => (j === i ? e.target.value : v)) + ) + } + onKeyDown={e => handleKeyDown(i, e)} + /> + + removeItem(i)} + disabled={disabled} + > + + + + ))} + insertItem(value.length)} + > + + Add More + +
+
+ ); +} + +const Fieldset = styled.fieldset` + border: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: ${props => props.theme.space[2]}px; +`; + +const Legend = styled.legend` + margin: 0 0 ${props => props.theme.space[1]}px 0; + padding: 0; + ${props => props.theme.typography.body3} +`;