-
Notifications
You must be signed in to change notification settings - Fork 4
feat: add new mui formik color picker component #231
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
70c2189
98f0a7b
056868e
8de3829
21ef82c
c800335
66f427a
efd3bc5
af2bf34
9b62075
80fd6e0
8bbad90
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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( | ||
| <Formik initialValues={initialValues} onSubmit={jest.fn()}> | ||
| <Form> | ||
| <MuiFormikColorInput name="testField" {...props} /> | ||
| </Form> | ||
| </Formik> | ||
| ); | ||
|
|
||
| const renderWithSubmit = (initialValues = { testField: "" }, props = {}) => { | ||
| const onSubmit = jest.fn(); | ||
| render( | ||
| <Formik initialValues={initialValues} onSubmit={onSubmit}> | ||
| <Form> | ||
| <MuiFormikColorInput name="testField" {...props} /> | ||
| <button type="submit">Submit</button> | ||
| </Form> | ||
| </Formik> | ||
| ); | ||
| 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( | ||
| <Formik | ||
| initialValues={{ testField: "" }} | ||
| initialErrors={{ testField: "Color is required" }} | ||
| initialTouched={{ testField: true }} | ||
| onSubmit={jest.fn()} | ||
| > | ||
| <Form> | ||
| <MuiFormikColorInput name="testField" /> | ||
| </Form> | ||
| </Formik> | ||
| ); | ||
| expect(screen.getByText("Color is required")).toBeInTheDocument(); | ||
| }); | ||
|
|
||
| test("does not show error when field is not yet touched", () => { | ||
| render( | ||
| <Formik | ||
| initialValues={{ testField: "" }} | ||
| initialErrors={{ testField: "Color is required" }} | ||
| initialTouched={{ testField: false }} | ||
| onSubmit={jest.fn()} | ||
| > | ||
| <Form> | ||
| <MuiFormikColorInput name="testField" /> | ||
| </Form> | ||
| </Formik> | ||
| ); | ||
| expect(screen.queryByText("Color is required")).not.toBeInTheDocument(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [HIGH] Default
Suggested fix: also seed Formik on mount when useEffect(() => { if (!field.value) helpers.setValue("#000000"); }, []);or render the swatch only when
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The color picker would pick a default color if the user don't interact with the component. It looks like this contradicts #231 (comment).
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| const debounceRef = useRef(null); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MEDIUM] No re-sync with external
Suggested fix: useEffect(() => {
if (field.value !== localValue) setLocalValue(field.value || "#000000");
}, [field.value]);(guard against overwriting in-flight typing if needed.) [LOW] No debounce cleanup on unmount. A pending useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, []); |
||
|
|
||
| useEffect(() => { | ||
| setHasValue(Boolean(field.value)); | ||
| if (field.value && field.value !== localValue) setLocalValue(field.value); | ||
| }, [field.value, localValue]); | ||
|
Comment on lines
+15
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: cat -n src/components/mui/formik-inputs/mui-formik-color-input.jsRepository: OpenStackweb/openstack-uicore-foundation Length of output: 4198 Remove Including Proposed fix useEffect(() => {
setHasValue(Boolean(field.value));
if (field.value && field.value !== localValue) setLocalValue(field.value);
- }, [field.value, localValue]);
+ }, [field.value]);🤖 Prompt for AI Agents |
||
|
|
||
| useEffect(() => () => { | ||
| if (debounceRef.current) clearTimeout(debounceRef.current); | ||
| }, []); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| const handleChange = (e) => { | ||
| const value = e.target.value; | ||
| setLocalValue(value); | ||
| setHasValue(true); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why do we need this state ? can't you just use localValue ?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this state is to track when the user pick a color since html color input requires a default value |
||
| if (debounceRef.current) clearTimeout(debounceRef.current); | ||
| debounceRef.current = setTimeout(() => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why do we need to debounce the onchange ? we never do this . If this is strictly necessary, add comments why
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is to limit the render of formik props when the user is dragging the cursor to pick a color, without this the component turns laggy. |
||
| 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); | ||
| }; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| return ( | ||
| <Box sx={{ position: "relative", width: "100%" }}> | ||
| <TextField | ||
| type="color" | ||
| name={field.name} | ||
| value={localValue} | ||
| onChange={handleChange} | ||
| onBlur={handleBlur} | ||
| error={meta.touched && Boolean(meta.error)} | ||
| helperText={meta.touched && meta.error} | ||
| fullWidth | ||
| InputProps={{ | ||
| ...(hasValue ? { | ||
| endAdornment: ( | ||
| <InputAdornment position="end"> | ||
| <IconButton size="small" onClick={handleClear} edge="end" disableRipple> | ||
| <ClearIcon fontSize="small" /> | ||
| </IconButton> | ||
| </InputAdornment> | ||
| ), | ||
| } : {}), ...restInputProps | ||
| }} | ||
| sx={{ | ||
| "& input[type='color']::-webkit-color-swatch-wrapper": { padding: "2px" }, | ||
| }} | ||
| {...rest} | ||
|
tomrndom marked this conversation as resolved.
|
||
| /> | ||
| {!hasValue && ( | ||
| <Box | ||
| sx={{ | ||
| position: "absolute", | ||
| top: 2, | ||
| left: 2, | ||
| right: 2, | ||
| bottom: 2, | ||
| borderRadius: "3px", | ||
| bgcolor: "background.paper", | ||
| pointerEvents: "none", | ||
| display: "flex", | ||
| alignItems: "center", | ||
| px: "14px", | ||
| gap: 1, | ||
| }} | ||
| > | ||
| <Box component="span" sx={{ color: "text.disabled" }}> | ||
| {placeholder} | ||
| </Box> | ||
| </Box> | ||
| )} | ||
| </Box> | ||
| ); | ||
| }; | ||
|
|
||
| MuiFormikColorInput.propTypes = { | ||
| name: PropTypes.string.isRequired, | ||
| placeholder: PropTypes.string | ||
| }; | ||
|
|
||
| export default MuiFormikColorInput; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -138,5 +138,8 @@ | |
| "total": "Total", | ||
| "rate": "Rate", | ||
| "action": "Action" | ||
| }, | ||
| "color_picker": { | ||
| "placeholder": "Select a color" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. any reason why you can't just use 250 ?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 250 made the delay between selecting a color and seeing it applied slightly noticeable, 150 felt a bit more responsive, but both work fine |
||
| export const DEBOUNCE_WAIT = 500; | ||
|
|
||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.