diff --git a/src/components/index.js b/src/components/index.js index f32815eb..55f708d7 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -83,6 +83,7 @@ export {TotalRow as MuiTotalRow, NotesRow as MuiNotesRow, FeeRow as MuiFeeRow, P export {default as MuiFormikAsyncSelect} from './mui/formik-inputs/mui-formik-async-select' export {default as MuiFormikCheckboxGroup} from './mui/formik-inputs/mui-formik-checkbox-group' export {default as MuiFormikCheckbox} from './mui/formik-inputs/mui-formik-checkbox' +export {default as MuiFormikColorInput} from './mui/formik-inputs/mui-formik-color-input' export {default as MuiFormikDatepicker} from './mui/formik-inputs/mui-formik-datepicker' export {default as MuiFormikDiscountField} from './mui/formik-inputs/mui-formik-discountfield' export {default as MuiFormikDropdownCheckbox} from './mui/formik-inputs/mui-formik-dropdown-checkbox' diff --git a/src/components/mui/__tests__/mui-formik-color-input.test.js b/src/components/mui/__tests__/mui-formik-color-input.test.js new file mode 100644 index 00000000..889838c2 --- /dev/null +++ b/src/components/mui/__tests__/mui-formik-color-input.test.js @@ -0,0 +1,166 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +import React from "react"; +import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; +import { Formik, Form } from "formik"; +import "@testing-library/jest-dom"; +import MuiFormikColorInput from "../formik-inputs/mui-formik-color-input"; + +const DEBOUNCE_MS = 250; +const PLACEHOLDER_KEY = "color_picker.placeholder"; + +const renderWithFormik = (props, initialValues = { testField: "" }) => + render( + +
+ + +
+ ); + +const renderWithSubmit = (initialValues = { testField: "" }, props = {}) => { + const onSubmit = jest.fn(); + render( + +
+ + + +
+ ); + return onSubmit; +}; + +// Restore real timers after each test to prevent leaking fake timers +afterEach(() => jest.useRealTimers()); + +const getColorInput = () => document.querySelector('input[type="color"]'); + +describe("MuiFormikColorInput", () => { + test("renders a color input", () => { + renderWithFormik({}); + expect(getColorInput()).toBeInTheDocument(); + }); + + test("loads initial value: reflects value, shows clear button, hides placeholder", () => { + renderWithFormik({}, { testField: "#ff0000" }); + expect(getColorInput()).toHaveValue("#ff0000"); + expect(screen.getByRole("button")).toBeInTheDocument(); + expect(screen.queryByText(PLACEHOLDER_KEY)).not.toBeInTheDocument(); + }); + + test("empty initial state: shows placeholder and no clear button", () => { + renderWithFormik({}); + expect(screen.getByText(PLACEHOLDER_KEY)).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + test("blur without selection: formik value stays empty and placeholder remains", async () => { + const onSubmit = renderWithSubmit({ testField: "" }); + const input = getColorInput(); + + fireEvent.focus(input); + fireEvent.blur(input); + + expect(screen.getByText(PLACEHOLDER_KEY)).toBeInTheDocument(); + fireEvent.click(screen.getByText("Submit")); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ testField: "" }, expect.anything()); + }); + }); + + // The useEffect resets hasValue to false whenever localValue changes (before the debounce + // commits the new color to field.value). The clear button only appears after the debounce fires. + test("select a color: shows clear button and hides placeholder after debounce", () => { + jest.useFakeTimers(); + renderWithFormik({}); + + fireEvent.change(getColorInput(), { target: { value: "#33aaff" } }); + act(() => jest.advanceTimersByTime(DEBOUNCE_MS)); + + expect(screen.getByRole("button")).toBeInTheDocument(); + expect(screen.queryByText(PLACEHOLDER_KEY)).not.toBeInTheDocument(); + }); + + test("select a color: updates formik value after debounce", async () => { + jest.useFakeTimers(); + const onSubmit = renderWithSubmit({ testField: "" }); + + fireEvent.change(getColorInput(), { target: { value: "#33aaff" } }); + act(() => jest.advanceTimersByTime(DEBOUNCE_MS)); + jest.useRealTimers(); + + fireEvent.click(screen.getByText("Submit")); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ testField: "#33aaff" }, expect.anything()); + }); + }); + + test("clear button: shows placeholder and hides clear button", () => { + renderWithFormik({}, { testField: "#ff0000" }); + + fireEvent.click(screen.getByRole("button")); + + expect(screen.getByText(PLACEHOLDER_KEY)).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + test("clear button: resets formik value to empty", async () => { + const onSubmit = renderWithSubmit({ testField: "#ff0000" }); + + fireEvent.click(screen.getAllByRole("button")[0]); + + fireEvent.click(screen.getByText("Submit")); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ testField: "" }, expect.anything()); + }); + }); + + test("shows error message when field is touched and has an error", () => { + render( + +
+ + +
+ ); + expect(screen.getByText("Color is required")).toBeInTheDocument(); + }); + + test("does not show error when field is not yet touched", () => { + render( + +
+ + +
+ ); + expect(screen.queryByText("Color is required")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/mui/formik-inputs/mui-formik-async-select.js b/src/components/mui/formik-inputs/mui-formik-async-select.js index 8f724fb6..109c1ddc 100644 --- a/src/components/mui/formik-inputs/mui-formik-async-select.js +++ b/src/components/mui/formik-inputs/mui-formik-async-select.js @@ -137,4 +137,4 @@ const MuiFormikAsyncAutocomplete = ({ ); }; -export default MuiFormikAsyncAutocomplete; +export default MuiFormikAsyncAutocomplete; \ No newline at end of file diff --git a/src/components/mui/formik-inputs/mui-formik-color-input.js b/src/components/mui/formik-inputs/mui-formik-color-input.js new file mode 100644 index 00000000..20404ad0 --- /dev/null +++ b/src/components/mui/formik-inputs/mui-formik-color-input.js @@ -0,0 +1,114 @@ +import React, { useEffect, useRef, useState } from "react"; +import T from "i18n-react"; +import PropTypes from "prop-types"; +import { Box, IconButton, InputAdornment, TextField } from "@mui/material"; +import ClearIcon from "@mui/icons-material/Clear"; +import { useField } from "formik"; +import { DEBOUNCE_WAIT_250 } from "../../../utils/constants"; + +const MuiFormikColorInput = ({ name, placeholder = T.translate("color_picker.placeholder"), InputProps: restInputProps, ...rest }) => { + const [field, meta, helpers] = useField(name); + const [hasValue, setHasValue] = useState(Boolean(field.value)); + const [localValue, setLocalValue] = useState(field.value || "#000000"); + const debounceRef = useRef(null); + + useEffect(() => { + setHasValue(Boolean(field.value)); + if (field.value && field.value !== localValue) setLocalValue(field.value); + }, [field.value, localValue]); + + useEffect(() => () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }, []); + + const handleChange = (e) => { + const value = e.target.value; + setLocalValue(value); + setHasValue(true); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + helpers.setValue(value); + debounceRef.current = null; + }, DEBOUNCE_WAIT_250); + }; + + const handleBlur = (e) => { + field.onBlur(e); + helpers.setTouched(true); + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + helpers.setValue(hasValue ? localValue : ""); + } + }; + + const handleClear = (e) => { + e.stopPropagation(); + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + setHasValue(false); + helpers.setValue(""); + helpers.setTouched(true); + }; + + return ( + + + + + + + ), + } : {}), ...restInputProps + }} + sx={{ + "& input[type='color']::-webkit-color-swatch-wrapper": { padding: "2px" }, + }} + {...rest} + /> + {!hasValue && ( + + + {placeholder} + + + )} + + ); +}; + +MuiFormikColorInput.propTypes = { + name: PropTypes.string.isRequired, + placeholder: PropTypes.string +}; + +export default MuiFormikColorInput; diff --git a/src/i18n/en.json b/src/i18n/en.json index 01f7bf27..96f2f2f8 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -138,5 +138,8 @@ "total": "Total", "rate": "Rate", "action": "Action" + }, + "color_picker": { + "placeholder": "Select a color" } } diff --git a/src/utils/constants.js b/src/utils/constants.js index 85e190fc..3f140450 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -7,6 +7,7 @@ export const BPS = 100; export const CODE_200 = 200; +export const DEBOUNCE_WAIT_150 = 150; export const DEBOUNCE_WAIT_250 = 250; export const DEBOUNCE_WAIT = 500; diff --git a/webpack.common.js b/webpack.common.js index 2a9513fc..00192aea 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -108,6 +108,7 @@ module.exports = { 'components/mui/formik-inputs/async-select': './src/components/mui/formik-inputs/mui-formik-async-select.js', 'components/mui/formik-inputs/checkbox-group': './src/components/mui/formik-inputs/mui-formik-checkbox-group.js', 'components/mui/formik-inputs/checkbox': './src/components/mui/formik-inputs/mui-formik-checkbox.js', + 'components/mui/formik-inputs/color-input': './src/components/mui/formik-inputs/mui-formik-color-input.js', 'components/mui/formik-inputs/datepicker': './src/components/mui/formik-inputs/mui-formik-datepicker.js', 'components/mui/formik-inputs/discount-field': './src/components/mui/formik-inputs/mui-formik-discountfield.js', 'components/mui/formik-inputs/dropdown-checkbox': './src/components/mui/formik-inputs/mui-formik-dropdown-checkbox.js',