* Add interceptor * ad tests for nova eventing interceptor * add README * Change files * fix one test * fix how bubble is called --------- Co-authored-by: Kerryn Frampton <kerrynb@microsoft.com> Co-authored-by: Stanislaw Wilczynski <Stanislaw.Wilczynski@microsoft.com>
This commit is contained in:
Родитель
13ff3c8be5
Коммит
3e47ea9614
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "minor",
|
||||
"comment": "add eventing interceptor",
|
||||
"packageName": "@nova/react",
|
||||
"email": "Stanislaw.Wilczynski@microsoft.com",
|
||||
"dependentChangeType": "patch"
|
||||
}
|
|
@ -74,3 +74,104 @@ For `babel-loader` (make sure template tags aren't stripped out by typescript by
|
|||
},
|
||||
},
|
||||
```
|
||||
|
||||
## Eventing
|
||||
|
||||
Nova eventing exposes a clearly contracted, component driven way to surface actions that occur within in the boundaries of a component.
|
||||
These events are published in an independent package so that they can be easily consumed by code that is outside of the component tree.
|
||||
|
||||
### Primary Use Cases for Events
|
||||
|
||||
- Bubbling a button click that should perform some sort of navigation or external action, like opening a modal on host app side
|
||||
- Bubbling an internal action that needs to be logged
|
||||
|
||||
### Eventing Contract
|
||||
|
||||
Eventing is primarily a contract between the component owner and host apps. The Event data object should contain all the appropriate context to allow the host apps to appropriately handle the event.
|
||||
|
||||
If the host app needs additional data to perform an action, this should be discussed with the component team to add an event or extend the data sent.
|
||||
|
||||
### Basic example
|
||||
|
||||
```tsx
|
||||
import { NovaEventingProvider, reactEventMapper } from "@nova/react";
|
||||
|
||||
const eventHandler = (eventWrapper: EventWrapper) => {
|
||||
if (eventWrapper.event.eventType === "showProfile") {
|
||||
// trigger some action to show profile
|
||||
}
|
||||
if (eventWrapper.originator === "MyComponent") {
|
||||
return handleEventsForMyComponent(eventWrapper);
|
||||
}
|
||||
};
|
||||
|
||||
const AppEventingProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<NovaEventingProvider
|
||||
bubble={eventHandler}
|
||||
reactEventMapper={
|
||||
reactEventMapper
|
||||
} /* you can also provide your own implementation of mapper */
|
||||
>
|
||||
{children}
|
||||
</NovaEventingProvider>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Then in some low level component
|
||||
|
||||
```tsx
|
||||
import { useNovaEventing } from "@nova/react";
|
||||
|
||||
const MyComponent = () => {
|
||||
const eventing = useNovaEventing();
|
||||
|
||||
const handleClick = (event: React.SyntheticEvent) => {
|
||||
eventing.bubble({
|
||||
reactEvent: event,
|
||||
event: {
|
||||
eventType: "selectProfile",
|
||||
originator: "MyComponent",
|
||||
data: () => ({
|
||||
userId: "123",
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return <button onClick={handleClick}>Select profile</button>;
|
||||
};
|
||||
```
|
||||
|
||||
### Intercepting events
|
||||
|
||||
As `NovaEventingProvider` is usually defined at the top level of the app, you may want to intercept events, which are more specific to your component and can be handled on lower level. This is not encouraged as it makes the handling not reusable by other component but if you are sure the event is specific and you want to handle it lower (like passing some arguments to your host app wrapper), you can use `NovaEventingInterceptor`:
|
||||
|
||||
```tsx
|
||||
const MyComponentWrapper = () => {
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
|
||||
const defaultInterceptor = (eventWrapper: EventWrapper) => {
|
||||
if (
|
||||
eventWrapper.event.originator === "MyComponent" &&
|
||||
eventWrapper.event.eventType === "selectProfile"
|
||||
) {
|
||||
const data = eventWrapper.event.data();
|
||||
setUserId(data.userId);
|
||||
return Promise.resolve(undefined);
|
||||
} else {
|
||||
return Promise.resolve(eventWrapper);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NovaEventingInterceptor intercept={defaultInterceptor}>
|
||||
{userId && <Profile userId={userId} />}
|
||||
<ComponentThatRenderMyComponentSomewhereInside />
|
||||
</NovaEventingInterceptor>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
The `NovaEventingInterceptor` will intercept the event and if you can check it's properties to decide if is should be acted upon. If from `intercept` promise resolving to undefined is returned the event will not be passed to eventing higher up the tree. However, if to process the event further, one should return a promise resolving to the `eventWrapper` object. That also gives a possibility to alter the event and still pass it further up.
|
||||
|
|
|
@ -3,12 +3,13 @@
|
|||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import type {
|
||||
GeneratedEventWrapper,
|
||||
ReactEventWrapper,
|
||||
NovaReactEventing,
|
||||
} from "./nova-eventing-provider";
|
||||
import { NovaEventingInterceptor } from "./nova-eventing-provider";
|
||||
import {
|
||||
NovaEventingProvider,
|
||||
useNovaEventing,
|
||||
|
@ -28,6 +29,7 @@ describe("useNovaEventing", () => {
|
|||
let eventing: NovaEventing;
|
||||
let prevWrappedEventing: NovaReactEventing;
|
||||
let eventCallback: () => void;
|
||||
const renderSpy = jest.fn();
|
||||
const initialChildren = "initial children";
|
||||
const updatedChildren = "updated children";
|
||||
|
||||
|
@ -43,6 +45,7 @@ describe("useNovaEventing", () => {
|
|||
prevWrappedEventing = undefined as unknown as NovaReactEventing;
|
||||
|
||||
TestComponent = ({ childrenText }) => {
|
||||
renderSpy();
|
||||
const wrappedEventing: NovaReactEventing = useNovaEventing();
|
||||
expect(wrappedEventing).toBeDefined();
|
||||
expect(wrappedEventing).not.toBe(eventing);
|
||||
|
@ -110,6 +113,8 @@ describe("useNovaEventing", () => {
|
|||
initialChildren,
|
||||
);
|
||||
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
wrapper.rerender(
|
||||
<NovaEventingProvider
|
||||
children={<TestComponent childrenText={updatedChildren} />}
|
||||
|
@ -120,6 +125,7 @@ describe("useNovaEventing", () => {
|
|||
expect(wrapper.queryAllByTestId("children")[0].innerHTML).toBe(
|
||||
updatedChildren,
|
||||
);
|
||||
expect(renderSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("Takes in children and eventing props, creates a stable wrapped NovaReactEventing instance from eventing across re-renders when children do not change.", () => {
|
||||
|
@ -137,6 +143,7 @@ describe("useNovaEventing", () => {
|
|||
expect(wrapper.queryAllByTestId("children")[0].innerHTML).toBe(
|
||||
initialChildren,
|
||||
);
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
wrapper.rerender(
|
||||
<NovaEventingProvider
|
||||
|
@ -148,6 +155,7 @@ describe("useNovaEventing", () => {
|
|||
expect(wrapper.queryAllByTestId("children")[0].innerHTML).toBe(
|
||||
initialChildren,
|
||||
);
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Update eventing instance to test useRef pathway. This will ensure the wrapped eventing instance
|
||||
// returned from useEventing is stable from one render to the next.
|
||||
|
@ -164,6 +172,7 @@ describe("useNovaEventing", () => {
|
|||
expect(wrapper.queryAllByTestId("children")[0].innerHTML).toBe(
|
||||
initialChildren,
|
||||
);
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
//Trigger a callback on the test child through eventing
|
||||
eventCallback();
|
||||
|
@ -186,6 +195,7 @@ describe("useNovaEventing", () => {
|
|||
expect(wrapper.queryAllByTestId("children")[0].innerHTML).toBe(
|
||||
initialChildren,
|
||||
);
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
//Trigger a callback on the test child through eventing
|
||||
eventCallback();
|
||||
|
@ -380,3 +390,141 @@ describe("useUnmountEventing", () => {
|
|||
expect(eventing.bubble).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NovaEventingInterceptor", () => {
|
||||
const originalError = console.error;
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
console.error = originalError;
|
||||
});
|
||||
const realMapper = jest.requireActual(
|
||||
"./react-event-source-mapper",
|
||||
).mapEventMetadata;
|
||||
|
||||
const mapEventMetadataMock = jest.fn().mockImplementation(realMapper);
|
||||
|
||||
const bubbleMock = jest.fn();
|
||||
|
||||
const parentEventing = {
|
||||
bubble: bubbleMock,
|
||||
generateEvent: bubbleMock,
|
||||
} as unknown as NovaEventing;
|
||||
|
||||
const callbackToBeCalledOnIntercept = jest.fn();
|
||||
|
||||
const defaultInterceptor = (eventWrapper: EventWrapper) => {
|
||||
if (eventWrapper.event.originator === "toBeIntercepted") {
|
||||
callbackToBeCalledOnIntercept();
|
||||
return Promise.resolve(undefined);
|
||||
} else {
|
||||
return Promise.resolve(eventWrapper);
|
||||
}
|
||||
};
|
||||
|
||||
const ComponentWithTwoEvents = ({ name }: { name: string }) => {
|
||||
const eventing = useNovaEventing();
|
||||
const onInterceptClick = (event: React.SyntheticEvent) => {
|
||||
eventing.bubble({
|
||||
reactEvent: event,
|
||||
event: { originator: "toBeIntercepted", type: "TypeToBeIntercepted" },
|
||||
});
|
||||
};
|
||||
|
||||
const onNonInterceptClick = (event: React.SyntheticEvent) => {
|
||||
eventing.bubble({
|
||||
reactEvent: event,
|
||||
event: {
|
||||
originator: "notToBeIntercepted",
|
||||
type: "TypeNotToBeIntercepted",
|
||||
},
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
Component with two events
|
||||
<button onClick={onInterceptClick}>
|
||||
{name}: Fire event to be intercepted
|
||||
</button>
|
||||
<button onClick={onNonInterceptClick}>
|
||||
{name}: Fire event without intercept
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const InterceptorTestComponent: React.FC<{
|
||||
interceptor?: (event: EventWrapper) => Promise<EventWrapper | undefined>;
|
||||
}> = ({ interceptor = defaultInterceptor }) => (
|
||||
<NovaEventingProvider
|
||||
eventing={parentEventing}
|
||||
reactEventMapper={mapEventMetadataMock}
|
||||
>
|
||||
<ComponentWithTwoEvents name="outside" />
|
||||
<NovaEventingInterceptor interceptor={interceptor}>
|
||||
<ComponentWithTwoEvents name="inside" />
|
||||
</NovaEventingInterceptor>
|
||||
</NovaEventingProvider>
|
||||
);
|
||||
|
||||
it("intercepts the event and does not bubble it up", async () => {
|
||||
const { getByText } = render(<InterceptorTestComponent />);
|
||||
const button = getByText("inside: Fire event to be intercepted");
|
||||
button.click();
|
||||
expect(callbackToBeCalledOnIntercept).toHaveBeenCalled();
|
||||
await waitFor(() => expect(mapEventMetadataMock).toHaveBeenCalled());
|
||||
expect(bubbleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bubbles the event when interceptor returns the event", async () => {
|
||||
const { getByText } = render(<InterceptorTestComponent />);
|
||||
const button = getByText("inside: Fire event without intercept");
|
||||
button.click();
|
||||
expect(callbackToBeCalledOnIntercept).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(bubbleMock).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("doesn't catch events outside of interceptor context", async () => {
|
||||
const { getByText } = render(<InterceptorTestComponent />);
|
||||
const button = getByText("outside: Fire event to be intercepted");
|
||||
button.click();
|
||||
expect(callbackToBeCalledOnIntercept).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(bubbleMock).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("throws an error when rendered outside of NovaEventingProvider", () => {
|
||||
console.error = jest.fn();
|
||||
expect.assertions(1);
|
||||
const InterceptorTestComponent: React.FC = () => (
|
||||
<NovaEventingInterceptor interceptor={defaultInterceptor}>
|
||||
<ComponentWithTwoEvents name="inside" />
|
||||
</NovaEventingInterceptor>
|
||||
);
|
||||
|
||||
expect(() => render(<InterceptorTestComponent />)).toThrow(
|
||||
"Nova Eventing provider must be initialized prior to creating NovaEventingInterceptor!",
|
||||
);
|
||||
});
|
||||
|
||||
it("intercepts the event and bubbles up the event when interceptor returns the event anyway", async () => {
|
||||
const interceptor = (eventWrapper: EventWrapper) => {
|
||||
if (eventWrapper.event.originator === "toBeIntercepted") {
|
||||
callbackToBeCalledOnIntercept();
|
||||
return Promise.resolve({
|
||||
...eventWrapper,
|
||||
event: { ...eventWrapper.event, data: () => "addedData" },
|
||||
});
|
||||
} else {
|
||||
return Promise.resolve(eventWrapper);
|
||||
}
|
||||
};
|
||||
const { getByText } = render(
|
||||
<InterceptorTestComponent interceptor={interceptor} />,
|
||||
);
|
||||
const button = getByText("inside: Fire event to be intercepted");
|
||||
button.click();
|
||||
expect(callbackToBeCalledOnIntercept).toHaveBeenCalled();
|
||||
await waitFor(() => expect(bubbleMock).toHaveBeenCalled());
|
||||
const bubbleCall = bubbleMock.mock.calls[0][0];
|
||||
expect(bubbleCall.event.data()).toBe("addedData");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,10 +6,11 @@ import invariant from "invariant";
|
|||
// Context is initialized with an empty object and this is null-checked within the hooks
|
||||
const NovaEventingContext = React.createContext<INovaEventingContext>({});
|
||||
|
||||
// Both properties are optional in the context for initialization state only, but eventing must be supplied in the props
|
||||
// All properties are optional in the context for initialization state only, but eventing must be supplied in the props
|
||||
interface INovaEventingContext {
|
||||
eventing?: NovaReactEventing;
|
||||
unmountEventing?: NovaReactEventing;
|
||||
internal?: InternalEventingContext;
|
||||
}
|
||||
|
||||
interface NovaEventingProviderProps {
|
||||
|
@ -58,6 +59,14 @@ export interface NovaReactEventing {
|
|||
generateEvent(eventWrapper: GeneratedEventWrapper): Promise<void>;
|
||||
}
|
||||
|
||||
export interface InternalEventingContext {
|
||||
eventingRef: React.MutableRefObject<NovaEventing>;
|
||||
unmountEventingRef: React.MutableRefObject<NovaEventing>;
|
||||
mapperRef: React.MutableRefObject<
|
||||
(reactEventWrapper: ReactEventWrapper) => EventWrapper
|
||||
>;
|
||||
}
|
||||
|
||||
export const NovaEventingProvider: React.FunctionComponent<
|
||||
NovaEventingProviderProps
|
||||
> = ({ children, eventing, unmountEventing, reactEventMapper }) => {
|
||||
|
@ -91,7 +100,11 @@ export const NovaEventingProvider: React.FunctionComponent<
|
|||
);
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({ eventing: reactEventing, unmountEventing: reactUnmountEventing }),
|
||||
() => ({
|
||||
eventing: reactEventing,
|
||||
unmountEventing: reactUnmountEventing,
|
||||
internal: { eventingRef, unmountEventingRef, mapperRef },
|
||||
}),
|
||||
[reactEventing, reactUnmountEventing],
|
||||
);
|
||||
|
||||
|
@ -112,6 +125,63 @@ export const useNovaEventing = (): NovaReactEventing => {
|
|||
return eventing;
|
||||
};
|
||||
|
||||
interface NovaEventingInterceptorProps {
|
||||
interceptor: (event: EventWrapper) => Promise<EventWrapper | undefined>;
|
||||
children?: React.ReactNode | undefined;
|
||||
}
|
||||
|
||||
export const NovaEventingInterceptor: React.FunctionComponent<
|
||||
NovaEventingInterceptorProps
|
||||
> = ({ children, interceptor }) => {
|
||||
// Nova contexts provide a facade over framework functions
|
||||
// We don't need to trigger rerender in children when we are rerendered
|
||||
// or when the input functions change, we just need to make sure callbacks
|
||||
// use the right functions
|
||||
const interceptorRef = React.useRef(interceptor);
|
||||
if (interceptorRef.current !== interceptor) {
|
||||
interceptorRef.current = interceptor;
|
||||
}
|
||||
|
||||
const { internal } = React.useContext(NovaEventingContext);
|
||||
|
||||
if (!internal) {
|
||||
invariant(
|
||||
internal,
|
||||
"Nova Eventing provider must be initialized prior to creating NovaEventingInterceptor!",
|
||||
);
|
||||
}
|
||||
|
||||
const reactEventing = React.useMemo(
|
||||
generateEventing(internal.eventingRef, internal.mapperRef, interceptorRef),
|
||||
[],
|
||||
);
|
||||
|
||||
const reactUnmountEventing = React.useMemo(
|
||||
generateEventing(
|
||||
internal.unmountEventingRef,
|
||||
internal.mapperRef,
|
||||
interceptorRef,
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
eventing: reactEventing,
|
||||
unmountEventing: reactUnmountEventing,
|
||||
internal,
|
||||
}),
|
||||
[reactEventing, reactUnmountEventing],
|
||||
);
|
||||
|
||||
return (
|
||||
<NovaEventingContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</NovaEventingContext.Provider>
|
||||
);
|
||||
};
|
||||
NovaEventingInterceptor.displayName = "NovaEventingInterceptor";
|
||||
|
||||
/**
|
||||
* Used for eventing that should be triggered when the component is unmounted, such as within a useEffect cleanup function
|
||||
* If unmountEventing has not been supplied to the NovaEventingProvider, this will fallback to use the defualt eventing instance
|
||||
|
@ -133,13 +203,25 @@ const generateEventing =
|
|||
mapperRef: React.MutableRefObject<
|
||||
(reactEventWrapper: ReactEventWrapper) => EventWrapper
|
||||
>,
|
||||
interceptorRef?: React.MutableRefObject<
|
||||
(event: EventWrapper) => Promise<EventWrapper | undefined>
|
||||
>,
|
||||
) =>
|
||||
(): NovaReactEventing => ({
|
||||
bubble: (eventWrapper: ReactEventWrapper) => {
|
||||
bubble: async (eventWrapper: ReactEventWrapper) => {
|
||||
const mappedEvent: EventWrapper = mapperRef.current(eventWrapper);
|
||||
return eventingRef.current.bubble(mappedEvent);
|
||||
if (!interceptorRef) {
|
||||
return eventingRef.current.bubble(mappedEvent);
|
||||
}
|
||||
|
||||
let eventToBubble: EventWrapper | undefined = mappedEvent;
|
||||
eventToBubble = await interceptorRef.current(mappedEvent);
|
||||
|
||||
return eventToBubble
|
||||
? eventingRef.current.bubble(eventToBubble)
|
||||
: Promise.resolve();
|
||||
},
|
||||
generateEvent: (eventWrapper: GeneratedEventWrapper) => {
|
||||
generateEvent: async (eventWrapper: GeneratedEventWrapper) => {
|
||||
const mappedEvent = {
|
||||
event: eventWrapper.event,
|
||||
source: {
|
||||
|
@ -147,6 +229,15 @@ const generateEventing =
|
|||
timeStamp: eventWrapper.timeStampOverride ?? Date.now(),
|
||||
},
|
||||
};
|
||||
return eventingRef.current.bubble(mappedEvent);
|
||||
if (!interceptorRef) {
|
||||
return eventingRef.current.bubble(mappedEvent);
|
||||
}
|
||||
|
||||
let eventToBubble: EventWrapper | undefined = mappedEvent;
|
||||
eventToBubble = await interceptorRef.current(mappedEvent);
|
||||
|
||||
return eventToBubble
|
||||
? eventingRef.current.bubble(eventToBubble)
|
||||
: Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
|
Загрузка…
Ссылка в новой задаче