From 58aab61b4193ba810e9f2751882bb247b5e7b22c Mon Sep 17 00:00:00 2001 From: Mohamed Khelif Date: Tue, 7 Nov 2023 12:23:25 -0500 Subject: [PATCH] DEVPROD-942 Support regular expression search on configure tasks page (#2132) --- .../HistoryTableTestSearch.tsx | 49 ++------- .../TextInputWithGlyph.stories.tsx | 1 + src/components/TextInputWithGlyph/index.tsx | 5 +- .../TextInputWithValidation.stories.tsx | 16 +++ .../TextInputWithValidation.test.tsx | 77 +++++++++++++++ .../TextInputWithValidation/index.tsx | 99 +++++++++++++++++++ .../TupleSelect/TupleSelect.test.tsx | 4 +- src/components/TupleSelect/index.tsx | 46 ++------- .../TupleSelectWithRegexConditional.test.tsx | 4 +- .../ConfigureTasks/index.tsx | 15 ++- .../ConfigureTasks/utils.test.ts | 12 +-- .../ConfigureTasks/utils.ts | 4 +- 12 files changed, 233 insertions(+), 99 deletions(-) create mode 100644 src/components/TextInputWithValidation/TextInputWithValidation.stories.tsx create mode 100644 src/components/TextInputWithValidation/TextInputWithValidation.test.tsx create mode 100644 src/components/TextInputWithValidation/index.tsx diff --git a/src/components/HistoryTable/HistoryTableTestSearch/HistoryTableTestSearch.tsx b/src/components/HistoryTable/HistoryTableTestSearch/HistoryTableTestSearch.tsx index fd8d0b73fb..1be815ca89 100644 --- a/src/components/HistoryTable/HistoryTableTestSearch/HistoryTableTestSearch.tsx +++ b/src/components/HistoryTable/HistoryTableTestSearch/HistoryTableTestSearch.tsx @@ -1,16 +1,10 @@ -import { useState } from "react"; import styled from "@emotion/styled"; -import IconButton from "@leafygreen-ui/icon-button"; -import { palette } from "@leafygreen-ui/palette"; -import Icon from "components/Icon"; -import IconTooltip from "components/IconTooltip"; -import TextInput from "components/TextInputWithGlyph"; +import TextInput from "components/TextInputWithValidation"; import { useUpsertQueryParams } from "hooks"; import { TestStatus } from "types/history"; import { validators } from "utils"; const { validateRegexp } = validators; -const { yellow } = palette; interface HistoryTableTestSearchProps { onSubmit?: () => void; @@ -19,19 +13,11 @@ interface HistoryTableTestSearchProps { export const HistoryTableTestSearch: React.FC = ({ onSubmit = () => {}, }) => { - const [input, setInput] = useState(""); - const isValid = validateRegexp(input); const handleSubmit = useUpsertQueryParams(); - const handleOnChange = (value: string) => { - setInput(value); - }; - const handleOnSubmit = () => { - if (isValid) { - onSubmit(); - handleSubmit({ category: TestStatus.Failed, value: input }); - setInput(""); - } + const handleOnSubmit = (input: string) => { + onSubmit(); + handleSubmit({ category: TestStatus.Failed, value: input }); }; return ( @@ -40,30 +26,11 @@ export const HistoryTableTestSearch: React.FC = ({ type="search" label="Filter by Failed Tests" aria-label="history-table-test-search-input" - value={input} placeholder="Search test name regex" - onChange={(e) => handleOnChange(e.target.value)} - onKeyPress={(e: React.KeyboardEvent) => - e.key === "Enter" && handleOnSubmit() - } - icon={ - isValid ? ( - - - - ) : ( - - Invalid Regular Expression - - ) - } + validatorErrorMessage="Invalid regular expression" + onSubmit={handleOnSubmit} + validator={validateRegexp} + clearOnSubmit /> ); diff --git a/src/components/TextInputWithGlyph/TextInputWithGlyph.stories.tsx b/src/components/TextInputWithGlyph/TextInputWithGlyph.stories.tsx index 21753e3f51..d48a190661 100644 --- a/src/components/TextInputWithGlyph/TextInputWithGlyph.stories.tsx +++ b/src/components/TextInputWithGlyph/TextInputWithGlyph.stories.tsx @@ -5,6 +5,7 @@ import { CustomStoryObj, CustomMeta } from "test_utils/types"; import TextInputWithGlyph from "."; export default { + title: "Components/TextInput/TextInputWithGlyph", component: TextInputWithGlyph, } satisfies CustomMeta; diff --git a/src/components/TextInputWithGlyph/index.tsx b/src/components/TextInputWithGlyph/index.tsx index c9bfa9f212..f85b6800f3 100644 --- a/src/components/TextInputWithGlyph/index.tsx +++ b/src/components/TextInputWithGlyph/index.tsx @@ -9,10 +9,10 @@ type TextInputWithGlyphProps = { const TextInputWithGlyph: React.FC = forwardRef( (props, ref) => { - const { icon, ...rest } = props; + const { className, icon, ...rest } = props; return ( - + {icon} @@ -38,4 +38,5 @@ const IconWrapper = styled.div` justify-content: center; `; +export type { TextInputWithGlyphProps }; export default TextInputWithGlyph; diff --git a/src/components/TextInputWithValidation/TextInputWithValidation.stories.tsx b/src/components/TextInputWithValidation/TextInputWithValidation.stories.tsx new file mode 100644 index 0000000000..2b1d61246c --- /dev/null +++ b/src/components/TextInputWithValidation/TextInputWithValidation.stories.tsx @@ -0,0 +1,16 @@ +import { CustomMeta, CustomStoryObj } from "test_utils/types"; +import TextInputWithValidation from "."; + +export default { + title: "Components/TextInput/TextInputWithValidation", + component: TextInputWithValidation, +} satisfies CustomMeta; + +export const Default: CustomStoryObj = { + render: (args) => , + argTypes: {}, + args: { + validator: (v) => v !== "bad", + label: "Some search field", + }, +}; diff --git a/src/components/TextInputWithValidation/TextInputWithValidation.test.tsx b/src/components/TextInputWithValidation/TextInputWithValidation.test.tsx new file mode 100644 index 0000000000..a9e5e843a1 --- /dev/null +++ b/src/components/TextInputWithValidation/TextInputWithValidation.test.tsx @@ -0,0 +1,77 @@ +import { render, screen, userEvent } from "test_utils"; +import TextInputWithValidation from "."; + +describe("textInputWithValidation", () => { + it("should not be able to submit with an invalid input", async () => { + const user = userEvent.setup(); + const onSubmit = jest.fn(); + render( + v.length > 5} + /> + ); + const input = screen.getByRole("textbox", { name: "textinput" }); + await user.type(input, "test"); + expect(input).toHaveValue("test"); + await user.type(input, "{enter}"); + expect(onSubmit).not.toHaveBeenCalledWith("test"); + }); + it("should not validate without a validation function", async () => { + const user = userEvent.setup(); + const onSubmit = jest.fn(); + render( + + ); + const input = screen.getByRole("textbox", { name: "textinput" }); + await user.type(input, "test"); + await user.type(input, "{enter}"); + expect(onSubmit).toHaveBeenCalledWith("test"); + }); + it("should call onChange only for valid inputs", async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render( + v.length >= 5} + /> + ); + const input = screen.getByRole("textbox", { name: "textinput" }); + await user.type(input, "test"); + expect(onChange).not.toHaveBeenCalledWith("test"); + await user.type(input, "5"); + expect(onChange).toHaveBeenCalledWith("test5"); + }); + it("clearOnSubmit should clear the input after a valid input is submitted", async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + const onSubmit = jest.fn(); + render( + v.length >= 5} + onSubmit={onSubmit} + clearOnSubmit + /> + ); + const input = screen.getByRole("textbox", { name: "textinput" }); + await user.type(input, "test"); + expect(onChange).not.toHaveBeenCalledWith("test"); + await user.type(input, "5"); + expect(onChange).toHaveBeenCalledWith("test5"); + await user.type(input, "{enter}"); + expect(input).toHaveValue(""); + expect(onSubmit).toHaveBeenCalledWith("test5"); + }); +}); diff --git a/src/components/TextInputWithValidation/index.tsx b/src/components/TextInputWithValidation/index.tsx new file mode 100644 index 0000000000..2984b9d247 --- /dev/null +++ b/src/components/TextInputWithValidation/index.tsx @@ -0,0 +1,99 @@ +import { useState, forwardRef } from "react"; +import IconButton from "@leafygreen-ui/icon-button"; +import { palette } from "@leafygreen-ui/palette"; +import Icon from "components/Icon"; +import IconTooltip from "components/IconTooltip"; +import TextInputWithGlyph from "components/TextInputWithGlyph"; +import type { TextInputWithGlyphProps } from "components/TextInputWithGlyph"; + +const { yellow } = palette; +type TextInputWithValidationProps = { + /** + * `onSubmit` will be called when the user submits a new input with the enter key or the plus button + * if the input is valid + * @param value - the value of the input + * @returns void + */ + onSubmit?: (value: string) => void; + validator?: (value: string) => boolean; + /** + * `onChange` will be called when the user types into the input and the input is valid + * @param value - the value of the input + * @returns void + */ + onChange?: (value: string) => void; + validatorErrorMessage?: string; + placeholder?: string; + /** + * If true, the input will be cleared when the user submits a new input + */ + clearOnSubmit?: boolean; +} & Omit; + +const TextInputWithValidation: React.FC = + forwardRef((props, ref) => { + const { + "aria-label": ariaLabel, + clearOnSubmit = false, + label, + onChange = () => {}, + onSubmit = () => {}, + validator = () => true, + validatorErrorMessage = "Invalid input", + ...rest + } = props; + + const [input, setInput] = useState(""); + const isValid = validator(input); + + const handleOnSubmit = () => { + if (isValid) { + onSubmit(input); + if (clearOnSubmit) { + setInput(""); + } + } + }; + + const handleOnChange = (value: string) => { + if (validator(value)) { + onChange(value); + } + setInput(value); + }; + + return ( + handleOnChange(e.target.value)} + onKeyPress={(e: React.KeyboardEvent) => + e.key === "Enter" && handleOnSubmit() + } + label={label} + aria-label={ariaLabel} + ref={ref} + icon={ + isValid ? ( + + + + ) : ( + + {validatorErrorMessage} + + ) + } + {...rest} + /> + ); + }); + +TextInputWithValidation.displayName = "TextInputWithValidation"; +export default TextInputWithValidation; diff --git a/src/components/TupleSelect/TupleSelect.test.tsx b/src/components/TupleSelect/TupleSelect.test.tsx index 21d6731855..172ed3cf8f 100644 --- a/src/components/TupleSelect/TupleSelect.test.tsx +++ b/src/components/TupleSelect/TupleSelect.test.tsx @@ -78,8 +78,8 @@ describe("tupleSelect", () => { expect(input).toHaveValue("bad"); expect(onSubmit).not.toHaveBeenCalled(); expect(validator).toHaveBeenLastCalledWith("bad"); - expect(screen.getByDataCy("tuple-select-warning")).toBeInTheDocument(); - await user.hover(screen.queryByDataCy("tuple-select-warning")); + expect(screen.getByLabelText("validation error")).toBeInTheDocument(); + await user.hover(screen.getByLabelText("validation error")); await screen.findByText(validatorErrorMessage); }); }); diff --git a/src/components/TupleSelect/index.tsx b/src/components/TupleSelect/index.tsx index 5b907b1091..3a9140b092 100644 --- a/src/components/TupleSelect/index.tsx +++ b/src/components/TupleSelect/index.tsx @@ -1,15 +1,10 @@ import { useState } from "react"; import styled from "@emotion/styled"; -import IconButton from "@leafygreen-ui/icon-button"; -import { palette } from "@leafygreen-ui/palette"; import { Select, Option } from "@leafygreen-ui/select"; import { Label } from "@leafygreen-ui/typography"; -import Icon from "components/Icon"; -import IconTooltip from "components/IconTooltip"; -import TextInput from "components/TextInputWithGlyph"; +import TextInput from "components/TextInputWithValidation"; import { size } from "constants/tokens"; -const { yellow } = palette; type option = { value: string; displayName: string; @@ -30,20 +25,12 @@ const TupleSelect: React.FC = ({ validator = () => true, validatorErrorMessage = "Invalid Input", }) => { - const [input, setInput] = useState(""); const [selected, setSelected] = useState(options[0].value); - const isValid = validator(input); - const handleOnSubmit = () => { - if (isValid) { - onSubmit({ category: selected, value: input }); - setInput(""); - } + const handleOnSubmit = (input: string) => { + onSubmit({ category: selected, value: input }); }; - const handleOnChange = (value: string) => { - setInput(value); - }; const selectedOption = options.find((o) => o.value === selected); return ( @@ -73,31 +60,12 @@ const TupleSelect: React.FC = ({ id="filter-input" aria-label={selectedOption.displayName} data-cy="tuple-select-input" - value={input} type="search" - onChange={(e) => handleOnChange(e.target.value)} placeholder={selectedOption.placeHolderText} - onKeyPress={(e: React.KeyboardEvent) => - e.key === "Enter" && handleOnSubmit() - } - icon={ - isValid ? ( - - - - ) : ( - - {validatorErrorMessage} - - ) - } + validator={validator} + validatorErrorMessage={validatorErrorMessage} + onSubmit={handleOnSubmit} + clearOnSubmit /> diff --git a/src/components/TupleSelectWithRegexConditional/TupleSelectWithRegexConditional.test.tsx b/src/components/TupleSelectWithRegexConditional/TupleSelectWithRegexConditional.test.tsx index ff8a23f7bb..0a13a8821b 100644 --- a/src/components/TupleSelectWithRegexConditional/TupleSelectWithRegexConditional.test.tsx +++ b/src/components/TupleSelectWithRegexConditional/TupleSelectWithRegexConditional.test.tsx @@ -77,8 +77,8 @@ describe("tupleSelectWithRegexConditional", () => { expect(input).toHaveValue("bad"); expect(onSubmit).not.toHaveBeenCalled(); expect(validator).toHaveBeenLastCalledWith("bad"); - expect(screen.getByDataCy("tuple-select-warning")).toBeInTheDocument(); - await user.hover(screen.queryByDataCy("tuple-select-warning")); + expect(screen.getByLabelText("validation error")).toBeInTheDocument(); + await user.hover(screen.getByLabelText("validation error")); await waitFor(() => { expect(screen.getByText(validatorErrorMessage)).toBeInTheDocument(); }); diff --git a/src/pages/configurePatch/configurePatchCore/ConfigureTasks/index.tsx b/src/pages/configurePatch/configurePatchCore/ConfigureTasks/index.tsx index b8da523168..8781d4da78 100644 --- a/src/pages/configurePatch/configurePatchCore/ConfigureTasks/index.tsx +++ b/src/pages/configurePatch/configurePatchCore/ConfigureTasks/index.tsx @@ -2,15 +2,16 @@ import React, { useMemo, useRef, useState } from "react"; import { css } from "@emotion/react"; import styled from "@emotion/styled"; import Checkbox from "@leafygreen-ui/checkbox"; -import TextInput from "@leafygreen-ui/text-input"; import Tooltip from "@leafygreen-ui/tooltip"; import { Body, Disclaimer } from "@leafygreen-ui/typography"; import pluralize from "pluralize"; import Icon from "components/Icon"; +import TextInput from "components/TextInputWithValidation"; import { CharKey } from "constants/keys"; import { size } from "constants/tokens"; import { VariantTask } from "gql/generated/types"; import useKeyboardShortcut from "hooks/useKeyboardShortcut"; +import { validateRegexp } from "utils/validators"; import { AliasState, ChildPatchAliased, @@ -87,7 +88,11 @@ const ConfigureTasks: React.FC = ({ const previouslySelectedVariants = selectedBuildVariants.map( (bv) => activatedVariants.find((vt) => vt.name === bv) || undefined ); - return deduplicateTasks(tasks, previouslySelectedVariants, search); + return deduplicateTasks( + tasks, + previouslySelectedVariants, + new RegExp(search) + ); }, [ selectedBuildVariantTasks, selectedBuildVariants, @@ -186,11 +191,11 @@ const ConfigureTasks: React.FC = ({ setSearch(e.target.value)} - placeholder="Search tasks" + onChange={(v) => setSearch(v)} + placeholder="Search tasks regex" ref={searchRef} data-cy="task-filter-input" + validator={validateRegexp} /> { it("should print all tasks for one variant", () => { const tasks = [{ task1: false, task2: false }]; - expect(deduplicateTasks(tasks, [], "")).toStrictEqual({ + expect(deduplicateTasks(tasks, [], /(?:)/)).toStrictEqual({ task1: { checkboxState: CheckboxState.Unchecked, activated: false, @@ -25,7 +25,7 @@ describe("deduplicateTasks", () => { { task1: false, task2: false }, { task3: false, task4: true }, ]; - expect(deduplicateTasks(tasks, [], "")).toStrictEqual({ + expect(deduplicateTasks(tasks, [], /(?:)/)).toStrictEqual({ task1: { checkboxState: CheckboxState.Unchecked, activated: false, @@ -49,7 +49,7 @@ describe("deduplicateTasks", () => { { task1: false, task2: false }, { task2: false, task3: false }, ]; - expect(deduplicateTasks(tasks, [], "")).toStrictEqual({ + expect(deduplicateTasks(tasks, [], /(?:)/)).toStrictEqual({ task1: { checkboxState: CheckboxState.Unchecked, activated: false, @@ -69,7 +69,7 @@ describe("deduplicateTasks", () => { { task1: false, task2: false }, { task2: true, task3: false }, ]; - expect(deduplicateTasks(tasks, [], "")).toStrictEqual({ + expect(deduplicateTasks(tasks, [], /(?:)/)).toStrictEqual({ task1: { checkboxState: CheckboxState.Unchecked, activated: false, @@ -94,7 +94,7 @@ describe("deduplicateTasks", () => { { tasks: ["task3"], name: "variant2" }, ]; expect( - deduplicateTasks(tasks, previouslyActivatedBuildvariants, "") + deduplicateTasks(tasks, previouslyActivatedBuildvariants, /(?:)/) ).toStrictEqual({ task1: { checkboxState: CheckboxState.Checked, @@ -115,7 +115,7 @@ describe("deduplicateTasks", () => { { task1: false, task2: false }, { task2: false, task3: false }, ]; - expect(deduplicateTasks(tasks, [], "task1")).toStrictEqual({ + expect(deduplicateTasks(tasks, [], /task1/)).toStrictEqual({ task1: { checkboxState: CheckboxState.Unchecked, activated: false, diff --git a/src/pages/configurePatch/configurePatchCore/ConfigureTasks/utils.ts b/src/pages/configurePatch/configurePatchCore/ConfigureTasks/utils.ts index 914ac884f9..6285e5b2bb 100644 --- a/src/pages/configurePatch/configurePatchCore/ConfigureTasks/utils.ts +++ b/src/pages/configurePatch/configurePatchCore/ConfigureTasks/utils.ts @@ -21,13 +21,13 @@ const deduplicateTasks = ( [task: string]: boolean; }[], previouslyActivatedBuildvariants: VariantTask[], - filterTerm: string + filterTerm: RegExp ): DeduplicateTasksResult => { const visibleTasks: DeduplicateTasksResult = {}; currentTasks.forEach((bv, i) => { const previouslyActivatedTasks = previouslyActivatedBuildvariants[i]?.tasks; Object.entries(bv).forEach(([taskName, value]) => { - if (filterTerm && !taskName.includes(filterTerm)) { + if (filterTerm && !filterTerm.test(taskName)) { return; } switch (visibleTasks[taskName]?.checkboxState) {