Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export {TotalRow as MuiTotalRow, NotesRow as MuiNotesRow} from './mui/table/extr
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'
Expand Down
50 changes: 39 additions & 11 deletions src/components/mui/formik-inputs/mui-formik-async-select.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,33 @@ import {
} from "@mui/material";
import { useField } from "formik";
import { DEBOUNCE_WAIT_250 } from "../../../utils/constants";
import PropTypes from "prop-types";

/**
* Async Autocomplete with two modes:
* - Remote (default): fetches options from API on each user input (debounced).
* - Local (localFilter=true): fetches once on mount and filters options client-side.
* Note: localFilter mode assumes stable queryParams (set once on mount).
* If queryParams need to change, remount the component instead.
*/
const MuiFormikAsyncAutocomplete = ({
name,
queryFunction,
multiple = false,
placeholder = "Select...",
plainValue = false,
hiddenOptions = [],
formatOption = (item) => ({ value: item.id.toString(), label: item.name }),
formatSelectedValue = null,
queryParams = [],
isMulti = false
isMulti = false,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Pre-existing multiple vs isMulti dual-prop divergence (worth fixing while editing this file).

  • multiple (default false, line 35) drives the handleChange value-shape branch (single vs array).
  • multiple={isMulti} (further down) is what's passed to MUI Autocomplete.

A consumer passing only isMulti={true} (without multiple={true}) gets multi-select UI with single-value Formik writes → array/object collisions on submit. The new propTypes block cements this two-name API into the public surface.

Suggested fix: collapse to one prop (multiple), or alias one to the other in destructuring (multiple = isMulti), and update propTypes accordingly.

localFilter = false
}) => {
const [field, meta, helpers] = useField(name);
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");

const value = field.value || (multiple ? [] : null);
const value = field.value || (isMulti ? [] : null);
const error = meta.touched && meta.error;

const fetchOptions = async (input = "") => {
Expand All @@ -58,7 +66,7 @@ const MuiFormikAsyncAutocomplete = ({
};

useEffect(() => {
Comment thread
tomrndom marked this conversation as resolved.
if (searchTerm) {
if (!localFilter && searchTerm) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] localFilter mode filters against whatever queryFunction("") returns — usually a paged subset.

The new docblock promises "fetches once on mount" + client-side filtering, but the only mount-time call (fetchOptions("") a few lines below) uses a queryFunction that elsewhere in this codebase honors server-side pagination/filter on input. Consumers will silently see an incomplete option list with no warning, contradicting the user-visible promise of "filter all options locally."

Suggested fix: in localFilter mode, require an explicit fetchAll/pageSize prop (or accept a pre-resolved options prop) and document the contract; otherwise emit a runtime warning.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be set on the implementation as part of the queryParams according to the queryFunction used.

Since the order of the per_page parameter is not always in the same position is a bit difficult to use a default MAX_PAGE_SIZE for all queryActions

const delayDebounce = setTimeout(() => {
fetchOptions(searchTerm);
}, DEBOUNCE_WAIT_250);
Expand All @@ -72,7 +80,7 @@ const MuiFormikAsyncAutocomplete = ({
}, []);

const handleChange = (event, selected) => {
if (!multiple) {
if (!isMulti) {
const selectedValue = plainValue ? selected?.value || "" : selected;
helpers.setValue(selectedValue);
return;
Expand All @@ -81,10 +89,10 @@ const MuiFormikAsyncAutocomplete = ({
const selectedItems = plainValue
? selected.map((s) => s.value)
: selected.map((s) =>
formatSelectedValue
? formatSelectedValue(s)
: { id: parseInt(s.value), name: s.label }
);
formatSelectedValue
? formatSelectedValue(s)
: { id: parseInt(s.value), name: s.label }
);

helpers.setValue(selectedItems);
};
Expand All @@ -99,7 +107,18 @@ const MuiFormikAsyncAutocomplete = ({
fullWidth
getOptionLabel={(option) => option.label || ""}
isOptionEqualToValue={(option, value) => option.value === value.value}
onInputChange={(e, newInput) => setSearchTerm(newInput)}
onInputChange={!localFilter ? (e, newInput) => setSearchTerm(newInput) : undefined}
filterOptions={
// only apply filterOptions for "local" search
localFilter
? (options, { inputValue }) =>
options.filter((opt) =>
String(opt.label ?? "").toLowerCase().includes(
String(inputValue ?? "").toLowerCase()
)
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
: undefined
}
renderInput={(params) => (
<TextField
{...params}
Expand Down Expand Up @@ -129,12 +148,21 @@ const MuiFormikAsyncAutocomplete = ({
)}
renderOption={(props, option, { selected }) => (
<li {...props}>
{multiple && <Checkbox checked={selected} sx={{ mr: 1 }} />}
{isMulti && <Checkbox checked={selected} sx={{ mr: 1 }} />}
{option.label}
</li>
)}
/>
);
};

MuiFormikAsyncAutocomplete.propTypes = {
name: PropTypes.string.isRequired,
isMulti: PropTypes.bool,
queryFunction: PropTypes.func.isRequired,
formatOption: PropTypes.func,
queryParams: PropTypes.array,
localFilter: PropTypes.bool,
};

export default MuiFormikAsyncAutocomplete;
65 changes: 65 additions & 0 deletions src/components/mui/formik-inputs/mui-formik-color-input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React, { useEffect, useRef, useState } from "react";
import PropTypes from "prop-types";
import { TextField } from "@mui/material";
import { useField } from "formik";
import { DEBOUNCE_WAIT_150 } from "../../../utils/constants";

const MuiFormikColorInput = ({ name, ...rest }) => {
const [field, meta, helpers] = useField(name);
const [localValue, setLocalValue] = useState(field.value || "#000000");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Default "#000000" masks falsy initialValues.

useState(field.value || "#000000") silently shows black while Formik state stays "" / null. If the user never touches the input, the form submits with the original empty value while the swatch implies #000000 was selected → data mismatch on save.

Suggested fix: also seed Formik on mount when field.value is empty:

useEffect(() => { if (!field.value) helpers.setValue("#000000"); }, []);

or render the swatch only when field.value is set.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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).
Please let me know if I should add some initial state to display the input as not selected since the input type="color" doesn't support an empty value

const debounceRef = useRef(null);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] No re-sync with external field.value changes.

localValue is initialized once from field.value and only mutated by user input. After Formik resetForm(), parent setFieldValue, or async-loaded initial values, the swatch keeps showing the old color while Formik state has changed → broken edit/reset flows.

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 setTimeout can fire helpers.setValue after unmount → stale writes / dev warnings, especially in tabbed forms. Add:

useEffect(() => () => { if (debounceRef.current) clearTimeout(debounceRef.current); }, []);


useEffect(() => {
if (field.value !== localValue) setLocalValue(field.value || "#000000");
}, [field.value]);

useEffect(() => () => {
if (!field.value) helpers.setValue("#000000");
if (debounceRef.current) clearTimeout(debounceRef.current);
}, []);
Comment on lines +16 to +19
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

cat -n src/components/mui/formik-inputs/mui-formik-color-input.js

Repository: OpenStackweb/openstack-uicore-foundation

Length of output: 2319


Remove Formik writes from unmount cleanup (stale-value side effect).

On line 17, the cleanup callback captures field.value in a closure with an empty dependency array, causing it to always reference the initial value from mount rather than the current value. If the field starts with a falsy value, the cleanup will always attempt to call helpers.setValue("#000000") during unmount, even if the field value has since changed. This mutates Formik state during component teardown, which can cause unexpected side effects during navigation or conditional rendering.

The debounce timeout cleanup on line 18 is necessary and should remain, but the conditional write is redundant—handleBlur already flushes pending debounces with the correct value.

Proposed fix
-  useEffect(() => () => {
-    if (!field.value) helpers.setValue("#000000");
-    if (debounceRef.current) clearTimeout(debounceRef.current);
-  }, []);
+  useEffect(
+    () => () => {
+      if (debounceRef.current) clearTimeout(debounceRef.current);
+    },
+    []
+  );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/mui/formik-inputs/mui-formik-color-input.js` around lines 16 -
19, The cleanup in the useEffect currently writes to Formik (reads field.value
and calls helpers.setValue("#000000")) using a stale closure; remove the
conditional Formik write from the cleanup so it no longer mutates Formik state
on unmount—keep only the debounce timeout cleanup
(clearTimeout(debounceRef.current)) in the useEffect cleanup. Locate the
useEffect that references field.value, helpers.setValue, and debounceRef and
delete the helpers.setValue branch, leaving the debounceRef.current clear call
intact (handleBlur already flushes debounced updates).


const handleChange = (e) => {
const value = e.target.value;
setLocalValue(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
helpers.setValue(value);
debounceRef.current = null;
}, DEBOUNCE_WAIT_150);
};

const handleBlur = (e) => {
field.onBlur(e);
helpers.setTouched(true);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
debounceRef.current = null;
helpers.setValue(localValue);
}
};

return (
<TextField
type="color"
name={field.name}
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
Comment thread
tomrndom marked this conversation as resolved.
error={meta.touched && Boolean(meta.error)}
helperText={meta.touched && meta.error}
fullWidth
sx={{
"& input[type='color']::-webkit-color-swatch-wrapper": {
padding: "2px"
}
}}
{...rest}
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
};

MuiFormikColorInput.propTypes = {
name: PropTypes.string.isRequired
};

export default MuiFormikColorInput;
1 change: 1 addition & 0 deletions src/utils/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const ZERO_INT = 0;

export const CODE_200 = 200;

export const DEBOUNCE_WAIT_150 = 150;
export const DEBOUNCE_WAIT_250 = 250;
export const DEBOUNCE_WAIT = 500;

Expand Down
1 change: 1 addition & 0 deletions webpack.common.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,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',
Expand Down
Loading