Skip to content

Commit a2b5196

Browse files
authored
Fix attribute Save button dirty state, double-submit, and loading state (#6659)
1 parent 30a7ddf commit a2b5196

13 files changed

Lines changed: 157 additions & 27 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"saleor-dashboard": patch
3+
---
4+
5+
**Attribute detail page – Save fixes**
6+
7+
- Saving an attribute no longer leaves the form in a "dirty" state, so the "you have unsaved changes" warning is no longer shown after a successful save.
8+
- The Save button now disables immediately on the first click, preventing accidental double submits.
9+
- The Save button keeps its primary appearance while saving and while showing the success checkmark (no more switching to the disabled/grey look on hover), and can no longer be clicked again until it returns to its idle state.

src/attributes/components/AttributePage/AttributePage.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ const AttributePage = ({
220220
set,
221221
data,
222222
isSaveDisabled,
223+
isSubmitting,
223224
submit,
224225
errors,
225226
setError,
@@ -399,7 +400,7 @@ const AttributePage = ({
399400
<Savebar.Spacer />
400401
<Savebar.CancelButton onClick={() => navigate(attributePageBackLink)} />
401402
<Savebar.ConfirmButton
402-
transitionState={saveButtonBarState}
403+
transitionState={isSubmitting ? "loading" : saveButtonBarState}
403404
onClick={submit}
404405
disabled={!!isSaveDisabled}
405406
/>

src/attributes/mutations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export const attributeUpdateMutation = gql`
2424
mutation AttributeUpdate($id: ID!, $input: AttributeUpdateInput!) {
2525
attributeUpdate(id: $id, input: $input) {
2626
attribute {
27-
...Attribute
27+
...AttributeUpdateResult
2828
}
2929
errors {
3030
...AttributeError
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/* While loading the button keeps its variant appearance. Disabling pointer
2+
* events prevents hover/active styles from shifting the color and blocks
3+
* repeated clicks without the "inactive" (disabled) look. */
4+
.noInteraction {
5+
pointer-events: none;
6+
}

src/components/ButtonWithLoader/ButtonWithLoader.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ describe("ButtonWithLoader", () => {
1717
// Assert
1818
expect(screen.getByRole("button")).toBeInTheDocument();
1919
expect(screen.getByTestId("button-progress")).toBeInTheDocument();
20+
expect(screen.getByRole("button")).toBeEnabled();
21+
expect(screen.getByRole("button")).toHaveAttribute("aria-busy", "true");
22+
});
23+
24+
it("should not call onClick while loading", () => {
25+
const onClick = jest.fn();
26+
27+
render(<ButtonWithLoader transitionState="loading" onClick={onClick} />);
28+
fireEvent.click(screen.getByRole("button"));
29+
expect(onClick).not.toHaveBeenCalled();
2030
});
2131

2232
it("should call onClick when clicked", () => {

src/components/ButtonWithLoader/ButtonWithLoader.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { SaleorThrobber } from "@dashboard/components/Throbber";
22
import { buttonMessages } from "@dashboard/intl";
33
import { Button, type ButtonProps, sprinkles } from "@saleor/macaw-ui-next";
4+
import clsx from "clsx";
45
import { useIntl } from "react-intl";
56

67
import { type ConfirmButtonTransitionState } from "../ConfirmButton";
8+
import styles from "./ButtonWithLoader.module.css";
79

810
interface ButtonWithLoaderProps extends ButtonProps {
911
transitionState: ConfirmButtonTransitionState;
@@ -14,6 +16,7 @@ export const ButtonWithLoader = ({
1416
onClick,
1517
disabled,
1618
children,
19+
className,
1720
...props
1821
}: ButtonWithLoaderProps) => {
1922
const intl = useIntl();
@@ -42,7 +45,10 @@ export const ButtonWithLoader = ({
4245
return (
4346
<Button
4447
{...props}
45-
disabled={isLoading || disabled}
48+
className={clsx(className, isLoading && styles.noInteraction)}
49+
disabled={disabled}
50+
aria-busy={isLoading}
51+
tabIndex={isLoading ? -1 : undefined}
4652
onClick={isLoading ? undefined : onClick}
4753
data-test-state={isLoading ? "loading" : "default"}
4854
>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/* While the action is in progress or showing success feedback the button keeps
2+
* its variant appearance. Disabling pointer events prevents hover/active styles
3+
* from shifting the color and blocks repeated clicks without the "inactive"
4+
* (disabled) look. */
5+
.noInteraction {
6+
pointer-events: none;
7+
}

src/components/ConfirmButton/ConfirmButton.test.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,37 @@ describe("ConfirmButton", () => {
3131
expect(screen.getByRole("button")).toBeInTheDocument();
3232
expect(screen.getByTestId("button-progress")).toBeInTheDocument();
3333
});
34+
it("should keep loading button enabled for primary styling and block interaction", () => {
35+
render(<ConfirmButton noTransition transitionState="loading" onClick={jest.fn()} />);
36+
37+
const button = screen.getByRole("button");
38+
39+
expect(button).toBeEnabled();
40+
expect(button).toHaveAttribute("aria-busy", "true");
41+
fireEvent.click(button);
42+
expect(button).not.toHaveAttribute("disabled");
43+
});
44+
it("should not call onClick while loading", () => {
45+
const onClick = jest.fn();
46+
47+
render(<ConfirmButton noTransition transitionState="loading" onClick={onClick} />);
48+
fireEvent.click(screen.getByRole("button"));
49+
expect(onClick).not.toHaveBeenCalled();
50+
});
51+
it("should not call onClick while success feedback is shown", () => {
52+
const onClick = jest.fn();
53+
54+
render(<ConfirmButton noTransition transitionState="success" onClick={onClick} />);
55+
fireEvent.click(screen.getByRole("button"));
56+
expect(onClick).not.toHaveBeenCalled();
57+
});
58+
it("should call onClick in error state so user can try again", () => {
59+
const onClick = jest.fn();
60+
61+
render(<ConfirmButton noTransition transitionState="error" onClick={onClick} />);
62+
fireEvent.click(screen.getByRole("button"));
63+
expect(onClick).toHaveBeenCalled();
64+
});
3465
it("should render a button with success", () => {
3566
render(<ConfirmButton noTransition transitionState="success" />);
3667
expect(screen.getByRole("button")).toBeInTheDocument();

src/components/ConfirmButton/ConfirmButton.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { SaleorThrobber } from "@dashboard/components/Throbber";
22
import { buttonMessages } from "@dashboard/intl";
33
import { Button, type ButtonProps, sprinkles } from "@saleor/macaw-ui-next";
4+
import clsx from "clsx";
45
import { Check } from "lucide-react";
56
import { useEffect, useRef, useState } from "react";
67
import { defineMessages, useIntl } from "react-intl";
78

9+
import styles from "./ConfirmButton.module.css";
10+
811
const DEFAULT_NOTIFICATION_SHOW_TIME = 3000;
912

1013
const messages = defineMessages({
@@ -38,13 +41,18 @@ export const ConfirmButton = ({
3841
disabled,
3942
children,
4043
variant,
44+
className,
4145
...props
4246
}: ConfirmButtonProps) => {
4347
const intl = useIntl();
4448
const [displayCompletedActionState, setDisplayCompletedActionState] = useState(false);
4549
const timeout = useRef<number>();
50+
const isLoading = transitionState === "loading";
4651
const isCompleted = noTransition ? transitionState !== "default" : displayCompletedActionState;
52+
const isSaveDisabled = !isCompleted && !!disabled;
53+
const isSuccess = transitionState === "success" && isCompleted;
4754
const isError = transitionState === "error" && isCompleted;
55+
const isInteractionLocked = isLoading || isSuccess;
4856
const defaultLabels: ConfirmButtonLabels = {
4957
confirm: intl.formatMessage(buttonMessages.save),
5058
error: intl.formatMessage(messages.tryAgain),
@@ -122,9 +130,12 @@ export const ConfirmButton = ({
122130
return (
123131
<Button
124132
{...props}
133+
className={clsx(className, isInteractionLocked && styles.noInteraction)}
125134
variant={isError ? "error" : variant}
126-
disabled={!isCompleted && disabled}
127-
onClick={transitionState === "loading" ? undefined : onClick}
135+
disabled={isSaveDisabled}
136+
aria-busy={isLoading}
137+
tabIndex={isInteractionLocked ? -1 : undefined}
138+
onClick={isInteractionLocked ? undefined : onClick}
128139
data-test-state={isCompleted ? transitionState : "default"}
129140
>
130141
{renderContent()}

src/fragments/attributes.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,25 @@ export const attributeFragment = gql`
3939
}
4040
`;
4141

42+
export const attributeUpdateResultFragment = gql`
43+
fragment AttributeUpdateResult on Attribute {
44+
...Attribute
45+
availableInGrid
46+
storefrontSearchPosition
47+
valueRequired
48+
referenceTypes {
49+
... on ProductType {
50+
id
51+
name
52+
}
53+
... on PageType {
54+
id
55+
name
56+
}
57+
}
58+
}
59+
`;
60+
4261
export const attributeDetailsFragment = gql`
4362
fragment AttributeDetails on Attribute {
4463
...Attribute

0 commit comments

Comments
 (0)