diff --git a/app/client/packages/design-system/theming/src/utils/cssRule.ts b/app/client/packages/design-system/theming/src/utils/cssRule.ts index 6724b6487c0d..7ff3a01a6f89 100644 --- a/app/client/packages/design-system/theming/src/utils/cssRule.ts +++ b/app/client/packages/design-system/theming/src/utils/cssRule.ts @@ -1,15 +1,30 @@ import kebabCase from "lodash/kebabCase"; import isObject from "lodash/isObject"; import type { Theme } from "../theme"; +import { objectKeys } from "@appsmith/utils"; +import type { TypographyVariantMetric } from "../token"; export const cssRule = (tokens: Theme) => { let styles = ""; - Object.values(tokens).forEach((token) => { + objectKeys(tokens).forEach((tokenKey) => { + const token = tokens[tokenKey]; + if (token == null) return; if (isObject(token)) { - Object.keys(token).forEach((key) => { + if (tokenKey === "typography") { + styles += objectKeys(token as NonNullable).reduce( + (prev: string, key) => { + return `${prev} --font-size-${key}: ${(token[key] as TypographyVariantMetric).fontSize};`; + }, + "", + ); + + return; + } + + objectKeys(token).forEach((key) => { //@ts-expect-error: type mismatch styles += `--${kebabCase(token[key].type)}-${kebabCase(key)}: ${ //@ts-expect-error: type mismatch diff --git a/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/appsmithConsole.js b/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/appsmithConsole.js new file mode 100644 index 000000000000..1c27b1b5eb03 --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/appsmithConsole.js @@ -0,0 +1,35 @@ +(function (nativeConsole) { + const postMessage = (method, args) => { + window.parent.postMessage( + { + type: "CUSTOM_WIDGET_CONSOLE_EVENT", + data: { + type: method, + args: args.map((d) => ({ + message: d, + })), + }, + }, + "*", + ); + }; + + const createProxy = (method) => + new Proxy(nativeConsole[method], { + apply(target, _this, args) { + try { + postMessage(method, args); + } finally { + return Reflect.apply(target, _this, args); + } + }, + }); + + ["log", "warn", "info"].forEach((method) => { + nativeConsole[method] = createProxy(method); + }); + + window.addEventListener("error", (event) => { + postMessage("error", [event.message]); + }); +})(window.console); 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 new file mode 100644 index 000000000000..16e5e6ee3a8b --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/constants.ts @@ -0,0 +1,18 @@ +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..ce37bb57a86b --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/createHtmlTemplate.ts @@ -0,0 +1,38 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +//@ts-ignore +import css from "!!raw-loader!./reset.css"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +//@ts-ignore +import script from "!!raw-loader!./customWidgetscript.js"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +//@ts-ignore +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 new file mode 100644 index 000000000000..93bddd00aabd --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/customWidgetscript.js @@ -0,0 +1,312 @@ +// Custom widget events definition +export const EVENTS = { + CUSTOM_WIDGET_READY: "CUSTOM_WIDGET_READY", + CUSTOM_WIDGET_READY_ACK: "CUSTOM_WIDGET_READY_ACK", + 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_MESSAGE_RECEIVED_ACK: "CUSTOM_WIDGET_MESSAGE_RECEIVED_ACK", + CUSTOM_WIDGET_CONSOLE_EVENT: "CUSTOM_WIDGET_CONSOLE_EVENT", + CUSTOM_WIDGET_THEME_UPDATE: "CUSTOM_WIDGET_THEME_UPDATE", + CUSTOM_WIDGET_UPDATE_HEIGHT: "CUSTOM_WIDGET_UPDATE_HEIGHT", +}; + +// Function to create a communication channel to the parent +export const createChannelToParent = () => { + const onMessageMap = new Map(); + + // Function to register an event handler for a message type + function onMessage(type, fn) { + let eventHandlerList = onMessageMap.get(type); + + if (eventHandlerList && eventHandlerList instanceof Array) { + eventHandlerList.push(fn); + } else { + eventHandlerList = [fn]; + onMessageMap.set(type, eventHandlerList); + } + + return () => { + // Function to unsubscribe an event handler + const index = eventHandlerList.indexOf(fn); + + eventHandlerList.splice(index, 1); + }; + } + + // Listen for 'message' events and dispatch to registered event handlers + window.addEventListener("message", (event) => { + if (event.source === window.parent) { + const handlerList = onMessageMap.get(event.data.type); + + if (handlerList) { + handlerList.forEach((fn) => fn(event.data)); + } + } + }); + // Queue to hold postMessage requests + const postMessageQueue = []; + // Flag to indicate if the flush is scheduled + let isFlushScheduled = false; + + /* + * Function to schedule microtask to flush postMessageQueue + * to ensure the order of message processed on the parent + */ + const scheduleMicrotaskToflushPostMessageQueue = () => { + if (!isFlushScheduled) { + isFlushScheduled = true; + queueMicrotask(() => { + (async () => { + while (postMessageQueue.length > 0) { + const message = postMessageQueue.shift(); + + await new Promise((resolve) => { + const key = Math.random(); + const unlisten = onMessage( + EVENTS.CUSTOM_WIDGET_MESSAGE_RECEIVED_ACK, + (data) => { + if (data.key === key && data.success) { + unlisten(); + resolve(); + } + }, + ); + + // Send the message to the parent + window.parent.postMessage( + Object.assign(Object.assign({}, message), { key }), + "*", + ); + }); + } + + isFlushScheduled = false; + })(); + }); + } + }; + + return { + onMessageMap, + postMessage: (type, data) => { + try { + // Try block to catch non clonealbe data error while postmessaging + // throw error if the data is not cloneable + postMessageQueue.push({ + type, + data, + }); + + scheduleMicrotaskToflushPostMessageQueue(); + } catch (e) { + throw e; + } + }, + onMessage, + }; +}; + +/* + * Function to initialize the script + * wrapping this inside a function to make it testable + */ +export function main() { + // Create a communication channel to the parent + const channel = createChannelToParent(); + /* + * Variables to hold the subscriber functions + */ + const modelSubscribers = []; + const themeSubscribers = []; + /* + * Variables to hold ready function and state + */ + let onReady; + let isReady = false; + let isReadyCalled = false; + + const heightObserver = new ResizeObserver(() => { + const height = document.body.clientHeight; + + channel.postMessage(EVENTS.CUSTOM_WIDGET_UPDATE_HEIGHT, { + height, + }); + }); + + // Callback for when the READY_ACK message is received + channel.onMessage(EVENTS.CUSTOM_WIDGET_READY_ACK, (event) => { + window.appsmith.model = event.model; + 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")); + + // Set the widget as ready + isReady = true; + + if (!isReadyCalled && onReady) { + onReady(); + isReadyCalled = true; + } + }); + // Callback for when MODEL_CHANGE message is received + channel.onMessage(EVENTS.CUSTOM_WIDGET_MODEL_CHANGE, (event) => { + if (event.model) { + const prevModel = window.appsmith.model; + + window.appsmith.model = event.model; + + // Notify model subscribers + modelSubscribers.forEach((fn) => { + fn(event.model, prevModel); + }); + } + }); + + channel.onMessage(EVENTS.CUSTOM_WIDGET_THEME_UPDATE, (event) => { + if (event.theme) { + const prevTheme = window.appsmith.theme; + + window.appsmith.theme = event.theme; + // Notify theme subscribers + themeSubscribers.forEach((fn) => { + fn(event.theme, prevTheme); + }); + } + + if (event.cssTokens) { + const el = document.querySelector("[data-appsmith-theme]"); + + if (el) { + el.innerHTML = event.cssTokens; + } else { + // Use appendChild instead of innerHTML to add the style element + const styleElement = document.createElement("style"); + + styleElement.setAttribute("data-appsmith-theme", ""); + styleElement.innerHTML = event.cssTokens; + document.head.appendChild(styleElement); + } + } + }); + + if (!window.appsmith) { + // Define appsmith global object + Object.defineProperty(window, "appsmith", { + configurable: false, + writable: false, + value: { + mode: "", + theme: {}, + onThemeChange: (fn) => { + if (typeof fn !== "function") { + throw new Error("onThemeChange expects a function as parameter"); + } + + themeSubscribers.push(fn); + fn(window.appsmith.theme); + + return () => { + // Unsubscribe from theme changes + const index = themeSubscribers.indexOf(fn); + + if (index > -1) { + themeSubscribers.splice(index, 1); + } + }; + }, + onModelChange: (fn) => { + if (typeof fn !== "function") { + throw new Error("onModelChange expects a function as parameter"); + } + + modelSubscribers.push(fn); + fn(window.appsmith.model); + + return () => { + // Unsubscribe from model changes + const index = modelSubscribers.indexOf(fn); + + if (index > -1) { + modelSubscribers.splice(index, 1); + } + }; + }, + updateModel: (obj) => { + if (!obj || typeof obj !== "object") { + throw new Error("updateModel expects an object as parameter"); + } + + appsmith.model = Object.assign( + Object.assign({}, appsmith.model), + obj, + ); + + // Send an update model message to the parent + channel.postMessage(EVENTS.CUSTOM_WIDGET_UPDATE_MODEL, obj); + }, + triggerEvent: (eventName, contextObj) => { + if (typeof eventName !== "string") { + throw new Error("eventName should be a string"); + } else if (contextObj && typeof contextObj !== "object") { + throw new Error("contextObj should be an object"); + } + + // Send a trigger event message to the parent + channel.postMessage(EVENTS.CUSTOM_WIDGET_TRIGGER_EVENT, { + eventName, + contextObj, + }); + }, + model: {}, + onReady: (fn) => { + if (typeof fn !== "function") { + throw new Error("onReady expects a function as parameter"); + } + + onReady = fn; + + if (isReady && !isReadyCalled && onReady) { + onReady(); + isReadyCalled = true; + } + }, + }, + }); + } + + // Listen for the 'load' event and send READY message to the parent + window.addEventListener("load", () => { + channel.postMessage(EVENTS.CUSTOM_WIDGET_READY); + }); +} + +/* + * Function to create appsmith css variables for model and ui primitive values + * variables get regenerated every time the model or ui changes. + */ +export const generateAppsmithCssVariables = (provider) => (source) => { + let cssTokens = document.getElementById(`appsmith-${provider}-css-tokens`); + + if (!cssTokens) { + cssTokens = document.createElement("style"); + cssTokens.id = `appsmith-${provider}-css-tokens`; + window.document.head.appendChild(cssTokens); + } + + const cssTokensContent = Object.keys(source).reduce((acc, key) => { + if (typeof source[key] === "string" || typeof source[key] === "number") { + return ` + ${acc} + --appsmith-${provider}-${key}: ${source[key]}; + `; + } else { + return acc; + } + }, ""); + + cssTokens.innerHTML = `:root {${cssTokensContent}}`; +}; 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 new file mode 100644 index 000000000000..8415a1d74861 --- /dev/null +++ b/app/client/src/modules/ui-builder/ui/wds/WDSCustomWidget/component/index.tsx @@ -0,0 +1,187 @@ +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 { getSandboxPermissions } from "../helpers"; +import { createHtmlTemplate } from "./createHtmlTemplate"; +import type { CustomWidgetComponentProps } from "../types"; +import { IframeMessenger } from "../services/IframeMessenger"; +import { useCustomWidgetHeight } from "./useCustomWidgetHeight"; + +const { disableIframeWidgetSandbox } = getAppsmithConfigs(); + +export function CustomWidgetComponent(props: CustomWidgetComponentProps) { + const { model, onConsole, onTriggerEvent, onUpdateModel, renderMode, size } = + props; + const iframe = useRef(null); + const theme = useContext(ThemeContext); + const [loading, setLoading] = useState(true); + const [isIframeReady, setIsIframeReady] = useState(false); + const messenger = useRef(null); + const componentHeight = useCustomWidgetHeight(size); + + // We want to anvil theme css variables in 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-"); + + return `:root {${prefixedTokens}}`; + }, [theme]); + + 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; + + messenger.current = new IframeMessenger(iframe.current); + + 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, + }; + + 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, + }); + } + }; + + 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 = 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 ( +
+