Skip to content

Commit 42c4773

Browse files
authored
feat: add event queue (#1201)
1 parent fc1f208 commit 42c4773

File tree

5 files changed

+182
-1
lines changed

5 files changed

+182
-1
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { createContext, FC, ReactNode, useContext } from "react";
2+
import React from "react";
3+
4+
export interface EventCallback<T> {
5+
onSuccess: (event: T) => void;
6+
onFailure: (msg: string) => void;
7+
onFinish?: () => void;
8+
}
9+
10+
export interface EventQueue<T> {
11+
get: (operationId: string) => EventCallback<T> | undefined;
12+
set: (
13+
operationId: string,
14+
onSuccess: (event: T) => void,
15+
onFailure: (msg: string) => void,
16+
onFinish?: () => void,
17+
) => void;
18+
remove: (operationId: string) => void;
19+
}
20+
21+
/**
22+
* This provides an event queue system for managing callbacks associated with
23+
* asynchronous operations (e.g., API calls) in an application.
24+
*
25+
* It allows components to register success, failure,
26+
* and optional finish handlers for a given operation ID, and later retrieve or remove them.
27+
*
28+
* This is useful for handling side effects when dealing
29+
* with multiple operations that need to be tracked and updated independently.
30+
*
31+
* The `createEventQueue` function should be used to create a single provider and context that are shared throughout your application.
32+
* The returned `EventQueueProvider` and `useEventQueue` hook should be exported
33+
* from a shared module and reused across the app to ensure a single
34+
* context instance is used.
35+
*
36+
* Usage pattern:
37+
* // eventQueue.ts
38+
* export const { EventQueueProvider, useEventQueue } = createEventQueue<EventType>();
39+
*
40+
* // App.tsx
41+
* import { EventQueueProvider } from "./eventQueue";
42+
* ...
43+
* <EventQueueProvider>
44+
* <App />
45+
* </EventQueueProvider>
46+
*
47+
* // In any other component
48+
* import { useEventQueue } from "./eventQueue";
49+
* ...
50+
* const eventQueue = useEventQueue();
51+
* eventQueue.set(operationId, onSuccess, onFailure);
52+
*/
53+
54+
export function createEventQueue<T>() {
55+
const EventQueueContext = createContext<EventQueue<T> | undefined>(undefined);
56+
57+
const eventQueue = new Map<string, EventCallback<T>>();
58+
59+
const EventQueueProvider: FC<{ children: ReactNode }> = ({ children }) => (
60+
<EventQueueContext.Provider
61+
value={{
62+
get: (operationId) => eventQueue.get(operationId),
63+
set: (operationId, onSuccess, onFailure, onFinish) =>
64+
eventQueue.set(operationId, { onSuccess, onFailure, onFinish }),
65+
remove: (operationId) => eventQueue.delete(operationId),
66+
}}
67+
>
68+
{children}
69+
</EventQueueContext.Provider>
70+
);
71+
72+
const useEventQueue = () => {
73+
const context = useContext(EventQueueContext);
74+
if (!context) {
75+
throw new Error(
76+
"useEventQueue must be used within an EventQueueProvider",
77+
);
78+
}
79+
return context;
80+
};
81+
82+
return { EventQueueProvider, useEventQueue, EventQueueContext };
83+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import React from "react";
2+
import { render, screen } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
5+
import { createEventQueue } from "./EventQueue";
6+
import Button from "../Button";
7+
8+
type Event = { message: string };
9+
10+
const { EventQueueProvider, useEventQueue } = createEventQueue<Event>();
11+
12+
describe("EventQueue", () => {
13+
const operationId = "test-operation-id";
14+
15+
let user: ReturnType<typeof userEvent.setup>;
16+
let successSpy: jest.Mock;
17+
let failureSpy: jest.Mock;
18+
let finishSpy: jest.Mock;
19+
20+
beforeEach(() => {
21+
user = userEvent.setup();
22+
successSpy = jest.fn();
23+
failureSpy = jest.fn();
24+
finishSpy = jest.fn();
25+
});
26+
27+
const TriggerComponent = () => {
28+
const queue = useEventQueue();
29+
30+
const handleRegister = () => {
31+
queue.set(operationId, successSpy, failureSpy, finishSpy);
32+
};
33+
34+
const triggerSuccess = () => {
35+
const event = queue.get(operationId);
36+
event?.onSuccess({ message: "Success!" });
37+
event?.onFinish?.();
38+
queue.remove(operationId);
39+
};
40+
41+
const triggerFailure = () => {
42+
const event = queue.get(operationId);
43+
event?.onFailure("Something went wrong.");
44+
event?.onFinish?.();
45+
queue.remove(operationId);
46+
};
47+
48+
return (
49+
<div>
50+
<Button onClick={handleRegister} data-testid="register-btn" />
51+
<Button onClick={triggerSuccess} data-testid="success-btn" />
52+
<Button onClick={triggerFailure} data-testid="failure-btn" />
53+
</div>
54+
);
55+
};
56+
57+
const renderQueue = () =>
58+
render(
59+
<EventQueueProvider>
60+
<TriggerComponent />
61+
</EventQueueProvider>,
62+
);
63+
64+
it("calls onSuccess and onFinish when success is triggered", async () => {
65+
renderQueue();
66+
await user.click(screen.getByTestId("register-btn"));
67+
await user.click(screen.getByTestId("success-btn"));
68+
69+
expect(successSpy).toHaveBeenCalledWith({ message: "Success!" });
70+
expect(failureSpy).not.toHaveBeenCalled();
71+
expect(finishSpy).toHaveBeenCalled();
72+
});
73+
74+
it("calls onFailure and onFinish when failure is triggered", async () => {
75+
renderQueue();
76+
await user.click(screen.getByTestId("register-btn"));
77+
await user.click(screen.getByTestId("failure-btn"));
78+
79+
expect(failureSpy).toHaveBeenCalledWith("Something went wrong.");
80+
expect(successSpy).not.toHaveBeenCalled();
81+
expect(finishSpy).toHaveBeenCalled();
82+
});
83+
84+
it("does nothing if callback is not registered", async () => {
85+
renderQueue();
86+
await user.click(screen.getByTestId("success-btn"));
87+
await user.click(screen.getByTestId("failure-btn"));
88+
89+
expect(successSpy).not.toHaveBeenCalled();
90+
expect(failureSpy).not.toHaveBeenCalled();
91+
expect(finishSpy).not.toHaveBeenCalled();
92+
});
93+
});

src/components/EventQueue/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { createEventQueue } from "./EventQueue";
2+
export type { EventCallback, EventQueue } from "./EventQueue";

src/index.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ it("exports all public hooks and components from the index file", () => {
66
const componentsPath = "./src/components/";
77

88
const isNotHidden = (name: string) => !name.startsWith(".");
9-
const ignoreDir = ["Notifications"];
9+
// exclude directories that are not meant to be components
10+
const ignoreDir = ["Notifications", "EventQueue"];
1011

1112
const hooks = fs
1213
.readdirSync(hooksPath)

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export { default as ConfirmationModal } from "./components/ConfirmationModal";
2424
export { default as ContextualMenu } from "./components/ContextualMenu";
2525
export { default as DoughnutChart } from "./components/DoughnutChart";
2626
export { default as EmptyState } from "./components/EmptyState";
27+
export { createEventQueue } from "./components/EventQueue";
2728
export { default as Field } from "./components/Field";
2829
export { default as Form } from "./components/Form";
2930
export { default as FormikField } from "./components/FormikField";
@@ -124,6 +125,7 @@ export type {
124125
} from "./components/ContextualMenu";
125126
export type { DoughnutChartProps, Segment } from "./components/DoughnutChart";
126127
export type { EmptyStateProps } from "./components/EmptyState";
128+
export type { EventCallback, EventQueue } from "./components/EventQueue";
127129
export type { FieldProps } from "./components/Field";
128130
export type { FormProps } from "./components/Form";
129131
export type { FormikFieldProps } from "./components/FormikField";

0 commit comments

Comments
 (0)