diff --git a/web/packages/shared/components/ToolTip/HoverTooltip.tsx b/web/packages/design/src/Tooltip/HoverTooltip.tsx similarity index 98% rename from web/packages/shared/components/ToolTip/HoverTooltip.tsx rename to web/packages/design/src/Tooltip/HoverTooltip.tsx index e9e16bba58359..6dbaab58bdf0a 100644 --- a/web/packages/shared/components/ToolTip/HoverTooltip.tsx +++ b/web/packages/design/src/Tooltip/HoverTooltip.tsx @@ -18,6 +18,7 @@ import React, { PropsWithChildren, useState } from 'react'; import styled, { useTheme } from 'styled-components'; + import { Popover, Flex, Text } from 'design'; import { JustifyContentProps, FlexBasisProps } from 'design/system'; @@ -64,6 +65,7 @@ export const HoverTooltip: React.FC< // whether we want to show the tooltip. if ( target instanceof Element && + target.parentElement && target.scrollWidth > target.parentElement.offsetWidth ) { setAnchorEl(event.currentTarget); @@ -75,7 +77,7 @@ export const HoverTooltip: React.FC< } function handlePopoverClose() { - setAnchorEl(null); + setAnchorEl(undefined); } // Don't render the tooltip if the content is undefined. diff --git a/web/packages/shared/components/ToolTip/ToolTip.story.tsx b/web/packages/design/src/Tooltip/IconTooltip.story.tsx similarity index 82% rename from web/packages/shared/components/ToolTip/ToolTip.story.tsx rename to web/packages/design/src/Tooltip/IconTooltip.story.tsx index 1830fc4902e12..8062ae357752a 100644 --- a/web/packages/shared/components/ToolTip/ToolTip.story.tsx +++ b/web/packages/design/src/Tooltip/IconTooltip.story.tsx @@ -17,17 +17,19 @@ */ import React from 'react'; -import { Text, Flex, ButtonPrimary } from 'design'; import styled, { useTheme } from 'styled-components'; + +import { Text, Flex, ButtonPrimary } from 'design'; import { P } from 'design/Text/Text'; -import { logos } from 'teleport/components/LogoHero/LogoHero'; +import AGPLLogoLight from 'design/assets/images/agpl-light.svg'; +import AGPLLogoDark from 'design/assets/images/agpl-dark.svg'; -import { ToolTipInfo } from './ToolTip'; +import { IconTooltip } from './IconTooltip'; import { HoverTooltip } from './HoverTooltip'; export default { - title: 'Shared/ToolTip', + title: 'Design/Tooltip', }; export const ShortContent = () => ( @@ -36,25 +38,25 @@ export const ShortContent = () => ( Hover the icon - "some popover content" + "some popover content"
Hover the icon - "some popover content" + "some popover content"
Hover the icon - "some popover content" + "some popover content"
Hover the icon - "some popover content" + "some popover content"
); @@ -65,13 +67,18 @@ const Grid = styled.div` grid-template-rows: repeat(3, 100px); `; +const logos = { + light: AGPLLogoLight, + dark: AGPLLogoDark, +}; + export const LongContent = () => { const theme = useTheme(); return ( <> Hover the icon - +

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim @@ -84,7 +91,7 @@ export const LongContent = () => { cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

-
+

Here's some content that shouldn't interfere with the semi-transparent @@ -92,7 +99,7 @@ export const LongContent = () => {

- +
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim @@ -113,7 +120,7 @@ export const WithMutedIconColor = () => ( Hover the icon - "some popover content" + "some popover content" ); @@ -122,7 +129,7 @@ export const WithKindWarning = () => ( Hover the icon - "some popover content" + "some popover content" ); @@ -131,7 +138,7 @@ export const WithKindError = () => ( Hover the icon - "some popover content" + "some popover content" ); diff --git a/web/packages/shared/components/ToolTip/ToolTip.tsx b/web/packages/design/src/Tooltip/IconTooltip.tsx similarity index 95% rename from web/packages/shared/components/ToolTip/ToolTip.tsx rename to web/packages/design/src/Tooltip/IconTooltip.tsx index f80a3a6342b91..e7434272fe6f3 100644 --- a/web/packages/shared/components/ToolTip/ToolTip.tsx +++ b/web/packages/design/src/Tooltip/IconTooltip.tsx @@ -27,7 +27,7 @@ import { anchorOriginForPosition, transformOriginForPosition } from './shared'; type ToolTipKind = 'info' | 'warning' | 'error'; -export const ToolTipInfo: React.FC< +export const IconTooltip: React.FC< PropsWithChildren<{ trigger?: 'click' | 'hover'; position?: Position; @@ -46,15 +46,15 @@ export const ToolTipInfo: React.FC< kind = 'info', }) => { const theme = useTheme(); - const [anchorEl, setAnchorEl] = useState(); + const [anchorEl, setAnchorEl] = useState(); const open = Boolean(anchorEl); - function handlePopoverOpen(event) { + function handlePopoverOpen(event: React.MouseEvent) { setAnchorEl(event.currentTarget); } function handlePopoverClose() { - setAnchorEl(null); + setAnchorEl(undefined); } const triggerOnHoverProps = { @@ -121,8 +121,6 @@ const ToolTipIcon = ({ return ; case 'error': return ; - default: - kind satisfies never; } }; diff --git a/web/packages/design/src/Tooltip/index.ts b/web/packages/design/src/Tooltip/index.ts new file mode 100644 index 0000000000000..1be43077b8952 --- /dev/null +++ b/web/packages/design/src/Tooltip/index.ts @@ -0,0 +1,20 @@ +/** + * Teleport + * Copyright (C) 2023 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 . + */ + +export { IconTooltip } from './IconTooltip'; +export { HoverTooltip } from './HoverTooltip'; diff --git a/web/packages/shared/components/ToolTip/shared.tsx b/web/packages/design/src/Tooltip/shared.tsx similarity index 100% rename from web/packages/shared/components/ToolTip/shared.tsx rename to web/packages/design/src/Tooltip/shared.tsx diff --git a/web/packages/shared/components/AccessRequests/AccessDuration/AccessDurationRequest.tsx b/web/packages/shared/components/AccessRequests/AccessDuration/AccessDurationRequest.tsx index 998245bbc58da..35cfac569c462 100644 --- a/web/packages/shared/components/AccessRequests/AccessDuration/AccessDurationRequest.tsx +++ b/web/packages/shared/components/AccessRequests/AccessDuration/AccessDurationRequest.tsx @@ -19,8 +19,9 @@ import React from 'react'; import { Flex, LabelInput, Text } from 'design'; +import { IconTooltip } from 'design/Tooltip'; + import Select, { Option } from 'shared/components/Select'; -import { ToolTipInfo } from 'shared/components/ToolTip'; export function AccessDurationRequest({ maxDuration, @@ -35,11 +36,11 @@ export function AccessDurationRequest({ Access Duration - + How long you would be given elevated privileges. Note that the time it takes to approve this request will be subtracted from the duration you requested. - + Access Request Lifetime - + The max duration of an access request, starting from its creation, until it expires. - + {getFormattedDurationTxt({ diff --git a/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.tsx b/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.tsx index 775c10f356267..fd4bc1578869d 100644 --- a/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.tsx +++ b/web/packages/shared/components/AccessRequests/NewRequest/RequestCheckout/RequestCheckout.tsx @@ -39,6 +39,8 @@ import { ArrowBack, ChevronDown, ChevronRight, Warning } from 'design/Icon'; import Table, { Cell } from 'design/DataTable'; import { Danger } from 'design/Alert'; +import { HoverTooltip } from 'design/Tooltip'; + import Validation, { useRule, Validator } from 'shared/components/Validation'; import { Attempt } from 'shared/hooks/useAttemptNext'; import { pluralize } from 'shared/utils/text'; @@ -47,7 +49,6 @@ import { FieldCheckbox } from 'shared/components/FieldCheckbox'; import { mergeRefs } from 'shared/libs/mergeRefs'; import { TextSelectCopyMulti } from 'shared/components/TextSelectCopy'; import { RequestableResourceKind } from 'shared/components/AccessRequests/NewRequest/resource'; -import { HoverTooltip } from 'shared/components/ToolTip'; import { CreateRequest } from '../../Shared/types'; import { AssumeStartTime } from '../../AssumeStartTime/AssumeStartTime'; diff --git a/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestReview/RequestReview.tsx b/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestReview/RequestReview.tsx index 6a93e92656ac0..c21e4295c3afc 100644 --- a/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestReview/RequestReview.tsx +++ b/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestReview/RequestReview.tsx @@ -22,12 +22,13 @@ import { ButtonPrimary, Text, Box, Alert, Flex, Label, H3 } from 'design'; import { Warning } from 'design/Icon'; import { Radio } from 'design/RadioGroup'; +import { HoverTooltip } from 'design/Tooltip'; + import Validation, { Validator } from 'shared/components/Validation'; import { FieldSelect } from 'shared/components/FieldSelect'; import { Option } from 'shared/components/Select'; import { Attempt } from 'shared/hooks/useAsync'; import { requiredField } from 'shared/components/Validation/rules'; -import { HoverTooltip } from 'shared/components/ToolTip'; import { FieldTextArea } from 'shared/components/FieldTextArea'; import { AccessRequest, RequestState } from 'shared/services/accessRequests'; diff --git a/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestView.tsx b/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestView.tsx index 3bebbf52fc0b4..9a3e424787378 100644 --- a/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestView.tsx +++ b/web/packages/shared/components/AccessRequests/ReviewRequests/RequestView/RequestView.tsx @@ -42,7 +42,8 @@ import { displayDateWithPrefixedTime } from 'design/datetime'; import { LabelKind } from 'design/LabelState/LabelState'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; + import { hasFinished, Attempt } from 'shared/hooks/useAsync'; import { diff --git a/web/packages/shared/components/AccessRequests/Shared/Shared.tsx b/web/packages/shared/components/AccessRequests/Shared/Shared.tsx index 97bf6987b2d6e..2159f6309c95e 100644 --- a/web/packages/shared/components/AccessRequests/Shared/Shared.tsx +++ b/web/packages/shared/components/AccessRequests/Shared/Shared.tsx @@ -21,7 +21,8 @@ import { ButtonPrimary, Text, Box, ButtonIcon, Menu } from 'design'; import { Info } from 'design/Icon'; import { displayDateWithPrefixedTime } from 'design/datetime'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; + import { AccessRequest } from 'shared/services/accessRequests'; export function PromotedMessage({ diff --git a/web/packages/shared/components/AdvancedSearchToggle/AdvancedSearchToggle.tsx b/web/packages/shared/components/AdvancedSearchToggle/AdvancedSearchToggle.tsx index 6800783daba6a..ea37fe2de003d 100644 --- a/web/packages/shared/components/AdvancedSearchToggle/AdvancedSearchToggle.tsx +++ b/web/packages/shared/components/AdvancedSearchToggle/AdvancedSearchToggle.tsx @@ -22,7 +22,7 @@ import { Text, Toggle, Link, Flex, H2 } from 'design'; import { P } from 'design/Text/Text'; -import { ToolTipInfo } from 'shared/components/ToolTip'; +import { IconTooltip } from 'design/Tooltip'; const GUIDE_URL = 'https://goteleport.com/docs/reference/predicate-language/#resource-filtering'; @@ -44,9 +44,9 @@ export function AdvancedSearchToggle(props: { > Advanced - + - + ); } diff --git a/web/packages/shared/components/ClusterDropdown/ClusterDropdown.tsx b/web/packages/shared/components/ClusterDropdown/ClusterDropdown.tsx index eb9babb16f43c..60846510fe90d 100644 --- a/web/packages/shared/components/ClusterDropdown/ClusterDropdown.tsx +++ b/web/packages/shared/components/ClusterDropdown/ClusterDropdown.tsx @@ -24,7 +24,7 @@ import { ChevronDown } from 'design/Icon'; import cfg from 'teleport/config'; import { Cluster } from 'teleport/services/clusters'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; export interface ClusterDropdownProps { clusterLoader: ClusterLoader; diff --git a/web/packages/shared/components/Controls/MultiselectMenu.tsx b/web/packages/shared/components/Controls/MultiselectMenu.tsx index f252cf7aa21be..98acea2a75d28 100644 --- a/web/packages/shared/components/Controls/MultiselectMenu.tsx +++ b/web/packages/shared/components/Controls/MultiselectMenu.tsx @@ -29,7 +29,7 @@ import { import { ChevronDown } from 'design/Icon'; import { CheckboxInput } from 'design/Checkbox'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; type MultiselectMenuProps = { options: { diff --git a/web/packages/shared/components/Controls/SortMenu.tsx b/web/packages/shared/components/Controls/SortMenu.tsx index d6bbc5cdf0d2d..a55dabad7929c 100644 --- a/web/packages/shared/components/Controls/SortMenu.tsx +++ b/web/packages/shared/components/Controls/SortMenu.tsx @@ -20,7 +20,7 @@ import React, { useState } from 'react'; import { ButtonBorder, Flex, Menu, MenuItem } from 'design'; import { ArrowDown, ArrowUp } from 'design/Icon'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; type SortMenuSort = { fieldName: Exclude; diff --git a/web/packages/shared/components/Controls/ViewModeSwitch.tsx b/web/packages/shared/components/Controls/ViewModeSwitch.tsx index 7997f2de29f66..62e5f94b36a3a 100644 --- a/web/packages/shared/components/Controls/ViewModeSwitch.tsx +++ b/web/packages/shared/components/Controls/ViewModeSwitch.tsx @@ -22,7 +22,7 @@ import { Rows, SquaresFour } from 'design/Icon'; import { ViewMode } from 'gen-proto-ts/teleport/userpreferences/v1/unified_resource_preferences_pb'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; export const ViewModeSwitch = ({ currentViewMode, diff --git a/web/packages/shared/components/FieldInput/FieldInput.tsx b/web/packages/shared/components/FieldInput/FieldInput.tsx index e8fceadf12214..1ea08bf39ed8f 100644 --- a/web/packages/shared/components/FieldInput/FieldInput.tsx +++ b/web/packages/shared/components/FieldInput/FieldInput.tsx @@ -28,8 +28,9 @@ import styled, { useTheme } from 'styled-components'; import { IconProps } from 'design/Icon/Icon'; import { InputMode, InputSize, InputType } from 'design/Input'; +import { IconTooltip } from 'design/Tooltip'; + import { useRule } from 'shared/components/Validation'; -import { ToolTipInfo } from 'shared/components/ToolTip'; const FieldInput = forwardRef( ( @@ -113,7 +114,7 @@ const FieldInput = forwardRef( > {label} - + ) : ( <>{label} diff --git a/web/packages/shared/components/FieldMultiInput/FieldMultiInput.story.tsx b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.story.tsx index 5362236a8b24d..2f798d4d923d1 100644 --- a/web/packages/shared/components/FieldMultiInput/FieldMultiInput.story.tsx +++ b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.story.tsx @@ -20,6 +20,12 @@ import React, { useState } from 'react'; import Box from 'design/Box'; +import { Button } from 'design/Button'; + +import Validation from 'shared/components/Validation'; + +import { arrayOf, requiredField } from '../Validation/rules'; + import { FieldMultiInput } from './FieldMultiInput'; export default { @@ -30,7 +36,21 @@ export function Story() { const [items, setItems] = useState([]); return ( - + + {({ validator }) => ( + <> + + + + )} + ); } diff --git a/web/packages/shared/components/FieldMultiInput/FieldMultiInput.test.tsx b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.test.tsx index ce023a071053a..89b191e1e5b2d 100644 --- a/web/packages/shared/components/FieldMultiInput/FieldMultiInput.test.tsx +++ b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.test.tsx @@ -19,20 +19,36 @@ import userEvent from '@testing-library/user-event'; import React, { useState } from 'react'; -import { render, screen } from 'design/utils/testing'; +import { act, render, screen } from 'design/utils/testing'; + +import Validation, { Validator } from 'shared/components/Validation'; + +import { arrayOf, requiredField } from '../Validation/rules'; import { FieldMultiInput, FieldMultiInputProps } from './FieldMultiInput'; const TestFieldMultiInput = ({ onChange, + refValidator, ...rest -}: Partial) => { +}: Partial & { + refValidator?: (v: Validator) => void; +}) => { const [items, setItems] = useState([]); const handleChange = (it: string[]) => { setItems(it); onChange?.(it); }; - return ; + return ( + + {({ validator }) => { + refValidator?.(validator); + return ( + + ); + }} + + ); }; test('adding, editing, and removing items', async () => { @@ -69,3 +85,35 @@ test('keyboard handling', async () => { await user.keyboard('{Enter}bananas'); expect(onChange).toHaveBeenLastCalledWith(['apples', 'bananas', 'oranges']); }); + +test('validation', async () => { + const user = userEvent.setup(); + let validator: Validator; + render( + { + validator = v; + }} + rule={arrayOf(requiredField('required'))} + /> + ); + + act(() => validator.validate()); + expect(validator.state.valid).toBe(true); + expect(screen.getByRole('textbox')).toHaveAccessibleDescription(''); + + await user.click(screen.getByRole('button', { name: 'Add More' })); + await user.type(screen.getAllByRole('textbox')[1], 'foo'); + act(() => validator.validate()); + expect(validator.state.valid).toBe(false); + expect(screen.getAllByRole('textbox')[0]).toHaveAccessibleDescription( + 'required' + ); + expect(screen.getAllByRole('textbox')[1]).toHaveAccessibleDescription(''); + + await user.type(screen.getAllByRole('textbox')[0], 'foo'); + act(() => validator.validate()); + expect(validator.state.valid).toBe(true); + expect(screen.getAllByRole('textbox')[0]).toHaveAccessibleDescription(''); + expect(screen.getAllByRole('textbox')[1]).toHaveAccessibleDescription(''); +}); diff --git a/web/packages/shared/components/FieldMultiInput/FieldMultiInput.tsx b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.tsx index e1dbace8c97d5..48a12d403f313 100644 --- a/web/packages/shared/components/FieldMultiInput/FieldMultiInput.tsx +++ b/web/packages/shared/components/FieldMultiInput/FieldMultiInput.tsx @@ -21,15 +21,35 @@ 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'; +import { + precomputed, + Rule, + ValidationResult, +} from 'shared/components/Validation/rules'; +import { useRule } from 'shared/components/Validation'; + +import FieldInput from '../FieldInput'; + +type StringListValidationResult = ValidationResult & { + /** + * A list of validation results, one per list item. Note: results are + * optional just because `useRule` by default returns only + * `ValidationResult`. For the actual validation, it's not optional; if it's + * undefined, or there are fewer results in this list than the list items, + * the corresponding items will be treated as valid. + */ + results?: ValidationResult[]; +}; + export type FieldMultiInputProps = { label?: string; value: string[]; disabled?: boolean; onChange?(val: string[]): void; + rule?: Rule; }; /** @@ -45,7 +65,13 @@ export function FieldMultiInput({ value, disabled, onChange, + rule = defaultRule, }: FieldMultiInputProps) { + // It's important to first validate, and then treat an empty array as a + // single-item list with an empty string, since this "synthetic" empty + // string is technically not a part of the model and should not be + // validated. + const validationResult: StringListValidationResult = useRule(rule(value)); if (value.length === 0) { value = ['']; } @@ -90,8 +116,11 @@ export function FieldMultiInput({ // procedure whose complexity would probably outweigh the benefits). - onChange?.( @@ -99,6 +128,7 @@ export function FieldMultiInput({ ) } onKeyDown={e => handleKeyDown(i, e)} + mb={0} /> () => ({ valid: true }); + const Fieldset = styled.fieldset` border: none; margin: 0; diff --git a/web/packages/shared/components/FieldSelect/FieldSelect.story.tsx b/web/packages/shared/components/FieldSelect/FieldSelect.story.tsx index cecfa4e84dac0..dde0dc2b97dce 100644 --- a/web/packages/shared/components/FieldSelect/FieldSelect.story.tsx +++ b/web/packages/shared/components/FieldSelect/FieldSelect.story.tsx @@ -53,7 +53,10 @@ export function Default() { return ( {({ validator }) => { - validator.validate(); + // Prevent rendering loop. + if (!validator.state.validating) { + validator.validate(); + } return ( () => ({ valid: true }); @@ -95,7 +96,7 @@ export const FieldSelectWrapper = ({ {toolTipContent ? ( {label} - + ) : ( label diff --git a/web/packages/shared/components/FieldTextArea/FieldTextArea.tsx b/web/packages/shared/components/FieldTextArea/FieldTextArea.tsx index 545ef4d56ce17..c8bd7a1e0439d 100644 --- a/web/packages/shared/components/FieldTextArea/FieldTextArea.tsx +++ b/web/packages/shared/components/FieldTextArea/FieldTextArea.tsx @@ -27,9 +27,10 @@ import { TextAreaSize } from 'design/TextArea'; import { BoxProps } from 'design/Box'; +import { IconTooltip } from 'design/Tooltip'; + import { useRule } from 'shared/components/Validation'; -import { ToolTipInfo } from '../ToolTip'; import { HelperTextLine } from '../FieldInput/FieldInput'; export type FieldTextAreaProps = BoxProps & { @@ -140,7 +141,7 @@ export const FieldTextArea = forwardRef< > {label} - + ) : ( <>{label} diff --git a/web/packages/shared/components/ToolTip/index.ts b/web/packages/shared/components/ToolTip/index.ts index c6518cad9b297..f1be185cb4ae6 100644 --- a/web/packages/shared/components/ToolTip/index.ts +++ b/web/packages/shared/components/ToolTip/index.ts @@ -16,5 +16,10 @@ * along with this program. If not, see . */ -export { ToolTipInfo } from './ToolTip'; -export { HoverTooltip } from './HoverTooltip'; +export { + /** @deprecated Use `TooltipInfo` from `design/Tooltip` */ + IconTooltip as ToolTipInfo, + + /** @deprecated Use `HoverTooltip` from `design/Tooltip` */ + HoverTooltip, +} from 'design/Tooltip'; diff --git a/web/packages/shared/components/UnifiedResources/CardsView/ResourceCard.tsx b/web/packages/shared/components/UnifiedResources/CardsView/ResourceCard.tsx index 8268c8b71d599..b592e0acddca0 100644 --- a/web/packages/shared/components/UnifiedResources/CardsView/ResourceCard.tsx +++ b/web/packages/shared/components/UnifiedResources/CardsView/ResourceCard.tsx @@ -26,7 +26,7 @@ import { ResourceIcon } from 'design/ResourceIcon'; import { makeLabelTag } from 'teleport/components/formatters'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; import { ResourceItemProps } from '../types'; import { PinButton } from '../shared/PinButton'; diff --git a/web/packages/shared/components/UnifiedResources/FilterPanel.tsx b/web/packages/shared/components/UnifiedResources/FilterPanel.tsx index 6e62a50a3d4c9..d470abe9a9e9e 100644 --- a/web/packages/shared/components/UnifiedResources/FilterPanel.tsx +++ b/web/packages/shared/components/UnifiedResources/FilterPanel.tsx @@ -26,7 +26,8 @@ import { ChevronDown, ArrowsIn, ArrowsOut, Refresh } from 'design/Icon'; import { ViewMode } from 'gen-proto-ts/teleport/userpreferences/v1/unified_resource_preferences_pb'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; + import { SortMenu } from 'shared/components/Controls/SortMenu'; import { ViewModeSwitch } from 'shared/components/Controls/ViewModeSwitch'; diff --git a/web/packages/shared/components/UnifiedResources/ListView/ResourceListItem.tsx b/web/packages/shared/components/UnifiedResources/ListView/ResourceListItem.tsx index 0f2a34024536a..582a7c5ece831 100644 --- a/web/packages/shared/components/UnifiedResources/ListView/ResourceListItem.tsx +++ b/web/packages/shared/components/UnifiedResources/ListView/ResourceListItem.tsx @@ -26,7 +26,7 @@ import { ResourceIcon } from 'design/ResourceIcon'; import { makeLabelTag } from 'teleport/components/formatters'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; import { ResourceItemProps } from '../types'; import { PinButton } from '../shared/PinButton'; diff --git a/web/packages/shared/components/UnifiedResources/ResourceTab.tsx b/web/packages/shared/components/UnifiedResources/ResourceTab.tsx index 2533e2a203165..2deb131ffcde9 100644 --- a/web/packages/shared/components/UnifiedResources/ResourceTab.tsx +++ b/web/packages/shared/components/UnifiedResources/ResourceTab.tsx @@ -20,7 +20,7 @@ import React from 'react'; import styled from 'styled-components'; import { Box, Text } from 'design'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; import { PINNING_NOT_SUPPORTED_MESSAGE } from './UnifiedResources'; diff --git a/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx b/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx index 5426b0d908a74..a9f0699018d42 100644 --- a/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx +++ b/web/packages/shared/components/UnifiedResources/UnifiedResources.tsx @@ -43,7 +43,8 @@ import { AvailableResourceMode, } from 'gen-proto-ts/teleport/userpreferences/v1/unified_resource_preferences_pb'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; + import { makeEmptyAttempt, makeSuccessAttempt, diff --git a/web/packages/shared/components/UnifiedResources/shared/CopyButton.tsx b/web/packages/shared/components/UnifiedResources/shared/CopyButton.tsx index a3a1a4ad12be6..43b9cb2217165 100644 --- a/web/packages/shared/components/UnifiedResources/shared/CopyButton.tsx +++ b/web/packages/shared/components/UnifiedResources/shared/CopyButton.tsx @@ -22,7 +22,7 @@ import ButtonIcon from 'design/ButtonIcon'; import { Check, Copy } from 'design/Icon'; import { copyToClipboard } from 'design/utils/copyToClipboard'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; export function CopyButton({ name, diff --git a/web/packages/shared/components/UnifiedResources/shared/PinButton.tsx b/web/packages/shared/components/UnifiedResources/shared/PinButton.tsx index 1eedee2db68a2..cde3b87142c04 100644 --- a/web/packages/shared/components/UnifiedResources/shared/PinButton.tsx +++ b/web/packages/shared/components/UnifiedResources/shared/PinButton.tsx @@ -21,7 +21,7 @@ import React, { useRef } from 'react'; import { PushPinFilled, PushPin } from 'design/Icon'; import ButtonIcon from 'design/ButtonIcon'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; import { PinningSupport } from '../types'; diff --git a/web/packages/shared/components/Validation/Validation.jsx b/web/packages/shared/components/Validation/Validation.jsx deleted file mode 100644 index 352a386bc35fd..0000000000000 --- a/web/packages/shared/components/Validation/Validation.jsx +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Teleport - * Copyright (C) 2023 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 from 'react'; - -import { isObject } from 'shared/utils/highbar'; - -import Logger from '../../libs/logger'; - -const logger = Logger.create('validation'); - -// Validator handles input validation -export default class Validator { - valid = true; - - constructor() { - // store subscribers - this._subs = []; - } - - // adds a callback to the list of subscribers - subscribe(cb) { - this._subs.push(cb); - } - - // removes a callback from the list of subscribers - unsubscribe(cb) { - const index = this._subs.indexOf(cb); - if (index > -1) { - this._subs.splice(index, 1); - } - } - - addResult(result) { - // result can be a boolean value or an object - let isValid = false; - if (isObject(result)) { - isValid = result.valid; - } else { - logger.error(`rule should return a valid object`); - } - - this.valid = this.valid && Boolean(isValid); - } - - reset() { - this.valid = true; - this.validating = false; - } - - validate() { - this.reset(); - this.validating = true; - this._subs.forEach(cb => { - try { - cb(); - } catch (err) { - logger.error(err); - } - }); - - return this.valid; - } -} - -const ValidationContext = React.createContext({}); - -export function Validation(props) { - const [validator] = React.useState(() => new Validator()); - // handle render functions - const children = - typeof props.children === 'function' - ? props.children({ validator }) - : props.children; - - return ( - - {children} - - ); -} - -export function useValidation() { - const value = React.useContext(ValidationContext); - if (!(value instanceof Validator)) { - logger.warn('Missing Validation Context declaration'); - } - - return value; -} diff --git a/web/packages/shared/components/Validation/Validation.test.tsx b/web/packages/shared/components/Validation/Validation.test.tsx index 19f40a8d44986..a933e9d916af9 100644 --- a/web/packages/shared/components/Validation/Validation.test.tsx +++ b/web/packages/shared/components/Validation/Validation.test.tsx @@ -17,32 +17,22 @@ */ import React from 'react'; +import { render, fireEvent, screen, act } from 'design/utils/testing'; -import { render, fireEvent, screen } from 'design/utils/testing'; +import Validator, { Result, Validation, useValidation } from './Validation'; -import Logger from '../../libs/logger'; - -import Validator, { Validation, useValidation } from './Validation'; - -jest.mock('../../libs/logger', () => { - const mockLogger = { - error: jest.fn(), - warn: jest.fn(), - }; - - return { - create: () => mockLogger, - }; +afterEach(() => { + jest.restoreAllMocks(); }); -test('methods of Validator: sub, unsub, validate', () => { +test('methods of Validator: addRuleCallback, removeRuleCallback, validate', () => { const mockCb1 = jest.fn(); const mockCb2 = jest.fn(); const validator = new Validator(); // test suscribe - validator.subscribe(mockCb1); - validator.subscribe(mockCb2); + validator.addRuleCallback(mockCb1); + validator.addRuleCallback(mockCb2); // test validate runs all subscribed cb's expect(validator.validate()).toBe(true); @@ -51,42 +41,42 @@ test('methods of Validator: sub, unsub, validate', () => { jest.clearAllMocks(); // test unsubscribe method removes correct cb - validator.unsubscribe(mockCb2); + validator.removeRuleCallback(mockCb2); expect(validator.validate()).toBe(true); expect(mockCb1).toHaveBeenCalledTimes(1); expect(mockCb2).toHaveBeenCalledTimes(0); }); test('methods of Validator: addResult, reset', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(); const validator = new Validator(); // test addResult for nil object const result = null; validator.addResult(result); - expect(Logger.create().error).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalledTimes(1); // test addResult for boolean validator.addResult(true); - expect(validator.valid).toBe(false); + expect(validator.state.valid).toBe(false); // test addResult with incorrect object - let resultObj = {}; - validator.addResult(resultObj); - expect(validator.valid).toBe(false); + validator.addResult({} as Result); + expect(validator.state.valid).toBe(false); // test addResult with correct object with "valid" prop from prior test set to false - resultObj = { valid: true }; + let resultObj = { valid: true }; validator.addResult(resultObj); - expect(validator.valid).toBe(false); + expect(validator.state.valid).toBe(false); // test reset validator.reset(); - expect(validator.valid).toBe(true); - expect(validator.validating).toBe(false); + expect(validator.state.valid).toBe(true); + expect(validator.state.validating).toBe(false); // test addResult with correct object with "valid" prop reset to true validator.addResult(resultObj); - expect(validator.valid).toBe(true); + expect(validator.state.valid).toBe(true); }); test('trigger validation via useValidation hook', () => { @@ -102,7 +92,7 @@ test('trigger validation via useValidation hook', () => { ); fireEvent.click(screen.getByRole('button')); - expect(validator.validating).toBe(true); + expect(validator.state.validating).toBe(true); }); test('trigger validation via render function', () => { @@ -122,5 +112,56 @@ test('trigger validation via render function', () => { ); fireEvent.click(screen.getByRole('button')); - expect(validator.validating).toBe(true); + expect(validator.state.validating).toBe(true); +}); + +test('rendering validation result via useValidation hook', () => { + let validator: Validator; + const TestComponent = () => { + validator = useValidation(); + return ( + <> +
Validating: {String(validator.state.validating)}
+
Valid: {String(validator.state.valid)}
+ + ); + }; + render( + + + + ); + validator.addRuleCallback(() => validator.addResult({ valid: false })); + + expect(screen.getByText('Validating: false')).toBeInTheDocument(); + expect(screen.getByText('Valid: true')).toBeInTheDocument(); + + act(() => validator.validate()); + expect(screen.getByText('Validating: true')).toBeInTheDocument(); + expect(screen.getByText('Valid: false')).toBeInTheDocument(); +}); + +test('rendering validation result via render function', () => { + let validator: Validator; + render( + + {props => { + validator = props.validator; + return ( + <> +
Validating: {String(validator.state.validating)}
+
Valid: {String(validator.state.valid)}
+ + ); + }} +
+ ); + validator.addRuleCallback(() => validator.addResult({ valid: false })); + + expect(screen.getByText('Validating: false')).toBeInTheDocument(); + expect(screen.getByText('Valid: true')).toBeInTheDocument(); + + act(() => validator.validate()); + expect(screen.getByText('Validating: true')).toBeInTheDocument(); + expect(screen.getByText('Valid: false')).toBeInTheDocument(); }); diff --git a/web/packages/shared/components/Validation/Validation.tsx b/web/packages/shared/components/Validation/Validation.tsx new file mode 100644 index 0000000000000..6450c2915a61d --- /dev/null +++ b/web/packages/shared/components/Validation/Validation.tsx @@ -0,0 +1,192 @@ +/* + * Teleport + * Copyright (C) 2023 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 from 'react'; + +import { Logger } from 'design/logger'; + +import { isObject } from 'shared/utils/highbar'; +import { Store, useStore } from 'shared/libs/stores'; + +import { ValidationResult } from './rules'; + +const logger = new Logger('validation'); + +/** A per-rule callback that will be executed during validation. */ +type RuleCallback = () => void; + +export type Result = ValidationResult | boolean; + +type ValidatorState = { + /** Indicates whether the last validation was successful. */ + valid: boolean; + /** + * Indicates whether the validator has been activated by a call to + * `validate`. + */ + validating: boolean; +}; + +/** A store that handles input validation and makes its results accessible. */ +export default class Validator extends Store { + state = { + valid: true, + validating: false, + }; + + /** + * @deprecated For temporary Enterprise compatibility only. Use {@link state} + * instead. + */ + valid = true; + + /** Callbacks that will be executed upon validation. */ + private ruleCallbacks: RuleCallback[] = []; + + /** Adds a rule callback that will be executed upon validation. */ + addRuleCallback(cb: RuleCallback) { + this.ruleCallbacks.push(cb); + } + + /** Removes a rule callback. */ + removeRuleCallback(cb: RuleCallback) { + const index = this.ruleCallbacks.indexOf(cb); + if (index > -1) { + this.ruleCallbacks.splice(index, 1); + } + } + + addResult(result: Result) { + // result can be a boolean value or an object + let isValid = false; + if (isObject(result)) { + isValid = result.valid; + } else { + logger.error(`rule should return a valid object`); + } + + const valid = this.state.valid && Boolean(isValid); + this.setState({ valid }); + this.valid = valid; + } + + reset() { + this.setState({ + valid: true, + validating: false, + }); + this.valid = true; + } + + validate() { + this.reset(); + this.setState({ validating: true }); + for (const cb of this.ruleCallbacks) { + try { + cb(); + } catch (err) { + logger.error(err); + } + } + + return this.state.valid; + } +} + +const ValidationContext = React.createContext(undefined); + +type ValidationRenderFunction = (arg: { + validator: Validator; +}) => React.ReactNode; + +/** + * Installs a validation context that provides a {@link Validator} store. The + * store can be retrieved either through {@link useValidation} hook or by a + * render callback, e.g.: + * + * ``` + * function Component() { + * return ( + * + * {({validator}) => ( + * <> + * (...) + * + * + * )} + * + * ); + * } + * ``` + * + * The simplest way to use validation is validating on the view layer: just use + * a `rule` prop with `FieldInput` or a similar component and pass a rule like + * `requiredField`. + * + * Unfortunately, due to architectural limitations, this will not work well in + * scenarios where information about validity about given field or group of + * fields is required outside that field. In cases like this, the best option + * is to validate the model during render time on the top level (for example, + * execute an entire set of rules on a model using `runRules`). The result of + * model validation will then contain information about the validity of each + * field. It can then be used wherever it's needed, and also attached to + * appropriate inputs with a `precomputed` validation rule. Example: + * + * ``` + * function Component(model: Model) { + * const rules = { + * name: requiredField('required'), + * email: requiredEmailLike, + * } + * const validationResult = runRules(model, rules); + * } + * ``` + * + * Note that, as this example shows clearly, the validator itself, despite its + * name, doesn't really validate anything -- it merely aggregates validation + * results. Also it's worth mentioning that the validator will not do it + * without our help -- each validated field needs to be actually attached to a + * field, even if using a `precomputed` rule, for this to work. The validation + * callbacks registered by validation rules on the particular fields are the + * actual points where the errors are consumed by the validator. + */ +export function Validation(props: { + children?: React.ReactNode | ValidationRenderFunction; +}) { + const [validator] = React.useState(() => new Validator()); + useStore(validator); + // handle render functions + const children = + typeof props.children === 'function' + ? props.children({ validator }) + : props.children; + + return ( + + {children} + + ); +} + +export function useValidation(): Validator { + const validator = React.useContext(ValidationContext); + if (!validator) { + throw new Error('useValidation() called without a validation context'); + } + return useStore(validator); +} diff --git a/web/packages/shared/components/Validation/rules.test.ts b/web/packages/shared/components/Validation/rules.test.ts index a07b16fb7aaa7..07ee1bf434d01 100644 --- a/web/packages/shared/components/Validation/rules.test.ts +++ b/web/packages/shared/components/Validation/rules.test.ts @@ -25,6 +25,8 @@ import { requiredEmailLike, requiredIamRoleName, requiredPort, + runRules, + arrayOf, } from './rules'; describe('requiredField', () => { @@ -153,3 +155,60 @@ describe('requiredPort', () => { expect(requiredPort(port)()).toEqual(expected); }); }); + +test('runRules', () => { + expect( + runRules( + { foo: 'val1', bar: 'val2', irrelevant: undefined }, + { foo: requiredField('no foo'), bar: requiredField('no bar') } + ) + ).toEqual({ + valid: true, + fields: { + foo: { valid: true, message: '' }, + bar: { valid: true, message: '' }, + }, + }); + + expect( + runRules( + { foo: '', bar: 'val2', irrelevant: undefined }, + { foo: requiredField('no foo'), bar: requiredField('no bar') } + ) + ).toEqual({ + valid: false, + fields: { + foo: { valid: false, message: 'no foo' }, + bar: { valid: true, message: '' }, + }, + }); +}); + +test.each([ + { + name: 'invalid', + items: ['a', '', 'c'], + expected: { + valid: false, + results: [ + { valid: true, message: '' }, + { valid: false, message: 'required' }, + { valid: true, message: '' }, + ], + }, + }, + { + name: 'valid', + items: ['a', 'b', 'c'], + expected: { + valid: true, + results: [ + { valid: true, message: '' }, + { valid: true, message: '' }, + { valid: true, message: '' }, + ], + }, + }, +])('arrayOf: $name', ({ items, expected }) => { + expect(arrayOf(requiredField('required'))(items)()).toEqual(expected); +}); diff --git a/web/packages/shared/components/Validation/rules.ts b/web/packages/shared/components/Validation/rules.ts index 52063d67fce99..545f28a348fce 100644 --- a/web/packages/shared/components/Validation/rules.ts +++ b/web/packages/shared/components/Validation/rules.ts @@ -31,6 +31,8 @@ export interface ValidationResult { */ export type Rule = (value: T) => () => R; +type RuleResult = ReturnType>; + /** * requiredField checks for empty strings and arrays. * @@ -280,6 +282,83 @@ const requiredAll = return { valid: true }; }; +/** A result of the {@link arrayOf} validation rule. */ +export type ArrayValidationResult = ValidationResult & { + /** Results of validating each separate item. */ + results: R[]; +}; + +/** Validates an array by executing given rule on each of its elements. */ +const arrayOf = + ( + elementRule: Rule + ): Rule> => + (values: T[]) => + () => { + const results = values.map(v => elementRule(v)()); + return { results: results, valid: results.every(r => r.valid) }; + }; + +/** + * Passes a precomputed validation result instead of computing it inside the + * rule. + * + * This rule is a hacky way to allow the validation engine to operate with + * validation results computed outside of the validator's validation cycle. See + * the `Validation` component's documentation for more information about where + * this is useful and a detailed usage example. + */ +const precomputed = + (res: ValidationResult): Rule => + () => + () => + res; + +/** + * A set of rules to be executed using `runRules` on a model object. The rule + * set contains a subset of keys of the object. + */ +export type RuleSet = Record< + K, + Rule +>; + +/** A result of executing a set of rules on a model object. */ +export type RuleSetValidationResult> = { + valid: boolean; + /** + * Each member of the `fields` object corresponds to a rule from within the + * rule set and contains the result of validating a model field of the same + * name. + */ + fields: { [k in keyof R]: RuleResult }; // Record; +}; + +/** + * Executes a set of rules on a model object, producing a precomputed + * validation result that can be used with `precomputed` rule to inject to + * field components, but also allows for consuming the validation data outside + * these fields. + * + * `K` is the subset of model field names. + * `M` is the validated model. + */ +export const runRules = >( + model: M, + rules: RuleSet +): RuleSetValidationResult> => { + const fields = {} as { + [k in keyof RuleSet]: RuleResult[k]>; + }; + let valid = true; + for (const key in rules) { + const modelValue = model[key]; + fields[key] = rules[key](modelValue)(); + valid &&= fields[key].valid; + } + return { fields, valid }; +}; + export { requiredToken, requiredPassword, @@ -292,4 +371,6 @@ export { requiredMatchingRoleNameAndRoleArn, validAwsIAMRoleName, requiredPort, + arrayOf, + precomputed, }; diff --git a/web/packages/shared/components/Validation/useRule.js b/web/packages/shared/components/Validation/useRule.js index ad0ca82157cbf..e8d2a77e391ae 100644 --- a/web/packages/shared/components/Validation/useRule.js +++ b/web/packages/shared/components/Validation/useRule.js @@ -39,7 +39,7 @@ export default function useRule(cb) { // register to validation context to be called on cb() React.useEffect(() => { function onValidate() { - if (validator.validating) { + if (validator.state.validating) { const result = cb(); validator.addResult(result); rerender({}); @@ -47,18 +47,18 @@ export default function useRule(cb) { } // subscribe to store changes - validator.subscribe(onValidate); + validator.addRuleCallback(onValidate); // unsubscribe on unmount function cleanup() { - validator.unsubscribe(onValidate); + validator.removeRuleCallback(onValidate); } return cleanup; }, [cb]); // if validation has been requested, cb right away. - if (validator.validating) { + if (validator.state.validating) { return cb(); } diff --git a/web/packages/teleport/src/Bots/List/Bots.tsx b/web/packages/teleport/src/Bots/List/Bots.tsx index be383fbd32997..594a1840d7c52 100644 --- a/web/packages/teleport/src/Bots/List/Bots.tsx +++ b/web/packages/teleport/src/Bots/List/Bots.tsx @@ -19,7 +19,7 @@ import React, { useEffect, useState } from 'react'; import { useAttemptNext } from 'shared/hooks'; import { Link } from 'react-router-dom'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; import { Alert, Box, Button, Indicator } from 'design'; import { diff --git a/web/packages/teleport/src/Console/DocumentKubeExec/KubeExecDataDialog.tsx b/web/packages/teleport/src/Console/DocumentKubeExec/KubeExecDataDialog.tsx index 36b8b7a505820..e8261cdf026c3 100644 --- a/web/packages/teleport/src/Console/DocumentKubeExec/KubeExecDataDialog.tsx +++ b/web/packages/teleport/src/Console/DocumentKubeExec/KubeExecDataDialog.tsx @@ -35,7 +35,7 @@ import { import Validation from 'shared/components/Validation'; import FieldInput from 'shared/components/FieldInput'; import { requiredField } from 'shared/components/Validation/rules'; -import { ToolTipInfo } from 'shared/components/ToolTip'; +import { IconTooltip } from 'design/Tooltip'; type Props = { onClose(): void; @@ -123,11 +123,11 @@ function KubeExecDataDialog({ onClose, onExec }: Props) { Interactive shell - + You can start an interactive shell and have a bidirectional communication with the target pod, or you can run one-off command and see its output. - +
diff --git a/web/packages/teleport/src/DesktopSession/TopBar.tsx b/web/packages/teleport/src/DesktopSession/TopBar.tsx index 9ab12b523067d..dcd5beaec4881 100644 --- a/web/packages/teleport/src/DesktopSession/TopBar.tsx +++ b/web/packages/teleport/src/DesktopSession/TopBar.tsx @@ -21,7 +21,7 @@ import { useTheme } from 'styled-components'; import { Text, TopNav, Flex } from 'design'; import { Clipboard, FolderShared } from 'design/Icon'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; import ActionMenu from './ActionMenu'; import { AlertDropdown } from './AlertDropdown'; diff --git a/web/packages/teleport/src/Discover/AwsMangementConsole/CreateAppAccess/CreateAppAccess.tsx b/web/packages/teleport/src/Discover/AwsMangementConsole/CreateAppAccess/CreateAppAccess.tsx index 4f0b1ea32146c..2c357df859a58 100644 --- a/web/packages/teleport/src/Discover/AwsMangementConsole/CreateAppAccess/CreateAppAccess.tsx +++ b/web/packages/teleport/src/Discover/AwsMangementConsole/CreateAppAccess/CreateAppAccess.tsx @@ -21,7 +21,7 @@ import styled from 'styled-components'; import { Box, Flex, Link, Mark, H3 } from 'design'; import TextEditor from 'shared/components/TextEditor'; import { Danger } from 'design/Alert'; -import { ToolTipInfo } from 'shared/components/ToolTip'; +import { IconTooltip } from 'design/Tooltip'; import { useAsync } from 'shared/hooks/useAsync'; import { P } from 'design/Text/Text'; @@ -81,7 +81,7 @@ export function CreateAppAccess() {

First configure your AWS IAM permissions

- + The following IAM permissions will be added as an inline policy named {IAM_POLICY_NAME} to IAM role{' '} {iamRoleName} @@ -94,7 +94,7 @@ export function CreateAppAccess() { /> - +

Run the command below on your{' '} diff --git a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSecurityGroups.tsx b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSecurityGroups.tsx index cc5ed4bb590b4..1717a082208ba 100644 --- a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSecurityGroups.tsx +++ b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSecurityGroups.tsx @@ -21,7 +21,7 @@ import React, { useState, useEffect } from 'react'; import { Text, Flex, Box, Indicator, ButtonSecondary, Subtitle3 } from 'design'; import * as Icons from 'design/Icon'; import { FetchStatus } from 'design/DataTable/types'; -import { HoverTooltip, ToolTipInfo } from 'shared/components/ToolTip'; +import { HoverTooltip, IconTooltip } from 'design/Tooltip'; import useAttempt from 'shared/hooks/useAttemptNext'; import { getErrMessage } from 'shared/utils/errorType'; import { pluralize } from 'shared/utils/text'; @@ -126,7 +126,7 @@ export const SelectSecurityGroups = ({ <> Select ECS Security Groups - + Select ECS security group(s) based on the following requirements:

    @@ -141,7 +141,7 @@ export const SelectSecurityGroups = ({
- +

diff --git a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSubnetIds.tsx b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSubnetIds.tsx index 785ec15fbda9e..8a6e93a0491b1 100644 --- a/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSubnetIds.tsx +++ b/web/packages/teleport/src/Discover/Database/DeployService/AutoDeploy/SelectSubnetIds.tsx @@ -29,7 +29,7 @@ import { } from 'design'; import * as Icons from 'design/Icon'; import { FetchStatus } from 'design/DataTable/types'; -import { HoverTooltip, ToolTipInfo } from 'shared/components/ToolTip'; +import { HoverTooltip, IconTooltip } from 'design/Tooltip'; import { pluralize } from 'shared/utils/text'; import useAttempt from 'shared/hooks/useAttemptNext'; import { getErrMessage } from 'shared/utils/errorType'; @@ -121,12 +121,12 @@ export function SelectSubnetIds({ <> Select ECS Subnets - + A subnet has an outbound internet route if it has a route to an internet gateway or a NAT gateway in a public subnet. - + diff --git a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AutoDiscoverToggle.tsx b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AutoDiscoverToggle.tsx index 204e30b3e79d1..617d10ba79790 100644 --- a/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AutoDiscoverToggle.tsx +++ b/web/packages/teleport/src/Discover/Database/EnrollRdsDatabase/AutoDiscoverToggle.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { Box, Toggle } from 'design'; -import { ToolTipInfo } from 'shared/components/ToolTip'; +import { IconTooltip } from 'design/Tooltip'; export function AutoDiscoverToggle({ wantAutoDiscover, @@ -40,11 +40,11 @@ export function AutoDiscoverToggle({ Auto-enroll all databases for the selected VPC - + Auto-enroll will automatically identify all RDS databases (e.g. PostgreSQL, MySQL, Aurora) from the selected VPC and register them as database resources in your infrastructure. - + ); diff --git a/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEksCluster.tsx b/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEksCluster.tsx index e73fc6dfc3e15..2505da7275658 100644 --- a/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEksCluster.tsx +++ b/web/packages/teleport/src/Discover/Kubernetes/EnrollEKSCluster/EnrollEksCluster.tsx @@ -31,7 +31,7 @@ import { FetchStatus } from 'design/DataTable/types'; import { Danger } from 'design/Alert'; import useAttempt from 'shared/hooks/useAttemptNext'; -import { ToolTipInfo } from 'shared/components/ToolTip'; +import { IconTooltip } from 'design/Tooltip'; import { getErrMessage } from 'shared/utils/errorType'; import { EksMeta, useDiscover } from 'teleport/Discover/useDiscover'; @@ -435,11 +435,11 @@ export function EnrollEksCluster(props: AgentStepProps) { Enable Kubernetes App Discovery - + Teleport's Kubernetes App Discovery will automatically identify and enroll to Teleport HTTP applications running inside a Kubernetes cluster. - + Auto-enroll all EKS clusters for selected region - + Auto-enroll will automatically identify all EKS clusters from the selected region and register them as Kubernetes resources in your infrastructure. - + {showTable && ( diff --git a/web/packages/teleport/src/Discover/Server/DiscoveryConfigSsm/DiscoveryConfigSsm.tsx b/web/packages/teleport/src/Discover/Server/DiscoveryConfigSsm/DiscoveryConfigSsm.tsx index 6a7245bacf798..6846a09779e53 100644 --- a/web/packages/teleport/src/Discover/Server/DiscoveryConfigSsm/DiscoveryConfigSsm.tsx +++ b/web/packages/teleport/src/Discover/Server/DiscoveryConfigSsm/DiscoveryConfigSsm.tsx @@ -30,7 +30,7 @@ import { import styled from 'styled-components'; import { Danger, Info } from 'design/Alert'; import TextEditor from 'shared/components/TextEditor'; -import { ToolTipInfo } from 'shared/components/ToolTip'; +import { IconTooltip } from 'design/Tooltip'; import FieldInput from 'shared/components/FieldInput'; import { Rule } from 'shared/components/Validation/rules'; import Validation, { Validator } from 'shared/components/Validation'; @@ -317,7 +317,7 @@ export function DiscoveryConfigSsm() { {' '} to configure your IAM permissions.

- + The following IAM permissions will be added as an inline policy named {IAM_POLICY_NAME} to IAM role{' '} {arnResourceName} @@ -330,7 +330,7 @@ export function DiscoveryConfigSsm() { /> - + Auto-enroll all EC2 instances for selected region - + Auto-enroll will automatically identify all EC2 instances from the selected region and register them as node resources in your infrastructure. - + {wantAutoDiscover && ( diff --git a/web/packages/teleport/src/Discover/Shared/Aws/ConfigureIamPerms.tsx b/web/packages/teleport/src/Discover/Shared/Aws/ConfigureIamPerms.tsx index 4c491191152b8..0c244462b7507 100644 --- a/web/packages/teleport/src/Discover/Shared/Aws/ConfigureIamPerms.tsx +++ b/web/packages/teleport/src/Discover/Shared/Aws/ConfigureIamPerms.tsx @@ -21,7 +21,7 @@ import styled from 'styled-components'; import { Flex, Link, Box, H3 } from 'design'; import { assertUnreachable } from 'shared/utils/assertUnreachable'; import TextEditor from 'shared/components/TextEditor'; -import { ToolTipInfo } from 'shared/components/ToolTip'; +import { IconTooltip } from 'design/Tooltip'; import { P } from 'design/Text/Text'; @@ -179,11 +179,11 @@ export function ConfigureIamPerms({ <>

Configure your AWS IAM permissions

- + The following IAM permissions will be added as an inline policy named {iamPolicyName} to IAM role {iamRoleName} {editor} - +

{msg} Run the command below on your{' '} diff --git a/web/packages/teleport/src/Discover/Shared/ConfigureDiscoveryService/ConfigureDiscoveryServiceDirections.tsx b/web/packages/teleport/src/Discover/Shared/ConfigureDiscoveryService/ConfigureDiscoveryServiceDirections.tsx index 9017c990205f4..1b56b2d69e270 100644 --- a/web/packages/teleport/src/Discover/Shared/ConfigureDiscoveryService/ConfigureDiscoveryServiceDirections.tsx +++ b/web/packages/teleport/src/Discover/Shared/ConfigureDiscoveryService/ConfigureDiscoveryServiceDirections.tsx @@ -18,7 +18,7 @@ import { Box, Flex, Input, Text, Mark, H3, Subtitle3 } from 'design'; import styled from 'styled-components'; -import { ToolTipInfo } from 'shared/components/ToolTip'; +import { IconTooltip } from 'design/Tooltip'; import React from 'react'; @@ -71,7 +71,7 @@ discovery_service: Auto-enrolling requires you to configure a{' '} Discovery Service - +
@@ -100,7 +100,7 @@ discovery_service:

Step 2

Define a Discovery Group name{' '} - + diff --git a/web/packages/teleport/src/Discover/Shared/SecurityGroupPicker/SecurityGroupPicker.tsx b/web/packages/teleport/src/Discover/Shared/SecurityGroupPicker/SecurityGroupPicker.tsx index 7b66604e54a51..890eeee6cc60a 100644 --- a/web/packages/teleport/src/Discover/Shared/SecurityGroupPicker/SecurityGroupPicker.tsx +++ b/web/packages/teleport/src/Discover/Shared/SecurityGroupPicker/SecurityGroupPicker.tsx @@ -23,7 +23,7 @@ import Table, { Cell } from 'design/DataTable'; import { Danger } from 'design/Alert'; import { CheckboxInput } from 'design/Checkbox'; import { FetchStatus } from 'design/DataTable/types'; -import { ToolTipInfo } from 'shared/components/ToolTip'; +import { IconTooltip } from 'design/Tooltip'; import { Attempt } from 'shared/hooks/useAttemptNext'; @@ -163,13 +163,13 @@ export const SecurityGroupPicker = ({ if (sg.recommended && sg.tips?.length) { return ( - +
    {sg.tips.map((tip, index) => (
  • {tip}
  • ))}
-
+
); } diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/ConfigureAwsOidcSummary.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/ConfigureAwsOidcSummary.tsx index aecd67c00d114..b99521e8719ae 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/ConfigureAwsOidcSummary.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/ConfigureAwsOidcSummary.tsx @@ -20,7 +20,7 @@ import React from 'react'; import styled from 'styled-components'; import { Flex, Box, H3, Text } from 'design'; import TextEditor from 'shared/components/TextEditor'; -import { ToolTipInfo } from 'shared/components/ToolTip'; +import { IconTooltip } from 'design/Tooltip'; import useStickyClusterId from 'teleport/useStickyClusterId'; @@ -61,7 +61,7 @@ export function ConfigureAwsOidcSummary({ }`; return ( - +

Running the command in AWS CloudShell does the following:

1. Configures an AWS IAM OIDC Identity Provider (IdP) @@ -76,7 +76,7 @@ export function ConfigureAwsOidcSummary({ /> -
+ ); } diff --git a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/S3BucketConfiguration.tsx b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/S3BucketConfiguration.tsx index a225196d65dfc..47452f3aa720e 100644 --- a/web/packages/teleport/src/Integrations/Enroll/AwsOidc/S3BucketConfiguration.tsx +++ b/web/packages/teleport/src/Integrations/Enroll/AwsOidc/S3BucketConfiguration.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { Text, Flex } from 'design'; import FieldInput from 'shared/components/FieldInput'; -import { ToolTipInfo } from 'shared/components/ToolTip'; +import { IconTooltip } from 'design/Tooltip'; export function S3BucketConfiguration({ s3Bucket, @@ -32,11 +32,11 @@ export function S3BucketConfiguration({ <> Amazon S3 Location - + Deprecated. Amazon is now validating the IdP certificate against a list of root CAs. Storing the OpenID Configuration in S3 is no longer required, and should be removed to improve security. - + { {getStatusCodeTitle(item.statusCode)} {statusDescription && ( - {statusDescription} + {statusDescription} )} diff --git a/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcHeader.tsx b/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcHeader.tsx index 8d95d16a691c4..773efe7beb610 100644 --- a/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcHeader.tsx +++ b/web/packages/teleport/src/Integrations/status/AwsOidc/AwsOidcHeader.tsx @@ -21,7 +21,7 @@ import { Link as InternalLink } from 'react-router-dom'; import { ButtonIcon, Flex, Label, Text } from 'design'; import { ArrowLeft } from 'design/Icon'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; import cfg from 'teleport/config'; import { getStatusAndLabel } from 'teleport/Integrations/helpers'; diff --git a/web/packages/teleport/src/JoinTokens/JoinTokens.tsx b/web/packages/teleport/src/JoinTokens/JoinTokens.tsx index 46e9100feab8b..862085cfd0157 100644 --- a/web/packages/teleport/src/JoinTokens/JoinTokens.tsx +++ b/web/packages/teleport/src/JoinTokens/JoinTokens.tsx @@ -42,7 +42,7 @@ import Dialog, { } from 'design/Dialog'; import { MenuButton } from 'shared/components/MenuAction'; import { Attempt, useAsync } from 'shared/hooks/useAsync'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; import { CopyButton } from 'shared/components/UnifiedResources/shared/CopyButton'; import { useTeleport } from 'teleport'; diff --git a/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx b/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx index 6daef672649c6..357c6a3d59471 100644 --- a/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx +++ b/web/packages/teleport/src/JoinTokens/UpsertJoinTokenDialog.tsx @@ -29,7 +29,7 @@ import { Alert, } from 'design'; import styled from 'styled-components'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; import { Cross } from 'design/Icon'; import Validation from 'shared/components/Validation'; import FieldInput from 'shared/components/FieldInput'; diff --git a/web/packages/teleport/src/Navigation/Navigation.tsx b/web/packages/teleport/src/Navigation/Navigation.tsx index 6450c575114bf..e50295ea5a1f9 100644 --- a/web/packages/teleport/src/Navigation/Navigation.tsx +++ b/web/packages/teleport/src/Navigation/Navigation.tsx @@ -21,7 +21,7 @@ import styled, { useTheme } from 'styled-components'; import { matchPath, useLocation, useHistory } from 'react-router'; import { Box, Text, Flex } from 'design'; -import { ToolTipInfo } from 'shared/components/ToolTip'; +import { IconTooltip } from 'design/Tooltip'; import cfg from 'teleport/config'; import { @@ -195,9 +195,9 @@ function LicenseFooter({ {title} - + {infoContent} - + {subText} diff --git a/web/packages/teleport/src/Navigation/SideNavigation/Section.tsx b/web/packages/teleport/src/Navigation/SideNavigation/Section.tsx index 8217106d5fd20..eb9d10c111b82 100644 --- a/web/packages/teleport/src/Navigation/SideNavigation/Section.tsx +++ b/web/packages/teleport/src/Navigation/SideNavigation/Section.tsx @@ -23,7 +23,7 @@ import styled, { css, useTheme } from 'styled-components'; import { Box, ButtonIcon, Flex, P2, Text } from 'design'; import { Theme } from 'design/theme'; import { ArrowLineLeft } from 'design/Icon'; -import { HoverTooltip, ToolTipInfo } from 'shared/components/ToolTip'; +import { HoverTooltip, IconTooltip } from 'design/Tooltip'; import cfg from 'teleport/config'; @@ -470,9 +470,9 @@ function LicenseFooter({ {title} - + {infoContent} - + {subText} diff --git a/web/packages/teleport/src/Notifications/Notifications.tsx b/web/packages/teleport/src/Notifications/Notifications.tsx index ada3dd3761af1..b64d1460e8041 100644 --- a/web/packages/teleport/src/Notifications/Notifications.tsx +++ b/web/packages/teleport/src/Notifications/Notifications.tsx @@ -24,7 +24,7 @@ import { Alert, Box, Flex, Indicator, Text } from 'design'; import { Notification as NotificationIcon, BellRinging } from 'design/Icon'; import Logger from 'shared/libs/logger'; import { useRefClickOutside } from 'shared/hooks/useRefClickOutside'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; import { useInfiniteScroll, diff --git a/web/packages/teleport/src/Roles/RoleEditor/EditorHeader.tsx b/web/packages/teleport/src/Roles/RoleEditor/EditorHeader.tsx index 37059aee38594..541e6f08bfefa 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/EditorHeader.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/EditorHeader.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { Flex, ButtonText, H2 } from 'design'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; import { Trash } from 'design/Icon'; import useTeleport from 'teleport/useTeleport'; diff --git a/web/packages/teleport/src/Roles/RoleEditor/Shared.tsx b/web/packages/teleport/src/Roles/RoleEditor/Shared.tsx index e6cece6752920..3652e87a537ca 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/Shared.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/Shared.tsx @@ -17,7 +17,7 @@ */ import { Box, ButtonPrimary, ButtonSecondary, Flex } from 'design'; -import { HoverTooltip } from 'shared/components/ToolTip'; +import { HoverTooltip } from 'design/Tooltip'; import useTeleport from 'teleport/useTeleport'; diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx index 01789e1f2f837..9eefd10718705 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx @@ -31,7 +31,7 @@ import FieldInput from 'shared/components/FieldInput'; import Validation, { Validator } from 'shared/components/Validation'; import { requiredField } from 'shared/components/Validation/rules'; import * as Icon from 'design/Icon'; -import { HoverTooltip, ToolTipInfo } from 'shared/components/ToolTip'; +import { HoverTooltip, IconTooltip } from 'design/Tooltip'; import styled, { useTheme } from 'styled-components'; import { MenuButton, MenuItem } from 'shared/components/MenuAction'; @@ -326,7 +326,7 @@ const Section = ({ {/* TODO(bl-nero): Show validation result in the summary. */}

{title}

- {tooltip && {tooltip}} + {tooltip && {tooltip}}
{removable && ( expect(screen.getByPlaceholderText('label key')).toBeInTheDocument(); expect(screen.getByPlaceholderText('label value')).toBeInTheDocument(); }); + +describe('validation rules', () => { + function setup(labels: Label[], rule: LabelsRule) { + let validator: Validator; + render( + + {({ validator: v }) => { + validator = v; + return ( + {}} rule={rule} /> + ); + }} + + ); + return { validator }; + } + + describe.each([ + { name: 'explicitly enforced standard rule', rule: nonEmptyLabels }, + { name: 'implicit standard rule', rule: undefined }, + ])('$name', ({ rule }) => { + test('invalid', () => { + const { validator } = setup( + [ + { name: '', value: 'foo' }, + { name: 'bar', value: '' }, + { name: 'asdf', value: 'qwer' }, + ], + rule + ); + act(() => validator.validate()); + expect(validator.state.valid).toBe(false); + expect(screen.getAllByRole('textbox')[0]).toHaveAccessibleDescription( + 'required' + ); // '' + expect(screen.getAllByRole('textbox')[1]).toHaveAccessibleDescription(''); // 'foo' + expect(screen.getAllByRole('textbox')[2]).toHaveAccessibleDescription(''); // 'bar' + expect(screen.getAllByRole('textbox')[3]).toHaveAccessibleDescription( + 'required' + ); // '' + expect(screen.getAllByRole('textbox')[4]).toHaveAccessibleDescription(''); // 'asdf' + expect(screen.getAllByRole('textbox')[5]).toHaveAccessibleDescription(''); // 'qwer' + }); + + test('valid', () => { + const { validator } = setup( + [ + { name: '', value: 'foo' }, + { name: 'bar', value: '' }, + { name: 'asdf', value: 'qwer' }, + ], + rule + ); + act(() => validator.validate()); + expect(validator.state.valid).toBe(false); + expect(screen.getAllByRole('textbox')[0]).toHaveAccessibleDescription( + 'required' + ); // '' + expect(screen.getAllByRole('textbox')[1]).toHaveAccessibleDescription(''); // 'foo' + expect(screen.getAllByRole('textbox')[2]).toHaveAccessibleDescription(''); // 'bar' + expect(screen.getAllByRole('textbox')[3]).toHaveAccessibleDescription( + 'required' + ); // '' + expect(screen.getAllByRole('textbox')[4]).toHaveAccessibleDescription(''); // 'asdf' + expect(screen.getAllByRole('textbox')[5]).toHaveAccessibleDescription(''); // 'qwer' + }); + }); + + const nameNotFoo: LabelsRule = (labels: Label[]) => () => { + const results = labels.map(label => ({ + name: + label.name === 'foo' + ? { valid: false, message: 'no foo please' } + : { valid: true }, + value: { valid: true }, + })); + return { + valid: results.every(r => r.name.valid && r.value.valid), + results: results, + }; + }; + + test('custom rule, invalid', async () => { + const { validator } = setup( + [ + { name: 'foo', value: 'bar' }, + { name: 'bar', value: 'foo' }, + ], + nameNotFoo + ); + act(() => validator.validate()); + expect(validator.state.valid).toBe(false); + expect(screen.getAllByRole('textbox')[0]).toHaveAccessibleDescription( + 'no foo please' + ); // 'foo' key + expect(screen.getAllByRole('textbox')[1]).toHaveAccessibleDescription(''); + expect(screen.getAllByRole('textbox')[2]).toHaveAccessibleDescription(''); + expect(screen.getAllByRole('textbox')[3]).toHaveAccessibleDescription(''); + }); + + test('custom rule, valid', async () => { + const { validator } = setup( + [ + { name: 'xyz', value: 'bar' }, + { name: 'bar', value: 'foo' }, + ], + nameNotFoo + ); + act(() => validator.validate()); + expect(validator.state.valid).toBe(true); + expect(screen.getAllByRole('textbox')[0]).toHaveAccessibleDescription(''); + expect(screen.getAllByRole('textbox')[1]).toHaveAccessibleDescription(''); + expect(screen.getAllByRole('textbox')[2]).toHaveAccessibleDescription(''); + expect(screen.getAllByRole('textbox')[3]).toHaveAccessibleDescription(''); + }); +}); diff --git a/web/packages/teleport/src/components/LabelsInput/LabelsInput.tsx b/web/packages/teleport/src/components/LabelsInput/LabelsInput.tsx index f163d7df0e0de..eee6025249817 100644 --- a/web/packages/teleport/src/components/LabelsInput/LabelsInput.tsx +++ b/web/packages/teleport/src/components/LabelsInput/LabelsInput.tsx @@ -19,8 +19,17 @@ import styled from 'styled-components'; import { Flex, Box, ButtonSecondary, ButtonIcon } from 'design'; import FieldInput from 'shared/components/FieldInput'; -import { Validator, useValidation } from 'shared/components/Validation'; -import { requiredField } from 'shared/components/Validation/rules'; +import { + Validator, + useRule, + useValidation, +} from 'shared/components/Validation'; +import { + precomputed, + requiredField, + Rule, + ValidationResult, +} from 'shared/components/Validation/rules'; import * as Icons from 'design/Icon'; import { inputGeometry } from 'design/Input/Input'; @@ -34,6 +43,24 @@ export type LabelInputTexts = { placeholder: string; }; +type LabelListValidationResult = ValidationResult & { + /** + * A list of validation results, one per label. Note: items are optional just + * because `useRule` by default returns only `ValidationResult`. For the + * actual validation, it's not optional; if it's undefined, or there are + * fewer items in this list than the labels, a default validation rule will + * be used instead. + */ + results?: LabelValidationResult[]; +}; + +type LabelValidationResult = { + name: ValidationResult; + value: ValidationResult; +}; + +export type LabelsRule = Rule; + export function LabelsInput({ labels = [], setLabels, @@ -44,6 +71,7 @@ export function LabelsInput({ labelKey = { fieldName: 'Key', placeholder: 'label key' }, labelVal = { fieldName: 'Value', placeholder: 'label value' }, inputWidth = 200, + rule = defaultRule, }: { labels: Label[]; setLabels(l: Label[]): void; @@ -57,8 +85,15 @@ export function LabelsInput({ * Makes it so at least one label is required */ areLabelsRequired?: boolean; + /** + * A rule for validating the list of labels as a whole. Note that contrary to + * other input fields, the labels input will default to validating every + * input as required if this property is undefined. + */ + rule?: LabelsRule; }) { const validator = useValidation() as Validator; + const validationResult: LabelListValidationResult = useRule(rule(labels)); function addLabel() { setLabels([...labels, { name: '', value: '' }]); @@ -92,11 +127,8 @@ export function LabelsInput({ setLabels(newList); }; - const requiredUniqueKey = value => () => { + const requiredKey = value => () => { // Check for empty length and duplicate key. - // TODO(bl-nero): This function doesn't really check for uniqueness; it - // needs to be fixed. This control should probably be merged with - // `LabelsCreater`, which has this feature working correctly. let notValid = !value || value.length === 0; return { @@ -121,12 +153,18 @@ export function LabelsInput({ )} {labels.map((label, index) => { + const validationItem: LabelValidationResult | undefined = + validationResult.results?.[index]; return ( () => ({ valid: true }); + const SmallText = styled.span` font-size: ${p => p.theme.fontSizes[1]}px; font-weight: lighter; `; + +export const nonEmptyLabels: LabelsRule = labels => () => { + const results = labels.map(label => ({ + name: requiredField('required')(label.name)(), + value: requiredField('required')(label.value)(), + })); + return { + valid: results.every(r => r.name.valid && r.value.valid), + results: results, + }; +};