Skip to content

Commit 26487b5

Browse files
committed
fix(Button): state props should override Action state
1 parent e1a5b92 commit 26487b5

File tree

2 files changed

+98
-26
lines changed

2 files changed

+98
-26
lines changed

packages/components/src/components/Action/Action.test.tsx

Lines changed: 82 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { render, screen } from "@testing-library/react";
2-
import React, { act } from "react";
2+
import { act, type FC } from "react";
33
import Action from "@/components/Action";
4-
import { Button } from "@/components/Button";
4+
import { Button, type ButtonProps } from "@/components/Button";
55
import type { Mock } from "vitest";
66
import userEvent from "@/lib/dev/vitestUserEvent";
77

@@ -44,7 +44,10 @@ afterEach(() => {
4444
vitest.useRealTimers();
4545
});
4646

47-
const button = <Button data-testid="button" />;
47+
const TestButton: FC<ButtonProps> = (p) => (
48+
<Button data-testid="button" {...p} />
49+
);
50+
4851
const getButton = () => screen.getByTestId("button");
4952

5053
const clickTrigger = async () => {
@@ -56,18 +59,30 @@ const advanceTime = async (ms: number) => {
5659
};
5760

5861
test("Sync Action is called when trigger is clicked", async () => {
59-
render(<Action action={syncAction1}>{button}</Action>);
62+
render(
63+
<Action action={syncAction1}>
64+
<TestButton />
65+
</Action>,
66+
);
6067
await clickTrigger();
6168
expect(syncAction1).toHaveBeenCalledOnce();
6269
});
6370

6471
test("Action function is updated when action prop changes", async () => {
65-
const r = render(<Action action={syncAction1}>{button}</Action>);
72+
const r = render(
73+
<Action action={syncAction1}>
74+
<TestButton />
75+
</Action>,
76+
);
6677
await clickTrigger();
6778
expect(syncAction1).toHaveBeenCalledOnce();
6879
expect(syncAction2).not.toHaveBeenCalledOnce();
6980

70-
r.rerender(<Action action={syncAction2}>{button}</Action>);
81+
r.rerender(
82+
<Action action={syncAction2}>
83+
<TestButton />
84+
</Action>,
85+
);
7186
await clickTrigger();
7287
expect(syncAction1).toHaveBeenCalledOnce();
7388
expect(syncAction2).toHaveBeenCalledOnce();
@@ -76,7 +91,9 @@ test("Action function is updated when action prop changes", async () => {
7691
test("Nested sync actions are called when trigger is clicked", async () => {
7792
render(
7893
<Action action={syncAction2}>
79-
<Action action={syncAction1}>{button}</Action>
94+
<Action action={syncAction1}>
95+
<TestButton />
96+
</Action>
8097
</Action>,
8198
);
8299
await clickTrigger();
@@ -88,7 +105,9 @@ test("Nested sync actions are not called when break action is used", async () =>
88105
render(
89106
<Action action={syncAction2}>
90107
<Action break>
91-
<Action action={syncAction1}>{button}</Action>
108+
<Action action={syncAction1}>
109+
<TestButton />
110+
</Action>
92111
</Action>
93112
</Action>,
94113
);
@@ -102,7 +121,9 @@ test("Nested sync actions are not called when skipped", async () => {
102121
<Action action={syncAction2}>
103122
<Action action={syncAction2}>
104123
<Action skip>
105-
<Action action={syncAction1}>{button}</Action>
124+
<Action action={syncAction1}>
125+
<TestButton />
126+
</Action>
106127
</Action>
107128
</Action>
108129
</Action>,
@@ -117,7 +138,9 @@ test("Nested sync actions are not called when multiple skipped", async () => {
117138
<Action action={syncAction2}>
118139
<Action action={syncAction2}>
119140
<Action skip={2}>
120-
<Action action={syncAction1}>{button}</Action>
141+
<Action action={syncAction1}>
142+
<TestButton />
143+
</Action>
121144
</Action>
122145
</Action>
123146
</Action>,
@@ -130,7 +153,9 @@ test("Nested sync actions are not called when multiple skipped", async () => {
130153
test("When nested sync actions, the inner action is called first", async () => {
131154
render(
132155
<Action action={syncAction2}>
133-
<Action action={syncAction1}>{button}</Action>
156+
<Action action={syncAction1}>
157+
<TestButton />
158+
</Action>
134159
</Action>,
135160
);
136161

@@ -139,7 +164,11 @@ test("When nested sync actions, the inner action is called first", async () => {
139164
});
140165

141166
test("Button is enabled again when async action has completed", async () => {
142-
render(<Action action={asyncAction1}>{button}</Action>);
167+
render(
168+
<Action action={asyncAction1}>
169+
<TestButton />
170+
</Action>,
171+
);
143172
await clickTrigger();
144173
await advanceTime(asyncActionDuration);
145174
expect(getButton()).not.toBeDisabled();
@@ -148,7 +177,9 @@ test("Button is enabled again when async action has completed", async () => {
148177
test("When nested async actions, the outer action is called after the first has completed", async () => {
149178
render(
150179
<Action action={asyncAction2}>
151-
<Action action={asyncAction1}>{button}</Action>
180+
<Action action={asyncAction1}>
181+
<TestButton />
182+
</Action>
152183
</Action>,
153184
);
154185
await clickTrigger();
@@ -181,20 +212,35 @@ describe("Feedback", () => {
181212
test("is shown when sync action succeeds", async () => {
182213
render(
183214
<Action action={syncAction1} showFeedback>
184-
{button}
215+
<TestButton />
185216
</Action>,
186217
);
187218
await clickTrigger();
188219
expectIconInDom("check");
189220
});
190221

222+
test("is shown when set in props", async () => {
223+
const dom = render(
224+
<Action action={syncAction1} showFeedback>
225+
<TestButton isSucceeded />
226+
</Action>,
227+
);
228+
expectIconInDom("check");
229+
dom.rerender(
230+
<Action action={syncAction1} showFeedback>
231+
<TestButton isFailed />
232+
</Action>,
233+
);
234+
expectIconInDom("x");
235+
});
236+
191237
test("is shown when sync action fails", async () => {
192238
syncAction1.mockImplementation(() => {
193239
throw new Error("Whoops");
194240
});
195241
render(
196242
<Action action={syncAction1} showFeedback>
197-
{button}
243+
<TestButton />
198244
</Action>,
199245
);
200246
await clickTrigger();
@@ -204,7 +250,7 @@ describe("Feedback", () => {
204250
test("is hidden after some time", async () => {
205251
render(
206252
<Action action={syncAction1} showFeedback>
207-
{button}
253+
<TestButton />
208254
</Action>,
209255
);
210256
await clickTrigger();
@@ -222,14 +268,31 @@ describe("Pending state", () => {
222268
});
223269

224270
test("is shown when async action is pending", async () => {
225-
render(<Action action={asyncAction1}>{button}</Action>);
271+
render(
272+
<Action action={asyncAction1}>
273+
<TestButton />
274+
</Action>,
275+
);
226276
await clickTrigger();
227277
await advanceTime(1000);
228278
expectIconInDom("loader-2");
229279
});
230280

281+
test("is shown when set in props", async () => {
282+
render(
283+
<Action action={asyncAction1}>
284+
<TestButton isPending />
285+
</Action>,
286+
);
287+
expectIconInDom("loader-2");
288+
});
289+
231290
test("is not shown when sync action is executed", async () => {
232-
render(<Action action={syncAction1}>{button}</Action>);
291+
render(
292+
<Action action={syncAction1}>
293+
<TestButton />
294+
</Action>,
295+
);
233296
await clickTrigger();
234297
await advanceTime(1000);
235298
expectNoIconInDom();
@@ -238,7 +301,7 @@ describe("Pending state", () => {
238301
test("is hidden after some time", async () => {
239302
render(
240303
<Action action={asyncAction1} showFeedback>
241-
{button}
304+
<TestButton />
242305
</Action>,
243306
);
244307
await clickTrigger();

packages/components/src/components/Action/Action.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import React from "react";
21
import { ActionModel as ActionModel } from "@/components/Action/models/ActionModel";
32
import type { PropsContext } from "@/lib/propsContext";
43
import { dynamic, PropsContextProvider } from "@/lib/propsContext";
@@ -22,18 +21,28 @@ const actionButtonContext: ComponentPropsContext<"Button"> = {
2221
return isConfirmationButton ? confirmAction.execute : action.execute;
2322
}),
2423

25-
isPending: dynamic((props) => useActionButtonState(props) === "isPending"),
24+
isPending: dynamic((props) => {
25+
const actionState = useActionButtonState(props);
26+
return props.isPending ?? actionState === "isPending";
27+
}),
2628

27-
isSucceeded: dynamic(
28-
(props) => useActionButtonState(props) === "isSucceeded",
29-
),
29+
isSucceeded: dynamic((props) => {
30+
const actionState = useActionButtonState(props);
31+
return props.isSucceeded ?? actionState === "isSucceeded";
32+
}),
3033

31-
isFailed: dynamic((props) => useActionButtonState(props) === "isFailed"),
34+
isFailed: dynamic((props) => {
35+
const actionState = useActionButtonState(props);
36+
return props.isFailed ?? actionState === "isFailed";
37+
}),
3238

3339
"aria-disabled": dynamic((props) => {
3440
const state = useActionButtonState(props);
3541
const someActionInContextIsBusy = useActionStateContext().useIsBusy();
36-
return state === "isExecuting" || someActionInContextIsBusy;
42+
return (
43+
props["aria-disabled"] ??
44+
(state === "isExecuting" || someActionInContextIsBusy)
45+
);
3746
}),
3847
};
3948

0 commit comments

Comments
 (0)