From 96ed2c8c55fe7b92856153b98aa7079c5acbc1c4 Mon Sep 17 00:00:00 2001 From: sagar davara Date: Thu, 27 Feb 2025 17:41:35 +0530 Subject: [PATCH 1/4] Added support for action callbacks in ensemble.invokeAPI --- .../src/ensemble/screens/home.yaml | 6 +- packages/framework/src/api/data.ts | 51 +- packages/framework/src/hooks/useScreenData.ts | 17 +- packages/framework/src/shared/models.ts | 4 +- packages/framework/src/state/screen.ts | 15 +- .../hooks/__tests__/useInvokeApi.test.tsx | 791 ++++++++++-------- .../src/runtime/hooks/useEnsembleAction.tsx | 13 +- .../runtime/{screen.tsx => screen/index.tsx} | 20 +- .../runtime/src/runtime/screen/wrapper.tsx | 34 + 9 files changed, 570 insertions(+), 381 deletions(-) rename packages/runtime/src/runtime/{screen.tsx => screen/index.tsx} (88%) create mode 100644 packages/runtime/src/runtime/screen/wrapper.tsx 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/hooks/useScreenData.ts b/packages/framework/src/hooks/useScreenData.ts index 2996ec013..f4b016319 100644 --- a/packages/framework/src/hooks/useScreenData.ts +++ b/packages/framework/src/hooks/useScreenData.ts @@ -1,6 +1,7 @@ import { useAtom, useAtomValue } from "jotai"; import { useCallback, useMemo } from "react"; import isEqual from "react-fast-compare"; +import { clone } from "lodash-es"; import type { Response, WebSocketConnection } from "../data"; import type { EnsembleAPIModel, @@ -19,13 +20,14 @@ export const useScreenData = (): { name: string, response: Partial | WebSocketConnection, ) => void; + setApi: (apiData: EnsembleAPIModel) => void; mockResponses: { [apiName: string]: EnsembleMockResponse | string | undefined; }; } => { - const apis = useAtomValue(screenApiAtom); const sockets = useAtomValue(screenSocketAtom); const [data, setDataAtom] = useAtom(screenDataAtom); + const [apis, setApiAtom] = useAtom(screenApiAtom); const apiMockResponses = useMemo(() => { return apis?.reduce( @@ -53,11 +55,24 @@ export const useScreenData = (): { [data, setDataAtom], ); + const setApi = useCallback( + (apiData: EnsembleAPIModel) => { + const index = apis?.findIndex((api) => api.name === apiData.name); + if (index === undefined || isEqual(apis?.[index], apiData) || !apis) { + return; + } + apis[index] = apiData; + setApiAtom(clone(apis)); + }, + [apis, setApiAtom], + ); + return { apis, sockets, data, setData, + setApi, mockResponses, }; }; 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/framework/src/state/screen.ts b/packages/framework/src/state/screen.ts index 28faf1222..2827aa683 100644 --- a/packages/framework/src/state/screen.ts +++ b/packages/framework/src/state/screen.ts @@ -3,7 +3,11 @@ import { focusAtom } from "jotai-optics"; import { assign } from "lodash-es"; import { atomFamily } from "jotai/utils"; import { type Response, type WebSocketConnection } from "../data"; -import type { EnsembleAppModel, EnsembleScreenModel } from "../shared"; +import type { + EnsembleAPIModel, + EnsembleAppModel, + EnsembleScreenModel, +} from "../shared"; import type { WidgetState } from "./widget"; export interface ScreenContextDefinition { @@ -57,10 +61,17 @@ export const screenModelAtom = focusAtom(screenAtom, (optic) => optic.prop("model"), ); -export const screenApiAtom = focusAtom(screenAtom, (optic) => { +export const screenApiFocusAtom = focusAtom(screenAtom, (optic) => { return optic.prop("model").optional().prop("apis"); }); +export const screenApiAtom = atom( + (get) => get(screenApiFocusAtom), + (_, set, update: EnsembleAPIModel[]) => { + set(screenApiFocusAtom, update); + }, +); + export const screenSocketAtom = focusAtom(screenAtom, (optic) => { return optic.prop("model").optional().prop("sockets"); }); 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/screen.tsx b/packages/runtime/src/runtime/screen/index.tsx similarity index 88% rename from packages/runtime/src/runtime/screen.tsx rename to packages/runtime/src/runtime/screen/index.tsx index e0b329f16..c4466fc60 100644 --- a/packages/runtime/src/runtime/screen.tsx +++ b/packages/runtime/src/runtime/screen/index.tsx @@ -6,17 +6,18 @@ import { ScreenContextProvider, error } 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"; +import { type WidgetComponent, WidgetRegistry } from "../../registry"; // FIXME: refactor // 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 { 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 { ScreenApiWrapper } from "./wrapper"; export interface EnsembleScreenProps { screen: EnsembleScreenModel; @@ -102,6 +103,7 @@ export const EnsembleScreen: React.FC = ({ screen={screen} > + diff --git a/packages/runtime/src/runtime/screen/wrapper.tsx b/packages/runtime/src/runtime/screen/wrapper.tsx new file mode 100644 index 000000000..248a8e40f --- /dev/null +++ b/packages/runtime/src/runtime/screen/wrapper.tsx @@ -0,0 +1,34 @@ +import type { EnsembleAPIModel } from "@ensembleui/react-framework"; +import { useScreenData } from "@ensembleui/react-framework"; +import { useEffect } from "react"; +// eslint-disable-next-line import/no-cycle +import { useEnsembleAction } from "../hooks"; + +export const ScreenApiWrapper: React.FC = () => { + const { apis = [], setApi } = useScreenData(); + + return ( + <> + {apis.map((api) => ( + + ))} + + ); +}; + +const EvaluateApi = ({ + api, + setApi, +}: { + api: EnsembleAPIModel; + setApi: (apiData: EnsembleAPIModel) => void; +}): null => { + const onResponseAction = useEnsembleAction(api.onResponse); + const onErrorAction = useEnsembleAction(api.onError); + + useEffect(() => { + setApi({ ...api, onResponseAction, onErrorAction }); + }, [api.name]); + + return null; +}; From 89b259d2618925f60c2940328b7709dc57e09b1d Mon Sep 17 00:00:00 2001 From: sagar davara Date: Tue, 4 Mar 2025 16:28:14 +0530 Subject: [PATCH 2/4] fix: improve api processing --- .../hooks/__tests__/useExecuteCode.test.tsx | 2 +- packages/runtime/src/runtime/screen/index.tsx | 29 ++++++++++--- .../runtime/src/runtime/screen/wrapper.tsx | 43 +++++++------------ 3 files changed, 39 insertions(+), 35 deletions(-) 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/screen/index.tsx b/packages/runtime/src/runtime/screen/index.tsx index c4466fc60..37f2c3577 100644 --- a/packages/runtime/src/runtime/screen/index.tsx +++ b/packages/runtime/src/runtime/screen/index.tsx @@ -17,7 +17,7 @@ import { ModalWrapper } from "../modal"; import { createCustomWidget } from "../customWidget"; import type { EnsembleMenuContext } from "../menu"; import { EnsembleMenu } from "../menu"; -import { ScreenApiWrapper } from "./wrapper"; +import { processApiDefinitions } from "./wrapper"; export interface EnsembleScreenProps { screen: EnsembleScreenModel; @@ -32,6 +32,7 @@ export const EnsembleScreen: React.FC = ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const { state, search, pathname } = useLocation(); const [isInitialized, setIsInitialized] = useState(false); + const [isAPIProcessed, setIsAPIProcessed] = useState(false); const routeParams = useParams(); // route params const params = new URLSearchParams(search); // query params const queryParams: { [key: string]: unknown } = Object.fromEntries(params); @@ -50,8 +51,23 @@ export const EnsembleScreen: React.FC = ({ inputs, ) as { [key: string]: unknown }; + const processedAPIs = processApiDefinitions(screen.apis); + useEffect(() => { - if (!screen.customWidgets || isEmpty(screen.customWidgets)) { + if (isEmpty(screen.apis)) { + setIsAPIProcessed(true); + return; + } + + screen.apis = processedAPIs; + setIsAPIProcessed(true); + }, [processedAPIs]); + + useEffect(() => { + // Ensure customWidgets is defined before using it + const customWidgets = screen?.customWidgets || []; + + if (isEmpty(customWidgets)) { setIsInitialized(true); return; } @@ -62,7 +78,7 @@ export const EnsembleScreen: React.FC = ({ } = {}; // load screen custom widgets - screen.customWidgets.forEach((customWidget) => { + customWidgets.forEach((customWidget) => { const originalImplementation = WidgetRegistry.findOrNull( customWidget.name, ); @@ -80,7 +96,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( @@ -90,9 +106,9 @@ export const EnsembleScreen: React.FC = ({ } }); }; - }, [screen.customWidgets]); + }, [screen?.customWidgets]); - if (!isInitialized) { + if (!isInitialized || !isAPIProcessed) { return null; } @@ -103,7 +119,6 @@ export const EnsembleScreen: React.FC = ({ screen={screen} > - diff --git a/packages/runtime/src/runtime/screen/wrapper.tsx b/packages/runtime/src/runtime/screen/wrapper.tsx index 248a8e40f..674bc9128 100644 --- a/packages/runtime/src/runtime/screen/wrapper.tsx +++ b/packages/runtime/src/runtime/screen/wrapper.tsx @@ -1,34 +1,23 @@ import type { EnsembleAPIModel } from "@ensembleui/react-framework"; -import { useScreenData } from "@ensembleui/react-framework"; -import { useEffect } from "react"; // eslint-disable-next-line import/no-cycle import { useEnsembleAction } from "../hooks"; +import { isEmpty } from "lodash-es"; -export const ScreenApiWrapper: React.FC = () => { - const { apis = [], setApi } = useScreenData(); +export const processApiDefinitions = ( + apis: EnsembleAPIModel[] = [], +): EnsembleAPIModel[] => { + if (isEmpty(apis)) { + return []; + } - return ( - <> - {apis.map((api) => ( - - ))} - - ); -}; - -const EvaluateApi = ({ - api, - setApi, -}: { - api: EnsembleAPIModel; - setApi: (apiData: EnsembleAPIModel) => void; -}): null => { - const onResponseAction = useEnsembleAction(api.onResponse); - const onErrorAction = useEnsembleAction(api.onError); - - useEffect(() => { - setApi({ ...api, onResponseAction, onErrorAction }); - }, [api.name]); + return apis.map((api) => { + const onResponseAction = useEnsembleAction(api.onResponse); + const onErrorAction = useEnsembleAction(api.onError); - return null; + return { + ...api, + onResponseAction, + onErrorAction, + }; + }); }; From 393faf035f7eb1714a732d8a7fa7aee55dbb476d Mon Sep 17 00:00:00 2001 From: sagar davara Date: Wed, 5 Mar 2025 12:29:55 +0530 Subject: [PATCH 3/4] fix: added useProcessApiDefinitions hook --- packages/framework/src/hooks/useScreenData.ts | 17 +------ packages/framework/src/state/screen.ts | 15 +------ .../useProcessApiDefinitions.ts} | 8 ++-- packages/runtime/src/runtime/screen/index.tsx | 44 ++++--------------- .../src/runtime/screen/onLoadAction.tsx | 28 ++++++++++++ 5 files changed, 44 insertions(+), 68 deletions(-) rename packages/runtime/src/runtime/{screen/wrapper.tsx => hooks/useProcessApiDefinitions.ts} (70%) create mode 100644 packages/runtime/src/runtime/screen/onLoadAction.tsx diff --git a/packages/framework/src/hooks/useScreenData.ts b/packages/framework/src/hooks/useScreenData.ts index f4b016319..2996ec013 100644 --- a/packages/framework/src/hooks/useScreenData.ts +++ b/packages/framework/src/hooks/useScreenData.ts @@ -1,7 +1,6 @@ import { useAtom, useAtomValue } from "jotai"; import { useCallback, useMemo } from "react"; import isEqual from "react-fast-compare"; -import { clone } from "lodash-es"; import type { Response, WebSocketConnection } from "../data"; import type { EnsembleAPIModel, @@ -20,14 +19,13 @@ export const useScreenData = (): { name: string, response: Partial | WebSocketConnection, ) => void; - setApi: (apiData: EnsembleAPIModel) => void; mockResponses: { [apiName: string]: EnsembleMockResponse | string | undefined; }; } => { + const apis = useAtomValue(screenApiAtom); const sockets = useAtomValue(screenSocketAtom); const [data, setDataAtom] = useAtom(screenDataAtom); - const [apis, setApiAtom] = useAtom(screenApiAtom); const apiMockResponses = useMemo(() => { return apis?.reduce( @@ -55,24 +53,11 @@ export const useScreenData = (): { [data, setDataAtom], ); - const setApi = useCallback( - (apiData: EnsembleAPIModel) => { - const index = apis?.findIndex((api) => api.name === apiData.name); - if (index === undefined || isEqual(apis?.[index], apiData) || !apis) { - return; - } - apis[index] = apiData; - setApiAtom(clone(apis)); - }, - [apis, setApiAtom], - ); - return { apis, sockets, data, setData, - setApi, mockResponses, }; }; diff --git a/packages/framework/src/state/screen.ts b/packages/framework/src/state/screen.ts index 2827aa683..28faf1222 100644 --- a/packages/framework/src/state/screen.ts +++ b/packages/framework/src/state/screen.ts @@ -3,11 +3,7 @@ import { focusAtom } from "jotai-optics"; import { assign } from "lodash-es"; import { atomFamily } from "jotai/utils"; import { type Response, type WebSocketConnection } from "../data"; -import type { - EnsembleAPIModel, - EnsembleAppModel, - EnsembleScreenModel, -} from "../shared"; +import type { EnsembleAppModel, EnsembleScreenModel } from "../shared"; import type { WidgetState } from "./widget"; export interface ScreenContextDefinition { @@ -61,17 +57,10 @@ export const screenModelAtom = focusAtom(screenAtom, (optic) => optic.prop("model"), ); -export const screenApiFocusAtom = focusAtom(screenAtom, (optic) => { +export const screenApiAtom = focusAtom(screenAtom, (optic) => { return optic.prop("model").optional().prop("apis"); }); -export const screenApiAtom = atom( - (get) => get(screenApiFocusAtom), - (_, set, update: EnsembleAPIModel[]) => { - set(screenApiFocusAtom, update); - }, -); - export const screenSocketAtom = focusAtom(screenAtom, (optic) => { return optic.prop("model").optional().prop("sockets"); }); diff --git a/packages/runtime/src/runtime/screen/wrapper.tsx b/packages/runtime/src/runtime/hooks/useProcessApiDefinitions.ts similarity index 70% rename from packages/runtime/src/runtime/screen/wrapper.tsx rename to packages/runtime/src/runtime/hooks/useProcessApiDefinitions.ts index 674bc9128..91a5247f6 100644 --- a/packages/runtime/src/runtime/screen/wrapper.tsx +++ b/packages/runtime/src/runtime/hooks/useProcessApiDefinitions.ts @@ -1,9 +1,9 @@ import type { EnsembleAPIModel } from "@ensembleui/react-framework"; -// eslint-disable-next-line import/no-cycle -import { useEnsembleAction } from "../hooks"; import { isEmpty } from "lodash-es"; +// eslint-disable-next-line import/no-cycle +import { useEnsembleAction } from "./useEnsembleAction"; -export const processApiDefinitions = ( +export const useProcessApiDefinitions = ( apis: EnsembleAPIModel[] = [], ): EnsembleAPIModel[] => { if (isEmpty(apis)) { @@ -11,8 +11,10 @@ export const processApiDefinitions = ( } return 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, diff --git a/packages/runtime/src/runtime/screen/index.tsx b/packages/runtime/src/runtime/screen/index.tsx index 37f2c3577..66e1f133a 100644 --- a/packages/runtime/src/runtime/screen/index.tsx +++ b/packages/runtime/src/runtime/screen/index.tsx @@ -1,15 +1,10 @@ -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 // eslint-disable-next-line import/no-cycle -import { useEnsembleAction } from "../hooks/useEnsembleAction"; import { EnsembleHeader } from "../header"; import { EnsembleFooter } from "../footer"; import { EnsembleBody } from "../body"; @@ -17,7 +12,8 @@ import { ModalWrapper } from "../modal"; import { createCustomWidget } from "../customWidget"; import type { EnsembleMenuContext } from "../menu"; import { EnsembleMenu } from "../menu"; -import { processApiDefinitions } from "./wrapper"; +import { useProcessApiDefinitions } from "../hooks/useProcessApiDefinitions"; +import { OnLoadAction } from "./onLoadAction"; export interface EnsembleScreenProps { screen: EnsembleScreenModel; @@ -51,7 +47,7 @@ export const EnsembleScreen: React.FC = ({ inputs, ) as { [key: string]: unknown }; - const processedAPIs = processApiDefinitions(screen.apis); + const processedAPIs = useProcessApiDefinitions(screen.apis); useEffect(() => { if (isEmpty(screen.apis)) { @@ -61,11 +57,11 @@ export const EnsembleScreen: React.FC = ({ screen.apis = processedAPIs; setIsAPIProcessed(true); - }, [processedAPIs]); + }, [processedAPIs, screen]); useEffect(() => { // Ensure customWidgets is defined before using it - const customWidgets = screen?.customWidgets || []; + const customWidgets = screen.customWidgets || []; if (isEmpty(customWidgets)) { setIsInitialized(true); @@ -106,7 +102,7 @@ export const EnsembleScreen: React.FC = ({ } }); }; - }, [screen?.customWidgets]); + }, [screen.customWidgets]); if (!isInitialized || !isAPIProcessed) { return null; @@ -135,27 +131,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}; +}; From c8904fbdcde4af05931e3127ac9e37b70e4e05f7 Mon Sep 17 00:00:00 2001 From: sagar davara Date: Tue, 11 Mar 2025 12:57:52 +0530 Subject: [PATCH 4/4] fix: update useProcessAPIDefinitions hook --- .../src/runtime/hooks/useProcessApiDefinitions.ts | 14 ++++++++------ packages/runtime/src/runtime/screen/index.tsx | 13 +------------ 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/packages/runtime/src/runtime/hooks/useProcessApiDefinitions.ts b/packages/runtime/src/runtime/hooks/useProcessApiDefinitions.ts index 91a5247f6..57a7020f8 100644 --- a/packages/runtime/src/runtime/hooks/useProcessApiDefinitions.ts +++ b/packages/runtime/src/runtime/hooks/useProcessApiDefinitions.ts @@ -1,16 +1,16 @@ -import type { EnsembleAPIModel } from "@ensembleui/react-framework"; +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 = ( - apis: EnsembleAPIModel[] = [], -): EnsembleAPIModel[] => { - if (isEmpty(apis)) { - return []; + screen: EnsembleScreenModel, +): boolean => { + if (isEmpty(screen.apis)) { + return true; } - return apis.map((api) => { + screen.apis = screen?.apis?.map((api) => { /* eslint-disable react-hooks/rules-of-hooks */ const onResponseAction = useEnsembleAction(api.onResponse); const onErrorAction = useEnsembleAction(api.onError); @@ -22,4 +22,6 @@ export const useProcessApiDefinitions = ( onErrorAction, }; }); + + return true; }; diff --git a/packages/runtime/src/runtime/screen/index.tsx b/packages/runtime/src/runtime/screen/index.tsx index 66e1f133a..ad71b868f 100644 --- a/packages/runtime/src/runtime/screen/index.tsx +++ b/packages/runtime/src/runtime/screen/index.tsx @@ -28,7 +28,6 @@ export const EnsembleScreen: React.FC = ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const { state, search, pathname } = useLocation(); const [isInitialized, setIsInitialized] = useState(false); - const [isAPIProcessed, setIsAPIProcessed] = useState(false); const routeParams = useParams(); // route params const params = new URLSearchParams(search); // query params const queryParams: { [key: string]: unknown } = Object.fromEntries(params); @@ -47,17 +46,7 @@ export const EnsembleScreen: React.FC = ({ inputs, ) as { [key: string]: unknown }; - const processedAPIs = useProcessApiDefinitions(screen.apis); - - useEffect(() => { - if (isEmpty(screen.apis)) { - setIsAPIProcessed(true); - return; - } - - screen.apis = processedAPIs; - setIsAPIProcessed(true); - }, [processedAPIs, screen]); + const isAPIProcessed = useProcessApiDefinitions(screen); useEffect(() => { // Ensure customWidgets is defined before using it