Skip to content
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;
111 changes: 111 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,111 @@
import React, { useEffect, useRef, useState } from "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_150 } from "../../../utils/constants";

const MuiFormikColorInput = ({ name, placeholder = "Select a color", ...rest }) => {
const [field, meta, helpers] = useField(name);
const [hasValue, setHasValue] = useState(Boolean(field.value));
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

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.

PR updated with changes to display a placeholder if not value is selected and allow to clean value.

image image

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(() => {
setHasValue(Boolean(field.value));
if (field.value && field.value !== localValue) setLocalValue(field.value);
}, [field.value]);

useEffect(() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
}, []);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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_150);
};

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);
};
Comment thread
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>
),
} : undefined}
sx={{
"& input[type='color']::-webkit-color-swatch-wrapper": { padding: "2px" },
}}
{...rest}
/>
{!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;
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