diff --git a/apps/kitchen-sink/src/ensemble/screens/home.yaml b/apps/kitchen-sink/src/ensemble/screens/home.yaml index f0d4a9c8a..f5d445cec 100644 --- a/apps/kitchen-sink/src/ensemble/screens/home.yaml +++ b/apps/kitchen-sink/src/ensemble/screens/home.yaml @@ -12,12 +12,10 @@ View: console.log('>>> secret variable >>>', ensemble.secrets.dummyOauthSecret) ensemble.storage.set('products', []); ensemble.invokeAPI('getDummyProducts').then((res) => ensemble.storage.set('products', (res?.body?.users || []).map((i) => ({ ...i, name: i.firstName + ' ' + i.lastName })))); - const res = await ensemble.invokeAPI('getDummyNumbers') - await new Promise((resolve) => setTimeout(resolve, 5000)) - return res + ensemble.invokeAPI('getDummyNumbers') onComplete: executeCode: | - console.log('API triggered', result) + console.log('API triggered') header: title: diff --git a/packages/framework/src/api/data.ts b/packages/framework/src/api/data.ts index 5efd9d124..3436fa314 100644 --- a/packages/framework/src/api/data.ts +++ b/packages/framework/src/api/data.ts @@ -50,31 +50,38 @@ export const invokeAPI = async ( const useMockResponse = has(api, "mockResponse") && isUsingMockResponse(screenContext.app?.id); - const response = await queryClient.fetchQuery({ - queryKey: [hash], - queryFn: () => - DataFetcher.fetch( - api, - { ...apiInputs, ...context }, - { - mockResponse: mockResponse( - evaluatedMockResponse ?? api.mockResponse, + try { + const response = await queryClient.fetchQuery({ + queryKey: [hash], + queryFn: () => + DataFetcher.fetch( + api, + { ...apiInputs, ...context }, + { + mockResponse: mockResponse( + evaluatedMockResponse ?? api.mockResponse, + useMockResponse, + ), useMockResponse, - ), - useMockResponse, - }, - ), - staleTime: - api.cacheExpirySeconds && !options?.bypassCache - ? api.cacheExpirySeconds * 1000 - : 0, - }); + }, + ), + staleTime: + api.cacheExpirySeconds && !options?.bypassCache + ? api.cacheExpirySeconds * 1000 + : 0, + }); - if (setter) { - set(update, api.name, response); - setter(screenDataAtom, { ...update }); + if (setter) { + set(update, api.name, response); + setter(screenDataAtom, { ...update }); + } + + api.onResponseAction?.callback({ ...context, response }); + + return response; + } catch (err) { + api.onErrorAction?.callback({ ...context, error: err }); } - return response; }; export const handleConnectSocket = ( diff --git a/packages/framework/src/shared/models.ts b/packages/framework/src/shared/models.ts index 66ad71651..46aeb7ad2 100644 --- a/packages/framework/src/shared/models.ts +++ b/packages/framework/src/shared/models.ts @@ -1,5 +1,5 @@ import type { CSSProperties } from "react"; -import type { EnsembleAction } from "./actions"; +import type { EnsembleAction, EnsembleActionHookResult } from "./actions"; import type { EnsembleConfigYAML } from "./dto"; /** @@ -74,6 +74,8 @@ export interface EnsembleAPIModel { body?: string | object; onResponse?: EnsembleAction; onError?: EnsembleAction; + onResponseAction?: EnsembleActionHookResult; + onErrorAction?: EnsembleActionHookResult; mockResponse?: EnsembleMockResponse | string; } diff --git a/packages/runtime/src/runtime/hooks/__tests__/useExecuteCode.test.tsx b/packages/runtime/src/runtime/hooks/__tests__/useExecuteCode.test.tsx index 064b81cd1..f51fec35f 100644 --- a/packages/runtime/src/runtime/hooks/__tests__/useExecuteCode.test.tsx +++ b/packages/runtime/src/runtime/hooks/__tests__/useExecuteCode.test.tsx @@ -146,7 +146,7 @@ test("call ensemble.invokeAPI with bypassCache", async () => { expect(withoutForceInitialResult).toBe(withoutForceResult); expect(withForceResult).not.toBe(withoutForceResult); -}); +}, 10000); test.todo("populates application invokables"); diff --git a/packages/runtime/src/runtime/hooks/__tests__/useInvokeApi.test.tsx b/packages/runtime/src/runtime/hooks/__tests__/useInvokeApi.test.tsx index c8fbb8750..7fd166639 100644 --- a/packages/runtime/src/runtime/hooks/__tests__/useInvokeApi.test.tsx +++ b/packages/runtime/src/runtime/hooks/__tests__/useInvokeApi.test.tsx @@ -40,407 +40,536 @@ const BrowserRouterWrapper = ({ children }: BrowserRouterProps) => ( const logSpy = jest.spyOn(console, "log").mockImplementation(jest.fn()); const errorSpy = jest.spyOn(console, "error").mockImplementation(jest.fn()); -beforeEach(() => { - jest.useFakeTimers(); -}); +describe("fetch API cache test", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); -afterEach(() => { - logSpy.mockClear(); - errorSpy.mockClear(); - jest.clearAllMocks(); - jest.useRealTimers(); - queryClient.clear(); -}); + afterEach(() => { + logSpy.mockClear(); + errorSpy.mockClear(); + jest.clearAllMocks(); + jest.useRealTimers(); + queryClient.clear(); + }); -test("fetch API cache response for cache expiry", async () => { - fetchMock.mockResolvedValue({ body: { data: "foobar" } }); - - render( - { + fetchMock.mockResolvedValue({ body: { data: "foobar" } }); + + render( + , - { wrapper: BrowserRouterWrapper }, - ); - - const button = screen.getByText("Test Cache"); - fireEvent.click(button); - - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(1); - }); + apis: [{ name: "testCache", method: "GET", cacheExpirySeconds: 5 }], + }} + />, + { wrapper: BrowserRouterWrapper }, + ); - fireEvent.click(button); + const button = screen.getByText("Test Cache"); + fireEvent.click(button); - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(1); - }); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + fireEvent.click(button); - jest.advanceTimersByTime(5000); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); - fireEvent.click(button); + jest.advanceTimersByTime(5000); - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(logSpy).toHaveBeenCalledWith("foobar"); + fireEvent.click(button); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(logSpy).toHaveBeenCalledWith("foobar"); + }); }); -}); -test("fetch API cache response for unique inputs while cache expiry", async () => { - fetchMock.mockResolvedValue({ body: { data: "foobar" } }); - - render( - { + fetchMock.mockResolvedValue({ body: { data: "foobar" } }); + + render( + , - { wrapper: BrowserRouterWrapper }, - ); + apis: [ + { + name: "testCache", + method: "GET", + inputs: ["page"], + cacheExpirySeconds: 60, + }, + ], + }} + />, + { wrapper: BrowserRouterWrapper }, + ); - const button = screen.getByText("Page 1"); - fireEvent.click(button); + const button = screen.getByText("Page 1"); + fireEvent.click(button); - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(1); - }); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); - fireEvent.click(button); + fireEvent.click(button); - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(1); - }); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); - const button2 = screen.getByText("Page 2"); - fireEvent.click(button2); + const button2 = screen.getByText("Page 2"); + fireEvent.click(button2); - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(2); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); }); -}); -test("fetch API without cache", async () => { - fetchMock.mockResolvedValue({ body: { data: "foobar" } }); - - render( - { + fetchMock.mockResolvedValue({ body: { data: "foobar" } }); + + render( + , - { wrapper: BrowserRouterWrapper }, - ); - - const button = screen.getByText("Trigger API"); - fireEvent.click(button); - - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(logSpy).toHaveBeenCalledWith("foobar"); - }); + apis: [{ name: "testCache", method: "GET" }], + }} + />, + { wrapper: BrowserRouterWrapper }, + ); - fireEvent.click(button); + const button = screen.getByText("Trigger API"); + fireEvent.click(button); - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(2); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith("foobar"); + }); + + fireEvent.click(button); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); }); -}); -test("after API response modal should close", async () => { - fetchMock.mockResolvedValue({ body: { data: "foobar" } }); - - render( - { + fetchMock.mockResolvedValue({ body: { data: "foobar" } }); + + render( + , - { wrapper: BrowserRouterWrapper }, - ); - - const showDialogButton = screen.getByText("Show Dialog"); - fireEvent.click(showDialogButton); - - const modalTitle = screen.getByText("This is modal"); - const triggerAPIButton = screen.getByText("Trigger API"); - - await waitFor(() => { - expect(modalTitle).toBeInTheDocument(); - expect(triggerAPIButton).toBeInTheDocument(); - }); + apis: [{ name: "testCache", method: "GET" }], + }} + />, + { wrapper: BrowserRouterWrapper }, + ); + + const showDialogButton = screen.getByText("Show Dialog"); + fireEvent.click(showDialogButton); - fireEvent.click(triggerAPIButton); + const modalTitle = screen.getByText("This is modal"); + const triggerAPIButton = screen.getByText("Trigger API"); - await waitFor(() => { - expect(modalTitle).not.toBeInTheDocument(); - expect(triggerAPIButton).not.toBeInTheDocument(); + await waitFor(() => { + expect(modalTitle).toBeInTheDocument(); + expect(triggerAPIButton).toBeInTheDocument(); + }); + + fireEvent.click(triggerAPIButton); + + await waitFor(() => { + expect(modalTitle).not.toBeInTheDocument(); + expect(triggerAPIButton).not.toBeInTheDocument(); + }); }); -}); -test("fetch API with force cache clear", async () => { - fetchMock.mockResolvedValue({ body: { data: "foobar" } }); - - render( - { + fetchMock.mockResolvedValue({ body: { data: "foobar" } }); + + render( + , - { wrapper: BrowserRouterWrapper }, - ); + apis: [ + { + name: "testForceCache", + method: "GET", + cacheExpirySeconds: 60, + }, + ], + }} + />, + { wrapper: BrowserRouterWrapper }, + ); - const withoutForce = screen.getByText("Without Force"); - fireEvent.click(withoutForce); + const withoutForce = screen.getByText("Without Force"); + fireEvent.click(withoutForce); - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(1); - }); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); - fireEvent.click(withoutForce); + fireEvent.click(withoutForce); - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(1); - }); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); - const withForce = screen.getByText("With Force"); - fireEvent.click(withForce); + const withForce = screen.getByText("With Force"); + fireEvent.click(withForce); - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(2); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); }); -}); -test("after API fetching using toggle check states", async () => { - fetchMock.mockResolvedValueOnce({ body: { data: "foo" }, isLoading: false }); - fetchMock.mockResolvedValueOnce({ body: { data: "bar" }, isLoading: false }); - - render( - { + fetchMock.mockResolvedValueOnce({ + body: { data: "foo" }, + isLoading: false, + }); + fetchMock.mockResolvedValueOnce({ + body: { data: "bar" }, + isLoading: false, + }); + + render( + , - { - wrapper: BrowserRouterWrapper, - }, - ); - - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(screen.getByText("Foo")).toBeInTheDocument(); - expect(screen.getByText("Bar")).toBeInTheDocument(); - }); - - fireEvent.click(screen.getByText("Bar")); - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(2); + apis: [ + { name: "fetchFoo", method: "GET" }, + { name: "fetchBar", method: "GET" }, + ], + onLoad: { invokeAPI: { name: "fetchFoo" } }, + }} + />, + { + wrapper: BrowserRouterWrapper, + }, + ); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(screen.getByText("Foo")).toBeInTheDocument(); + expect(screen.getByText("Bar")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("Bar")); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + fireEvent.click(screen.getByText("Foo")); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + fireEvent.click(screen.getByText("Verify States")); + await waitFor(() => { + expect(logSpy).toHaveBeenCalledWith(false); + }); }); +}); - fireEvent.click(screen.getByText("Foo")); - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(2); +describe("test action callback for ensemble.invokeAPI", () => { + afterEach(() => { + logSpy.mockClear(); + errorSpy.mockClear(); + jest.clearAllMocks(); + jest.useRealTimers(); + queryClient.clear(); }); - fireEvent.click(screen.getByText("Verify States")); - await waitFor(() => { - expect(logSpy).toHaveBeenCalledWith(false); + test("test executeCode and ensemble.invokeAPI with onLoad", async () => { + render( + , + { wrapper: BrowserRouterWrapper }, + ); + + await waitFor(() => { + expect(logSpy).toHaveBeenCalledWith("onResponse from API"); + }); + + const button = screen.getByText("Trigger Invoke API"); + fireEvent.click(button); + + await waitFor(() => { + expect(logSpy).toHaveBeenCalledWith("onResponse from invokeAPI"); + }); + + const triggerAPI = screen.getByText("Trigger API"); + fireEvent.click(triggerAPI); + + await waitFor(() => { + expect(logSpy).toHaveBeenCalledWith("onResponse from API"); + expect(logSpy).toHaveBeenCalledWith("onResponse from inside"); + }); + + const triggerAPIError = screen.getByText("Trigger API Error"); + fireEvent.click(triggerAPIError); + + await waitFor(() => { + expect(logSpy).toHaveBeenCalledWith("onError from API"); + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ + error: "Request failed with status code 404", + }), + ); + }); }); }); diff --git a/packages/runtime/src/runtime/hooks/useEnsembleAction.tsx b/packages/runtime/src/runtime/hooks/useEnsembleAction.tsx index f60f58833..5fb75451d 100644 --- a/packages/runtime/src/runtime/hooks/useEnsembleAction.tsx +++ b/packages/runtime/src/runtime/hooks/useEnsembleAction.tsx @@ -171,9 +171,6 @@ export const useInvokeAPI: EnsembleActionHook = (action) => { return apis?.find((api) => api.name === action.name); }, [action?.name, apis]); - const onAPIResponseAction = useEnsembleAction(currentApi?.onResponse); - const onAPIErrorAction = useEnsembleAction(currentApi?.onError); - const invokeCommand = useCommandCallback( async (evalContext, ...args: unknown[]) => { if (!action?.name || !currentApi) return; @@ -245,10 +242,7 @@ export const useInvokeAPI: EnsembleActionHook = (action) => { setData(action.id, response); } - onAPIResponseAction?.callback({ - ...(args[0] as { [key: string]: unknown }), - response, - }); + currentApi.onResponseAction?.callback({ ...context, response }); onInvokeAPIResponseAction?.callback({ ...(args[0] as { [key: string]: unknown }), response, @@ -270,10 +264,7 @@ export const useInvokeAPI: EnsembleActionHook = (action) => { }); } - onAPIErrorAction?.callback({ - ...(args[0] as { [key: string]: unknown }), - error: e, - }); + currentApi.onErrorAction?.callback({ ...context, error: e }); onInvokeAPIErrorAction?.callback({ ...(args[0] as { [key: string]: unknown }), error: e, diff --git a/packages/runtime/src/runtime/hooks/useProcessApiDefinitions.ts b/packages/runtime/src/runtime/hooks/useProcessApiDefinitions.ts new file mode 100644 index 000000000..57a7020f8 --- /dev/null +++ b/packages/runtime/src/runtime/hooks/useProcessApiDefinitions.ts @@ -0,0 +1,27 @@ +import type { EnsembleScreenModel } from "@ensembleui/react-framework"; +import { isEmpty } from "lodash-es"; +// eslint-disable-next-line import/no-cycle +import { useEnsembleAction } from "./useEnsembleAction"; + +export const useProcessApiDefinitions = ( + screen: EnsembleScreenModel, +): boolean => { + if (isEmpty(screen.apis)) { + return true; + } + + screen.apis = screen?.apis?.map((api) => { + /* eslint-disable react-hooks/rules-of-hooks */ + const onResponseAction = useEnsembleAction(api.onResponse); + const onErrorAction = useEnsembleAction(api.onError); + /* eslint-enable react-hooks/rules-of-hooks */ + + return { + ...api, + onResponseAction, + onErrorAction, + }; + }); + + return true; +}; diff --git a/packages/runtime/src/runtime/screen.tsx b/packages/runtime/src/runtime/screen/index.tsx similarity index 67% rename from packages/runtime/src/runtime/screen.tsx rename to packages/runtime/src/runtime/screen/index.tsx index e0b329f16..ad71b868f 100644 --- a/packages/runtime/src/runtime/screen.tsx +++ b/packages/runtime/src/runtime/screen/index.tsx @@ -1,22 +1,19 @@ -import type { - EnsembleAction, - EnsembleScreenModel, -} from "@ensembleui/react-framework"; -import { ScreenContextProvider, error } from "@ensembleui/react-framework"; +import type { EnsembleScreenModel } from "@ensembleui/react-framework"; +import { ScreenContextProvider } from "@ensembleui/react-framework"; import React, { useEffect, useState } from "react"; import { useLocation, useParams, useOutletContext } from "react-router-dom"; import { isEmpty, merge } from "lodash-es"; -import { type WidgetComponent, WidgetRegistry } from "../registry"; -// FIXME: refactor +import { type WidgetComponent, WidgetRegistry } from "../../registry"; // eslint-disable-next-line import/no-cycle -import { useEnsembleAction } from "./hooks/useEnsembleAction"; -import { EnsembleHeader } from "./header"; -import { EnsembleFooter } from "./footer"; -import { EnsembleBody } from "./body"; -import { ModalWrapper } from "./modal"; -import { createCustomWidget } from "./customWidget"; -import type { EnsembleMenuContext } from "./menu"; -import { EnsembleMenu } from "./menu"; +import { EnsembleHeader } from "../header"; +import { EnsembleFooter } from "../footer"; +import { EnsembleBody } from "../body"; +import { ModalWrapper } from "../modal"; +import { createCustomWidget } from "../customWidget"; +import type { EnsembleMenuContext } from "../menu"; +import { EnsembleMenu } from "../menu"; +import { useProcessApiDefinitions } from "../hooks/useProcessApiDefinitions"; +import { OnLoadAction } from "./onLoadAction"; export interface EnsembleScreenProps { screen: EnsembleScreenModel; @@ -49,8 +46,13 @@ export const EnsembleScreen: React.FC = ({ inputs, ) as { [key: string]: unknown }; + const isAPIProcessed = useProcessApiDefinitions(screen); + useEffect(() => { - if (!screen.customWidgets || isEmpty(screen.customWidgets)) { + // Ensure customWidgets is defined before using it + const customWidgets = screen.customWidgets || []; + + if (isEmpty(customWidgets)) { setIsInitialized(true); return; } @@ -61,7 +63,7 @@ export const EnsembleScreen: React.FC = ({ } = {}; // load screen custom widgets - screen.customWidgets.forEach((customWidget) => { + customWidgets.forEach((customWidget) => { const originalImplementation = WidgetRegistry.findOrNull( customWidget.name, ); @@ -79,7 +81,7 @@ export const EnsembleScreen: React.FC = ({ return () => { // unMount screen custom widgets - screen.customWidgets?.forEach((customWidget) => { + customWidgets.forEach((customWidget) => { WidgetRegistry.unregister(customWidget.name); if (customWidget.name in initialWidgetValues) { WidgetRegistry.register( @@ -91,7 +93,7 @@ export const EnsembleScreen: React.FC = ({ }; }, [screen.customWidgets]); - if (!isInitialized) { + if (!isInitialized || !isAPIProcessed) { return null; } @@ -118,27 +120,3 @@ export const EnsembleScreen: React.FC = ({ ); }; - -const OnLoadAction: React.FC< - React.PropsWithChildren<{ - action?: EnsembleAction; - context: { [key: string]: unknown }; - }> -> = ({ action, children, context }) => { - const onLoadAction = useEnsembleAction(action); - const [isComplete, setIsComplete] = useState(false); - useEffect(() => { - if (!onLoadAction?.callback || isComplete) { - return; - } - try { - onLoadAction.callback(context); - } catch (e) { - error(e); - } finally { - setIsComplete(true); - } - }, [context, isComplete, onLoadAction?.callback]); - - return <>{children}; -}; diff --git a/packages/runtime/src/runtime/screen/onLoadAction.tsx b/packages/runtime/src/runtime/screen/onLoadAction.tsx new file mode 100644 index 000000000..198f52ab5 --- /dev/null +++ b/packages/runtime/src/runtime/screen/onLoadAction.tsx @@ -0,0 +1,28 @@ +import { useEffect, useState } from "react"; +import { error, type EnsembleAction } from "@ensembleui/react-framework"; +// eslint-disable-next-line import/no-cycle +import { useEnsembleAction } from "../hooks"; + +export const OnLoadAction: React.FC< + React.PropsWithChildren<{ + action?: EnsembleAction; + context: { [key: string]: unknown }; + }> +> = ({ action, children, context }) => { + const onLoadAction = useEnsembleAction(action); + const [isComplete, setIsComplete] = useState(false); + useEffect(() => { + if (!onLoadAction?.callback || isComplete) { + return; + } + try { + onLoadAction.callback(context); + } catch (e) { + error(e); + } finally { + setIsComplete(true); + } + }, [context, isComplete, onLoadAction?.callback]); + + return <>{children}; +};