From 819881989e6c4e1e6576acae911840a02523a45a Mon Sep 17 00:00:00 2001 From: Pawan Kumar Date: Tue, 10 Dec 2024 13:03:03 +0530 Subject: [PATCH] chore: Refactor WDS custom widget (#38038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /ok-to-test tags="@tag.Widget" Fixes #38028 ## Summary by CodeRabbit ## Summary by CodeRabbit - **New Features** - Introduced a new `IframeMessenger` class for improved communication between the parent window and iframe. - Added `onConsole`, `onTriggerEvent`, and `onUpdateModel` props to the `Preview` component for enhanced event handling. - Streamlined HTML template generation with the `createHtmlTemplate` function. - **Bug Fixes** - Removed unnecessary UI-related event handling from the widget, simplifying the communication structure. - Updated event names in template files from `"onReset"` to `"onResetClick"` for clarity. - **Refactor** - Renamed `CustomComponent` to `CustomWidgetComponent` for clarity. - Modularized message handling logic in the `CustomWidgetComponent`. - Refactored the `defaultApp.ts` to use dynamic template data instead of hardcoded values. - **Style** - Updated CSS for the `.container` class to enhance layout consistency. - **Tests** - Simplified test assertions in the `customWidgetscript` test suite. > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: > Commit: eb55fbdf716e48b677fc01ea40bf6a894ffd75ba > Cypress dashboard. > Tags: `@tag.Widget` > Spec: >
Mon, 09 Dec 2024 12:47:13 UTC --- .../component/IframeMessenger.ts | 46 +++ .../WDSCustomWidget/component/constants.ts | 18 - .../component/createHtmlTemplate.ts | 35 ++ .../component/customWidgetscript.js | 35 -- ...ipt.test.ts => customWidgetscript.test.ts} | 9 - .../wds/WDSCustomWidget/component/index.tsx | 313 ++++++++---------- .../component/styles.module.css | 1 + .../component/useCustomWidgetHeight.ts | 4 +- .../ui/wds/WDSCustomWidget/helpers/index.ts | 18 + .../ui/wds/WDSCustomWidget/types.ts | 48 +++ .../wds/WDSCustomWidget/widget/defaultApp.ts | 194 +---------- .../ui/wds/WDSCustomWidget/widget/index.tsx | 44 ++- .../Templates/anvilTemplates/react.ts | 3 +- .../Templates/anvilTemplates/vanillaJs.ts | 2 +- .../Templates/anvilTemplates/vue.ts | 2 +- .../Header/CodeTemplates/Templates/react.ts | 2 +- .../CodeTemplates/Templates/vanillaJs.ts | 2 +- .../Header/CodeTemplates/Templates/vue.ts | 2 +- .../CustomWidgetBuilder/Preview/index.tsx | 12 +- 19 files changed, 348 insertions(+), 442 deletions(-) create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/IframeMessenger.ts delete mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/constants.ts create mode 100644 app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/createHtmlTemplate.ts rename app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/{customWidgetScript.test.ts => customWidgetscript.test.ts} (98%) diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/IframeMessenger.ts b/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/IframeMessenger.ts new file mode 100644 index 000000000000..d1a7afbbe62d --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/IframeMessenger.ts @@ -0,0 +1,46 @@ +import type { IframeMessage } from "../types"; +import { EVENTS } from "./customWidgetscript"; + +export class IframeMessenger { + private iframe: HTMLIFrameElement; + + constructor(iframe: HTMLIFrameElement) { + this.iframe = iframe; + } + + handleMessage = ( + event: MessageEvent, + handlers: Record) => void>, + ) => { + const iframeWindow = + this.iframe.contentWindow || this.iframe.contentDocument?.defaultView; + + // Without this check, malicious scripts from other windows could inject + // unauthorized messages into our application, potentially leading to data + // breaches or unauthorized state modifications + if (event.source !== iframeWindow) return; + + // We send an acknowledgement message for every event to ensure reliable communication + // between the parent window and iframe. This helps in maintaining message ordering + // and preventing race conditions. + this.acknowledgeMessage(event.data); + + const handler = handlers[event.data.type]; + + if (handler) { + handler(event.data.data); + } + }; + + private acknowledgeMessage(message: IframeMessage) { + this.postMessage({ + type: EVENTS.CUSTOM_WIDGET_MESSAGE_RECEIVED_ACK, + key: message.key, + success: true, + }); + } + + postMessage(message: IframeMessage) { + this.iframe.contentWindow?.postMessage(message, "*"); + } +} diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/constants.ts b/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/constants.ts deleted file mode 100644 index 16e5e6ee3a8b..000000000000 --- a/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/constants.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const CUSTOM_WIDGET_LOAD_EVENTS = { - STARTED: "started", - DOM_CONTENTED_LOADED: "DOMContentLoaded", - COMPLETED: "completed", -}; - -export const getAppsmithScriptSchema = (model: Record) => ({ - appsmith: { - mode: "", - model: model, - onUiChange: Function, - onModelChange: Function, - onThemeChange: Function, - updateModel: Function, - triggerEvent: Function, - onReady: Function, - }, -}); diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/createHtmlTemplate.ts b/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/createHtmlTemplate.ts new file mode 100644 index 000000000000..3cd164c54040 --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/createHtmlTemplate.ts @@ -0,0 +1,35 @@ +// @ts-expect-error - Type error due to raw loader +import css from "!!raw-loader!./reset.css"; + +// @ts-expect-error - Type error due to raw loader +import script from "!!raw-loader!./customWidgetscript.js"; + +// @ts-expect-error - Type error due to raw loader +import appsmithConsole from "!!raw-loader!./appsmithConsole.js"; + +interface CreateHtmlTemplateProps { + cssTokens: string; + onConsole: boolean; + srcDoc: { html: string; js: string; css: string }; +} + +export const createHtmlTemplate = (props: CreateHtmlTemplateProps) => { + const { cssTokens, onConsole, srcDoc } = props; + + return ` + + + + + + ${onConsole ? `` : ""} + + ${srcDoc.html} + + + + `; +}; diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/customWidgetscript.js b/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/customWidgetscript.js index dcabe42ca8e2..93bddd00aabd 100644 --- a/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/customWidgetscript.js +++ b/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/customWidgetscript.js @@ -5,7 +5,6 @@ export const EVENTS = { CUSTOM_WIDGET_UPDATE_MODEL: "CUSTOM_WIDGET_UPDATE_MODEL", CUSTOM_WIDGET_TRIGGER_EVENT: "CUSTOM_WIDGET_TRIGGER_EVENT", CUSTOM_WIDGET_MODEL_CHANGE: "CUSTOM_WIDGET_MODEL_CHANGE", - CUSTOM_WIDGET_UI_CHANGE: "CUSTOM_WIDGET_UI_CHANGE", CUSTOM_WIDGET_MESSAGE_RECEIVED_ACK: "CUSTOM_WIDGET_MESSAGE_RECEIVED_ACK", CUSTOM_WIDGET_CONSOLE_EVENT: "CUSTOM_WIDGET_CONSOLE_EVENT", CUSTOM_WIDGET_THEME_UPDATE: "CUSTOM_WIDGET_THEME_UPDATE", @@ -119,7 +118,6 @@ export function main() { * Variables to hold the subscriber functions */ const modelSubscribers = []; - const uiSubscribers = []; const themeSubscribers = []; /* * Variables to hold ready function and state @@ -139,15 +137,12 @@ export function main() { // Callback for when the READY_ACK message is received channel.onMessage(EVENTS.CUSTOM_WIDGET_READY_ACK, (event) => { window.appsmith.model = event.model; - window.appsmith.ui = event.ui; window.appsmith.theme = event.theme; window.appsmith.mode = event.mode; heightObserver.observe(window.document.body); // Subscribe to model and UI changes window.appsmith.onModelChange(generateAppsmithCssVariables("model")); - window.appsmith.onUiChange(generateAppsmithCssVariables("ui")); - window.appsmith.onThemeChange(generateAppsmithCssVariables("theme")); // Set the widget as ready isReady = true; @@ -170,18 +165,6 @@ export function main() { }); } }); - // Callback for when UI_CHANGE message is received - channel.onMessage(EVENTS.CUSTOM_WIDGET_UI_CHANGE, (event) => { - if (event.ui) { - const prevUi = window.appsmith.ui; - - window.appsmith.ui = event.ui; - // Notify UI subscribers - uiSubscribers.forEach((fn) => { - fn(event.ui, prevUi); - }); - } - }); channel.onMessage(EVENTS.CUSTOM_WIDGET_THEME_UPDATE, (event) => { if (event.theme) { @@ -235,23 +218,6 @@ export function main() { } }; }, - onUiChange: (fn) => { - if (typeof fn !== "function") { - throw new Error("onUiChange expects a function as parameter"); - } - - uiSubscribers.push(fn); - fn(window.appsmith.ui); - - return () => { - // Unsubscribe from UI changes - const index = uiSubscribers.indexOf(fn); - - if (index > -1) { - uiSubscribers.splice(index, 1); - } - }; - }, onModelChange: (fn) => { if (typeof fn !== "function") { throw new Error("onModelChange expects a function as parameter"); @@ -296,7 +262,6 @@ export function main() { }); }, model: {}, - ui: {}, onReady: (fn) => { if (typeof fn !== "function") { throw new Error("onReady expects a function as parameter"); diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/customWidgetScript.test.ts b/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/customWidgetscript.test.ts similarity index 98% rename from app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/customWidgetScript.test.ts rename to app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/customWidgetscript.test.ts index 963e6478d036..1b4e6bb87d6f 100644 --- a/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/customWidgetScript.test.ts +++ b/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/customWidgetscript.test.ts @@ -165,10 +165,6 @@ describe("CustomWidgetScript", () => { model: { test: 1, }, - ui: { - width: 1, - height: 2, - }, mode: "test", theme: { color: "#fff", @@ -182,11 +178,6 @@ describe("CustomWidgetScript", () => { test: 1, }); - expect(window.appsmith.ui).toEqual({ - width: 1, - height: 2, - }); - expect(handler).toHaveBeenCalled(); }); diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/index.tsx b/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/index.tsx index ac763a2df101..9b0e7d4523be 100644 --- a/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/index.tsx +++ b/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/index.tsx @@ -1,42 +1,33 @@ -import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; -import styled from "styled-components"; -import kebabCase from "lodash/kebabCase"; - -// @ts-expect-error Cannot find module due to raw-loader -import script from "!!raw-loader!./customWidgetscript.js"; - -// @ts-expect-error Cannot find module due to raw-loader -import appsmithConsole from "!!raw-loader!./appsmithConsole.js"; - -// @ts-expect-error Cannot find module due to raw-loader -import css from "!!raw-loader!./reset.css"; import clsx from "clsx"; +import kebabCase from "lodash/kebabCase"; import AnalyticsUtil from "ee/utils/AnalyticsUtil"; +import { cssRule, ThemeContext } from "@appsmith/wds-theming"; +import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; + +import styles from "./styles.module.css"; import { EVENTS } from "./customWidgetscript"; import { getAppsmithConfigs } from "ee/configs"; -import styles from "./styles.module.css"; -import { cssRule, ThemeContext } from "@appsmith/wds-theming"; +import { getSandboxPermissions } from "../helpers"; +import { IframeMessenger } from "./IframeMessenger"; +import { createHtmlTemplate } from "./createHtmlTemplate"; +import type { CustomWidgetComponentProps } from "../types"; import { useCustomWidgetHeight } from "./useCustomWidgetHeight"; -import type { COMPONENT_SIZE } from "../constants"; - -const Container = styled.div` - height: 100%; - width: 100%; -`; const { disableIframeWidgetSandbox } = getAppsmithConfigs(); -export function CustomComponent(props: CustomComponentProps) { - const { size } = props; +export function CustomWidgetComponent(props: CustomWidgetComponentProps) { + const { model, onConsole, onTriggerEvent, onUpdateModel, renderMode, size } = + props; const iframe = useRef(null); const theme = useContext(ThemeContext); - const { search } = window.location; - const queryParams = new URLSearchParams(search); - const isEmbed = queryParams.get("embed") === "true"; - const componentHeight = useCustomWidgetHeight(size, isEmbed); - const [loading, setLoading] = React.useState(true); + const [loading, setLoading] = useState(true); const [isIframeReady, setIsIframeReady] = useState(false); + const messenger = useRef(null); + const componentHeight = useCustomWidgetHeight(size); + // We want to pass anvil theme's css variables to the iframe so that it looks like a anvil theme. To do, we are + // generating the css variables from the anvil theme and then sending it to the iframe. See the + // createHtmlTemplate.tsx file where we are using the cssTokens. const cssTokens = useMemo(() => { const tokens = cssRule(theme); const prefixedTokens = tokens.replace(/--/g, "--appsmith-theme-"); @@ -44,133 +35,140 @@ export function CustomComponent(props: CustomComponentProps) { return `:root {${prefixedTokens}}`; }, [theme]); - useEffect(() => { - const handler = (event: MessageEvent) => { - const iframeWindow = - iframe.current?.contentWindow || - iframe.current?.contentDocument?.defaultView; - - if (event.source === iframeWindow) { - // Sending acknowledgement for all messages since we're queueing all the postmessage from iframe - iframe.current?.contentWindow?.postMessage( - { - type: EVENTS.CUSTOM_WIDGET_MESSAGE_RECEIVED_ACK, - key: event.data.key, - success: true, - }, - "*", - ); - - const message = event.data; - - switch (message.type) { - case EVENTS.CUSTOM_WIDGET_READY: - setIsIframeReady(true); - iframe.current?.contentWindow?.postMessage( - { - type: EVENTS.CUSTOM_WIDGET_READY_ACK, - model: props.model, - ui: {}, - mode: props.renderMode, - theme, - }, - "*", - ); - - if ( - props.renderMode === "DEPLOYED" || - props.renderMode === "EDITOR" - ) { - AnalyticsUtil.logEvent("CUSTOM_WIDGET_LOAD_INIT", { - widgetId: props.widgetId, - renderMode: props.renderMode, - }); - } - - break; - case EVENTS.CUSTOM_WIDGET_UPDATE_MODEL: - props.update(message.data); - break; - case EVENTS.CUSTOM_WIDGET_TRIGGER_EVENT: - props.execute(message.data.eventName, message.data.contextObj); - break; - case EVENTS.CUSTOM_WIDGET_UPDATE_HEIGHT: - const height = message.data.height; - - if (props.renderMode !== "BUILDER" && height) { - iframe.current?.style.setProperty("height", `${height}px`); - } - - break; - case "CUSTOM_WIDGET_CONSOLE_EVENT": - props.onConsole && - props.onConsole(message.data.type, message.data.args); - break; - } - } - }; + useEffect( + // The iframe sends messages to the parent window (main Appsmith application) + // to communicate with it. Here we set up a listener for these messages + // and handle them appropriately. + function setupIframeMessageHandler() { + if (!iframe.current) return; - window.addEventListener("message", handler, false); + messenger.current = new IframeMessenger(iframe.current); - return () => window.removeEventListener("message", handler, false); - }, [props.model]); + const messageHandlers = { + [EVENTS.CUSTOM_WIDGET_READY]: handleIframeOnLoad, + [EVENTS.CUSTOM_WIDGET_UPDATE_MODEL]: handleModelUpdate, + [EVENTS.CUSTOM_WIDGET_TRIGGER_EVENT]: handleTriggerEvent, + [EVENTS.CUSTOM_WIDGET_UPDATE_HEIGHT]: handleHeightUpdate, + [EVENTS.CUSTOM_WIDGET_CONSOLE_EVENT]: handleConsoleEvent, + }; - useEffect(() => { - if (iframe.current && iframe.current.contentWindow && isIframeReady) { - iframe.current.contentWindow.postMessage( - { - type: EVENTS.CUSTOM_WIDGET_MODEL_CHANGE, - model: props.model, - }, - "*", - ); + const handler = (event: MessageEvent) => { + messenger.current?.handleMessage(event, messageHandlers); + }; + + window.addEventListener("message", handler, false); + + return () => window.removeEventListener("message", handler, false); + }, + [model], + ); + + // the iframe sends CUSTOM_WIDGET_READY message when "onload" event is triggered + // on the iframe's window object + const handleIframeOnLoad = () => { + setIsIframeReady(true); + + messenger.current?.postMessage({ + type: EVENTS.CUSTOM_WIDGET_READY_ACK, + model: props.model, + mode: props.renderMode, + }); + + logInitializationEvent(); + }; + + // the iframe can make changes to the model, when it needs to + // this is done by sending a CUSTOM_WIDGET_UPDATE_MODEL message to the parent window + const handleModelUpdate = (message: Record) => { + onUpdateModel(message.model as Record); + }; + + // the iframe elements can trigger events. Triggered events here would mean + // executing an appsmith action. When the iframe elements want to execute an action, + // it sends a CUSTOM_WIDGET_TRIGGER_EVENT message to the parent window. + const handleTriggerEvent = (message: Record) => { + onTriggerEvent( + message.eventName as string, + message.contextObj as Record, + ); + }; + + // iframe content can change its height based on its content. When this happens, + // we want to update the height of the iframe so that it is same as the iframe content's height. + // To do this, we listen to CUSTOM_WIDGET_UPDATE_HEIGHT messages from the iframe and update the height of the iframe + const handleHeightUpdate = (message: Record) => { + const height = message.height; + + if (props.renderMode !== "BUILDER" && height) { + iframe.current?.style.setProperty("height", `${height}px`); + } + }; + + // we intercept console function calls in the iframe and send them to the parent window + // so that they can be logged in the console of the main Appsmith application + const handleConsoleEvent = (eventData: Record) => { + if (!onConsole) return; + + onConsole(eventData.type as string, eventData.args as string); + }; + + const logInitializationEvent = () => { + if (renderMode === "DEPLOYED" || renderMode === "EDITOR") { + AnalyticsUtil.logEvent("CUSTOM_WIDGET_LOAD_INIT", { + widgetId: props.widgetId, + renderMode: props.renderMode, + }); } - }, [props.model]); + }; - useEffect(() => { - if (iframe.current && iframe.current.contentWindow && isIframeReady) { - iframe.current.contentWindow.postMessage( - { + useEffect( + // iframe can listen to changes to model with `appsmith.onModelChange` function. + // To do this, we send a CUSTOM_WIDGET_MODEL_CHANGE message to the iframe + // when the model changes. Iframe would be listening to these messages and + // when it receives one, it calls all the callbacks that were registered + // with `appsmith.onModelChange` function + function handleModelChange() { + if (iframe.current && iframe.current.contentWindow && isIframeReady) { + messenger.current?.postMessage({ + type: EVENTS.CUSTOM_WIDGET_MODEL_CHANGE, + model: model, + }); + } + }, + [model], + ); + + useEffect( + // similar to model change, iframe can listen to changes to theme with + // `appsmith.onThemeChange` function. + function handleThemeUpdate() { + if (iframe.current && iframe.current.contentWindow && isIframeReady) { + messenger.current?.postMessage({ type: EVENTS.CUSTOM_WIDGET_THEME_UPDATE, theme, - }, - "*", - ); - } - }, [theme, isIframeReady]); - - const srcDoc = ` - - - - - - - - - ${props.srcDoc.html} - - - - - `; - - useEffect(() => { - setLoading(true); - }, [srcDoc]); + }); + } + }, + [theme, isIframeReady], + ); + + const srcDoc = createHtmlTemplate({ + cssTokens, + onConsole: !!props.onConsole, + srcDoc: props.srcDoc, + }); + + useEffect( + // Everytime srcDoc changes, we want to set loading to true, so that all iframe events are reset + function handleIframeLoad() { + setLoading(true); + }, + [srcDoc], + ); return ( -