Skip to content
26 changes: 13 additions & 13 deletions src/attributes/utils/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,10 +281,10 @@ describe("Sending only changed attributes", () => {
describe("works with select attributes", () => {
test.each`
newAttr | oldAttr | expected
${null} | ${null} | ${null}
${"my value"} | ${"my value"} | ${null}
${"my value"} | ${null} | ${["my value"]}
${null} | ${"my value"} | ${[]}
${null} | ${null} | ${"skip"}
${"my value"} | ${"my value"} | ${"skip"}
${"my value"} | ${null} | ${{ value: "my value" }}
${null} | ${"my value"} | ${null}
`("$oldAttr -> $newAttr returns $expected", ({ newAttr, oldAttr, expected }) => {
const attribute = createSelectAttribute(newAttr);
const prevAttribute = createSelectAttribute(oldAttr);
Expand All @@ -293,7 +293,7 @@ describe("Sending only changed attributes", () => {
prevAttributes: [prevAttribute],
updatedFileAttributes: [],
});
const expectedResult = expected !== null ? [{ id: ATTR_ID, values: expected }] : [];
const expectedResult = expected !== "skip" ? [{ id: ATTR_ID, dropdown: expected }] : [];

expect(result).toEqual(expectedResult);
});
Expand All @@ -302,10 +302,10 @@ describe("Sending only changed attributes", () => {
describe("works with required select attributes", () => {
test.each`
newAttr | oldAttr | expected
${null} | ${null} | ${[]}
${"my value"} | ${"my value"} | ${["my value"]}
${"my value"} | ${null} | ${["my value"]}
${null} | ${"my value"} | ${[]}
${null} | ${null} | ${null}
${"my value"} | ${"my value"} | ${{ value: "my value" }}
${"my value"} | ${null} | ${{ value: "my value" }}
${null} | ${"my value"} | ${null}
`("$oldAttr -> $newAttr returns $expected", ({ newAttr, oldAttr, expected }) => {
const attribute = createSelectAttribute(newAttr, true);
const prevAttribute = createSelectAttribute(oldAttr, true);
Expand All @@ -314,7 +314,7 @@ describe("Sending only changed attributes", () => {
prevAttributes: [prevAttribute],
updatedFileAttributes: [],
});
const expectedResult = expected !== null ? [{ id: ATTR_ID, values: expected }] : [];
const expectedResult = [{ id: ATTR_ID, dropdown: expected }];

expect(result).toEqual(expectedResult);
});
Expand Down Expand Up @@ -925,10 +925,10 @@ describe("prepareAttributesInput", () => {
});

// Assert
expect(result).toEqual([{ id: ATTR_ID, values: ["val-1"] }]);
expect(result).toEqual([{ id: ATTR_ID, dropdown: { value: "val-1" } }]);
});

it("should create input without null values for dropdowns", () => {
it("should create input with null dropdown for empty dropdowns", () => {
// Arrange & Act
const attribute = createDropdownAttribute(null);
const prevAttribute = createDropdownAttribute("val-1");
Expand All @@ -939,7 +939,7 @@ describe("prepareAttributesInput", () => {
});

// Assert
expect(result).toEqual([{ id: ATTR_ID, values: [] }]);
expect(result).toEqual([{ id: ATTR_ID, dropdown: null }]);
});
});

Expand Down
4 changes: 3 additions & 1 deletion src/attributes/utils/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,9 +394,11 @@ export const prepareAttributesInput = ({
}

if (inputType === AttributeInputTypeEnum.DROPDOWN) {
const dropdownValue = attr.value[0];

attrInput.push({
id: attr.id,
values: attr.value.filter(value => value !== null),
dropdown: dropdownValue ? { value: dropdownValue } : null,
});

return attrInput;
Expand Down
47 changes: 13 additions & 34 deletions src/components/Attributes/AttributeRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,18 @@ import {
getMultiChoices,
getMultiDisplayValue,
getReferenceDisplayValue,
getSingleChoices,
getSingleDisplayValue,
getTruncatedTextValue,
} from "@dashboard/components/Attributes/utils";
import FileUploadField from "@dashboard/components/FileUploadField";
import RichTextEditor from "@dashboard/components/RichTextEditor";
import SortableChipsField from "@dashboard/components/SortableChipsField";
import { AttributeInputTypeEnum } from "@dashboard/graphql";
import { Box, DynamicCombobox, Input, Select, Text } from "@saleor/macaw-ui-next";
import { Box, Input, Select, Text } from "@saleor/macaw-ui-next";
import { useIntl } from "react-intl";

import { Multiselect } from "../Combobox";
import { DateTimeField } from "../DateTimeField";
import { DropdownRow } from "./DropdownRow";
import { SingleReferenceField } from "./SingleReferenceField";
import { AttributeRowProps } from "./types";

Expand All @@ -42,7 +41,7 @@ const AttributeRow = ({
fetchMoreAttributeValues,
onAttributeSelectBlur,
richTextGetters,
}: AttributeRowProps) => {
}: AttributeRowProps): JSX.Element => {
const intl = useIntl();

switch (attribute.data.inputType) {
Expand Down Expand Up @@ -96,36 +95,16 @@ const AttributeRow = ({
);
case AttributeInputTypeEnum.DROPDOWN:
return (
<BasicAttributeRow label={attribute.label}>
<DynamicCombobox
size="small"
disabled={disabled}
options={getSingleChoices(attributeValues)}
value={
attribute.value[0]
? {
value: attribute.value[0],
label: getSingleDisplayValue(attribute, attributeValues),
}
: null
}
error={!!error}
helperText={getErrorMessage(error, intl)}
name={`attribute:${attribute.label}`}
id={`attribute:${attribute.label}`}
label=""
onChange={option => onChange(attribute.id, option?.value ?? "")}
onFocus={() => {
fetchAttributeValues("", attribute.id);
}}
onBlur={onAttributeSelectBlur}
onScrollEnd={() => {
if (fetchMoreAttributeValues?.hasMore) {
fetchMoreAttributeValues.onFetchMore();
}
}}
/>
</BasicAttributeRow>
<DropdownRow
attribute={attribute}
attributeValues={attributeValues}
disabled={disabled}
error={error}
onChange={onChange}
fetchAttributeValues={fetchAttributeValues}
fetchMoreAttributeValues={fetchMoreAttributeValues}
onAttributeSelectBlur={onAttributeSelectBlur}
/>
);
case AttributeInputTypeEnum.SWATCH:
return (
Expand Down
109 changes: 109 additions & 0 deletions src/components/Attributes/DropdownRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { BasicAttributeRow } from "@dashboard/components/Attributes/BasicAttributeRow";
import { getErrorMessage, getSingleDisplayValue } from "@dashboard/components/Attributes/utils";
import {
AttributeValueFragment,
PageErrorWithAttributesFragment,
ProductErrorWithAttributesFragment,
} from "@dashboard/graphql";
import { DynamicCombobox, Option } from "@saleor/macaw-ui-next";
import { useState } from "react";
import { useIntl } from "react-intl";

import { AttributeInput } from "./Attributes";
import { AttributeRowHandlers } from "./types";
import { useAttributeDropdown } from "./useAttributeDropdown";

type DropdownRowProps = Pick<
AttributeRowHandlers,
"onChange" | "fetchAttributeValues" | "fetchMoreAttributeValues"
> & {
attribute: AttributeInput;
attributeValues: AttributeValueFragment[];
disabled: boolean;
error: ProductErrorWithAttributesFragment | PageErrorWithAttributesFragment;
onAttributeSelectBlur?: () => void;
};

export const DropdownRow = ({
attribute,
attributeValues,
disabled,
error,
onChange,
fetchAttributeValues,
fetchMoreAttributeValues,
onAttributeSelectBlur,
}: DropdownRowProps) => {
const intl = useIntl();
const [inputValue, setInputValue] = useState("");
const [selectedValue, setSelectedValue] = useState<Option | null>(
attribute.value[0]
? {
value: attribute.value[0],
label: getSingleDisplayValue(attribute, attributeValues),
}
: null,
);
Comment on lines +39 to +46
Copy link

Copilot AI Jan 26, 2026

Choose a reason for hiding this comment

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

selectedValue is initialized from attribute.value/attributeValues once, but it won’t update if the parent changes attribute.value (e.g. form reset, external update, attributeValues fetched later). This can leave the combobox showing a stale selection. Prefer deriving value directly from props each render, or add an effect to sync selectedValue when attribute.value[0] / attributeValues change.

Copilot uses AI. Check for mistakes.

const { customValueOption, customValueLabel, handleFetchMore, handleInputChange, handleFocus } =
useAttributeDropdown({
inputValue,
selectedValue,
fetchOptions: query => fetchAttributeValues(query, attribute.id),
fetchMore: fetchMoreAttributeValues,
});

const options: Option[] = attributeValues
.filter(value => value.slug !== null)
.map(value => ({
value: value.slug as string,
label: value.name ?? value.slug ?? "",
}));

const handleOnChange = (option: Option | null) => {
if (!option) {
setSelectedValue(null);
onChange(attribute.id, "");

return;
}

const isCustomValue = option.label.includes(customValueLabel);

if (isCustomValue) {
const newOption = { label: option.value, value: option.value };

setSelectedValue(newOption);
onChange(attribute.id, option.value);
setInputValue("");
} else {
setSelectedValue(option);
onChange(attribute.id, option.value);
}
};

return (
<BasicAttributeRow label={attribute.label}>
<DynamicCombobox
size="small"
disabled={disabled}
options={[...customValueOption, ...options]}
value={selectedValue}
error={!!error}
helperText={getErrorMessage(error, intl)}
name={`attribute:${attribute.label}`}
id={`attribute:${attribute.label}`}
label=""
onChange={handleOnChange}
onInputValueChange={value => {
setInputValue(value);
handleInputChange(value);
}}
onFocus={handleFocus}
onBlur={onAttributeSelectBlur}
onScrollEnd={handleFetchMore}
loading={fetchMoreAttributeValues?.hasMore || fetchMoreAttributeValues?.loading}
/>
</BasicAttributeRow>
);
};
22 changes: 13 additions & 9 deletions src/components/Attributes/SwatchRow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @ts-strict-ignore
import { BasicAttributeRow } from "@dashboard/components/Attributes/BasicAttributeRow";
import { getErrorMessage, getSingleDisplayValue } from "@dashboard/components/Attributes/utils";
import { useComboboxHandlers } from "@dashboard/components/Combobox/hooks/useComboboxHandlers";
import { getBySlug } from "@dashboard/misc";
import { Box, DynamicCombobox } from "@saleor/macaw-ui-next";
import { useMemo } from "react";
Expand All @@ -27,9 +28,16 @@ export const SwatchRow = ({
disabled,
error,
onChange,
}: SwatchRowProps) => {
}: SwatchRowProps): JSX.Element => {
const intl = useIntl();
const value = attribute.data.values.find(getBySlug(attribute.value[0]));

const { handleFetchMore, handleFocus, handleInputChange } = useComboboxHandlers({
fetchOptions: query => fetchAttributeValues(query, attribute.id),
alwaysFetchOnFocus: false,
fetchMore: fetchMoreAttributeValues,
});

const options = useMemo(
() =>
attributeValues.map(({ file, value, slug, name }) => ({
Expand Down Expand Up @@ -76,14 +84,10 @@ export const SwatchRow = ({
name={`attribute:${attribute.label}`}
id={`attribute:${attribute.label}`}
onChange={e => onChange(attribute.id, e?.value ?? "")}
onFocus={() => {
fetchAttributeValues("", attribute.id);
}}
onScrollEnd={() => {
if (fetchMoreAttributeValues?.hasMore) {
fetchMoreAttributeValues.onFetchMore();
}
}}
onInputValueChange={handleInputChange}
onFocus={handleFocus}
onScrollEnd={handleFetchMore}
loading={fetchMoreAttributeValues?.hasMore || fetchMoreAttributeValues?.loading}
/>
</BasicAttributeRow>
);
Expand Down
Loading
Loading