diff --git a/frontend/src/AppBuilder/AppCanvas/AppCanvas.jsx b/frontend/src/AppBuilder/AppCanvas/AppCanvas.jsx index 229fb44175..abff6a9d7e 100644 --- a/frontend/src/AppBuilder/AppCanvas/AppCanvas.jsx +++ b/frontend/src/AppBuilder/AppCanvas/AppCanvas.jsx @@ -19,12 +19,12 @@ import useSidebarMargin from './Hooks/useSidebarMargin'; import useAppPageSidebarHeight from './Hooks/useAppPageSidebarHeight'; import { Container } from './Container'; import { SuspenseCountProvider } from './SuspenseTracker'; +import { MobileLayout } from './MobileLayout'; +import { DesktopLayout } from './DesktopLayout'; // Lazy load editor-only component to reduce viewer bundle size const AppCanvasBanner = lazy(() => import('@/AppBuilder/Header/AppCanvasBanner')); const EditorSelecto = React.lazy(() => import('./Selecto')); const Grid = React.lazy(() => import('./Grid')); -const MobileLayout = lazy(() => import('./MobileLayout').then((m) => ({ default: m.MobileLayout }))); -const DesktopLayout = lazy(() => import('./DesktopLayout').then((m) => ({ default: m.DesktopLayout }))); import useCanvasMinWidth from './Hooks/useCanvasMinWidth'; import useEnableMainCanvasScroll from './Hooks/useEnableMainCanvasScroll'; import useCanvasResizing from './Hooks/useCanvasResizing'; @@ -264,50 +264,48 @@ export const AppCanvas = ({ appId, switchDarkMode, darkMode }) => { onAllResolved={handleAllSuspenseResolved} deferCheck={isModuleMode || appType === 'module'} > - - {isMobileLayout ? ( - - ) : ( - - )} - + {isMobileLayout ? ( + + ) : ( + + )} )} diff --git a/frontend/src/AppBuilder/AppCanvas/DesktopLayout.jsx b/frontend/src/AppBuilder/AppCanvas/DesktopLayout.jsx index 427c0b8046..ed0cd38b04 100644 --- a/frontend/src/AppBuilder/AppCanvas/DesktopLayout.jsx +++ b/frontend/src/AppBuilder/AppCanvas/DesktopLayout.jsx @@ -1,12 +1,13 @@ -import React from 'react'; +import React, { Suspense, lazy } from 'react'; import cx from 'classnames'; import { PAGE_CANVAS_HEADER_HEIGHT } from './appCanvasConstants'; -import PageCanvasHeader from './PageCanvasHeader'; -import PageCanvasFooter from './PageCanvasFooter'; import PagesSidebarNavigation from './PageMenu/PagesSidebarNavigation'; import { CanvasContentTail } from './CanvasContentTail'; +const PageCanvasHeader = lazy(() => import('./PageCanvasHeader')); +const PageCanvasFooter = lazy(() => import('./PageCanvasFooter')); + export const DesktopLayout = ({ pageKey, isModuleMode, @@ -35,7 +36,9 @@ export const DesktopLayout = ({ className={cx({ 'h-100': isModuleMode })} style={{ position: 'relative', display: 'flex', flexDirection: 'column' }} > - + + +
- + + + ); diff --git a/frontend/src/AppBuilder/AppCanvas/MobileLayout.jsx b/frontend/src/AppBuilder/AppCanvas/MobileLayout.jsx index dd7d9d1896..62468e6bb4 100644 --- a/frontend/src/AppBuilder/AppCanvas/MobileLayout.jsx +++ b/frontend/src/AppBuilder/AppCanvas/MobileLayout.jsx @@ -1,12 +1,13 @@ -import React, { useRef } from 'react'; +import React, { Suspense, useRef, lazy } from 'react'; import cx from 'classnames'; import { PAGE_CANVAS_HEADER_HEIGHT } from './appCanvasConstants'; -import PageCanvasHeader from './PageCanvasHeader'; -import PageCanvasFooter from './PageCanvasFooter'; import MobileNavigationHeader from './PageMenu/MobileNavigationHeader'; import { CanvasContentTail } from './CanvasContentTail'; +const PageCanvasHeader = lazy(() => import('./PageCanvasHeader')); +const PageCanvasFooter = lazy(() => import('./PageCanvasFooter')); + export const MobileLayout = ({ pageKey, // mobileCanvasFrameRef, @@ -41,7 +42,13 @@ export const MobileLayout = ({ className={cx('tw-absolute tw-inset-0 tw-overflow-hidden tw-pointer-events-none')} /> {/* Canvas header — sticky at top of scroll */} - + + + {/* Mobile nav — sticky below header */} {appType !== 'module' && (
{mainCanvasContainer} - + + +
); }; diff --git a/frontend/src/AppBuilder/LeftSidebar/AppLibraries/AppLibrariesIcon.jsx b/frontend/src/AppBuilder/LeftSidebar/AppLibraries/AppLibrariesIcon.jsx new file mode 100644 index 0000000000..5358db387a --- /dev/null +++ b/frontend/src/AppBuilder/LeftSidebar/AppLibraries/AppLibrariesIcon.jsx @@ -0,0 +1,7 @@ +import { withEditionSpecificComponent } from '@/modules/common/helpers/withEditionSpecificComponent'; + +const AppLibrariesIcon = () => { + return null; +}; + +export default withEditionSpecificComponent(AppLibrariesIcon, 'AppLibraries'); diff --git a/frontend/src/AppBuilder/LeftSidebar/AppLibraries/index.jsx b/frontend/src/AppBuilder/LeftSidebar/AppLibraries/index.jsx new file mode 100644 index 0000000000..72e40889a0 --- /dev/null +++ b/frontend/src/AppBuilder/LeftSidebar/AppLibraries/index.jsx @@ -0,0 +1,7 @@ +import { withEditionSpecificComponent } from '@/modules/common/helpers/withEditionSpecificComponent'; + +const AppLibraries = () => { + return null; +}; + +export default withEditionSpecificComponent(AppLibraries, 'AppLibraries'); diff --git a/frontend/src/AppBuilder/LeftSidebar/GlobalSettings/styles.scss b/frontend/src/AppBuilder/LeftSidebar/GlobalSettings/styles.scss index f3b6d8b12c..1d8316bd98 100644 --- a/frontend/src/AppBuilder/LeftSidebar/GlobalSettings/styles.scss +++ b/frontend/src/AppBuilder/LeftSidebar/GlobalSettings/styles.scss @@ -267,4 +267,5 @@ width: 158px; } } + } diff --git a/frontend/src/AppBuilder/LeftSidebar/LeftSidebar.jsx b/frontend/src/AppBuilder/LeftSidebar/LeftSidebar.jsx index 7da3f8375b..0b70a5f5ef 100644 --- a/frontend/src/AppBuilder/LeftSidebar/LeftSidebar.jsx +++ b/frontend/src/AppBuilder/LeftSidebar/LeftSidebar.jsx @@ -22,6 +22,8 @@ import { useOthers, useSelf } from '@y-presence/react'; import { useAppDataActions, useAppInfo } from '@/_stores/appDataStore'; import AppHistoryIcon from './AppHistory/AppHistoryIcon'; import AppHistory from './AppHistory'; +import AppLibrariesIcon from './AppLibraries/AppLibrariesIcon'; +import AppLibraries from './AppLibraries'; import { APP_HEADER_HEIGHT, QUERY_PANE_HEIGHT } from '../AppCanvas/appCanvasConstants'; // TODO: remove passing refs to LeftSidebarItem and use state @@ -115,6 +117,8 @@ export const BaseLeftSidebar = ({ return renderAIChat({ darkMode }); case 'apphistory': return ; + case 'libraries': + return toggleLeftSidebar(false)} />; case 'debugger': return toggleLeftSidebar(false)} darkMode={darkMode} />; case 'settings': @@ -189,6 +193,14 @@ export const BaseLeftSidebar = ({ setSideBarBtnRefs={setSideBarBtnRefs} /> )} + {featureAccess?.appJsLibraries && ( + + )} { // if tooljetai is open don't close - if (['tooljetai', 'inspect', 'debugger', 'settings'].includes(selectedSidebarItem)) return; + if (['tooljetai', 'inspect', 'debugger', 'settings', 'libraries'].includes(selectedSidebarItem)) return; const isWithinSidebar = e.target.closest('.left-sidebar'); const isClickOnInspect = e.target.closest('.config-handle-inspect'); if (pinned || isWithinSidebar || isClickOnInspect) return; diff --git a/frontend/src/AppBuilder/_helpers/libraryLoader.js b/frontend/src/AppBuilder/_helpers/libraryLoader.js new file mode 100644 index 0000000000..36f647d67c --- /dev/null +++ b/frontend/src/AppBuilder/_helpers/libraryLoader.js @@ -0,0 +1,122 @@ +import toast from 'react-hot-toast'; +import moment from 'moment'; +import _ from 'lodash'; +import axios from 'axios'; + +/** + * Executes UMD/IIFE source code and captures the exported module. + * Creates a sandboxed environment with fake module/exports/define to capture + * the library export without polluting the global scope. + */ +function executeUMD(source) { + const module = { exports: {} }; + const exports = module.exports; + let amdResult = null; + + const define = Object.assign( + (...args) => { + // Handle: define(factory), define(deps, factory), define(id, deps, factory) + const factory = args.find((a) => typeof a === 'function'); + if (factory) { + amdResult = factory(); + return; + } + // Handle: define(value) — plain object/string export + const value = args[args.length - 1]; + if (typeof value !== 'string') { + amdResult = value; + } + }, + { amd: true } + ); + + const fn = new Function('module', 'exports', 'define', 'self', '"use strict";\n' + source); + fn(module, exports, define, {}); + + // Priority: AMD result > reassigned module.exports > properties added to exports + if (amdResult != null) return amdResult; + if (module.exports !== exports) return module.exports; // was reassigned (e.g. module.exports = factory()) + if (Object.keys(exports).length > 0) return exports; // properties were added to original object + return null; +} + +/** + * Fetches a JS library from a URL and loads it using the UMD shim. + * Returns the exported module object. + */ +export async function loadLibraryFromURL(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch library from ${url}: ${response.status} ${response.statusText}`); + } + const source = await response.text(); + return executeUMD(source); +} + +/** + * Initializes all JS libraries from the globalSettings.libraries.javascript array. + * Returns a registry object mapping library names to their exports. + */ +export async function initializeLibraries(jsLibraries = []) { + const registry = {}; + const enabledLibraries = jsLibraries.filter((lib) => lib.enabled); + + // Load all libraries in parallel for faster startup + const results = await Promise.allSettled( + enabledLibraries.map(async (lib) => { + const module = await loadLibraryFromURL(lib.url); + return { name: lib.name, module }; + }) + ); + + results.forEach((result, index) => { + const lib = enabledLibraries[index]; + if (result.status === 'fulfilled') { + if (result.value.module != null) { + registry[result.value.name] = result.value.module; + } else { + console.warn(`Library "${lib.name}" loaded but exported nothing. Ensure it's a UMD/IIFE build.`); + } + } else { + console.error(`Failed to load library "${lib.name}" from ${lib.url}:`, result.reason); + toast.error(`Failed to load JS library "${lib.name}"`); + } + }); + + return registry; +} + +/** + * Executes preloaded JavaScript and captures exported functions/variables. + * The code must return an object — each property becomes a top-level variable + * available in RunJS queries, transformations, and {{}} expressions. + * + * Libraries (both built-in and user-added) are available in scope. + * No access to components, queries, globals, etc. + * + * Example user code: + * function formatCurrency(amount) { return '$' + amount.toFixed(2); } + * const TAX_RATE = 0.08; + * return { formatCurrency, TAX_RATE }; + */ +export async function executePreloadedJS(code, libraryRegistry = {}) { + if (!code?.trim()) return {}; + + try { + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + const fnParams = ['moment', '_', 'axios', ...Object.keys(libraryRegistry)]; + const fnArgs = [moment, _, axios, ...Object.values(libraryRegistry)]; + const fn = new AsyncFunction(...fnParams, code); + const result = await fn(...fnArgs); + + if (result && typeof result === 'object' && !Array.isArray(result)) { + return result; + } + + return {}; + } catch (error) { + console.error('Preloaded JS execution failed:', error); + toast.error('Preloaded JavaScript failed: ' + error.message); + return {}; + } +} diff --git a/frontend/src/AppBuilder/_hooks/useAppData.js b/frontend/src/AppBuilder/_hooks/useAppData.js index b8cbf2b857..bcb6a769fa 100644 --- a/frontend/src/AppBuilder/_hooks/useAppData.js +++ b/frontend/src/AppBuilder/_hooks/useAppData.js @@ -27,6 +27,8 @@ import { useLocation, useParams } from 'react-router-dom'; import { useMounted } from '@/_hooks/use-mount'; import useThemeAccess from './useThemeAccess'; import toast from 'react-hot-toast'; +import { initializeLibraries, executePreloadedJS } from '@/AppBuilder/_helpers/libraryLoader'; + /** * this is to normalize the query transformation options to match the expected schema. Takes care of corrupted data. * This will get redundanted once api response for appdata is made uniform across all the endpoints. @@ -113,6 +115,8 @@ const useAppData = ( const setPageSwitchInProgress = useStore((state) => state.setPageSwitchInProgress); const selectedVersion = useStore((state) => state.selectedVersion); const setIsPublicAccess = useStore((state) => state.setIsPublicAccess); + const setJsLibraryRegistry = useStore((state) => state.setJsLibraryRegistry); + const setJsLibraryLoading = useStore((state) => state.setJsLibraryLoading); const setModulesIsLoading = useStore((state) => state?.setModulesIsLoading ?? noop); const setModulesList = useStore((state) => state?.setModulesList ?? noop); @@ -596,10 +600,38 @@ const useAppData = ( useEffect(() => { if (isComponentLayoutReady) { mode === 'edit' && initSuggestions(moduleId); - runOnLoadQueries(moduleId).then(() => { + + const loadLibrariesAndRun = async () => { + // Load JS libraries and preloaded JS from globalSettings before running queries + const globalSettings = useStore.getState().globalSettings; + const jsLibraries = globalSettings?.libraries?.javascript || []; + const preloadedJS = globalSettings?.preloadedScript?.javascript || ''; + + const hasJSLibrariesAccess = useStore.getState().license?.featureAccess?.appJsLibraries; + + if (hasJSLibrariesAccess && (jsLibraries.length > 0 || preloadedJS)) { + setJsLibraryLoading(true); + try { + const registry = jsLibraries.length > 0 ? await initializeLibraries(jsLibraries) : {}; + + // Execute preloaded JS — its returned exports merge into the registry + const preloadedExports = await executePreloadedJS(preloadedJS, registry); + const fullRegistry = { ...registry, ...preloadedExports }; + + setJsLibraryRegistry(fullRegistry); + } catch (error) { + console.error('Failed to initialize JS libraries:', error); + } finally { + setJsLibraryLoading(false); + } + } + + await runOnLoadQueries(moduleId); const currentPageEvents = events.filter((event) => event.target === 'page' && event.sourceId === currentPageId); handleEvent('onPageLoad', currentPageEvents, {}); - }); + }; + + loadLibrariesAndRun(); } }, [isComponentLayoutReady, moduleId, mode]); diff --git a/frontend/src/AppBuilder/_stores/slices/librarySlice.js b/frontend/src/AppBuilder/_stores/slices/librarySlice.js new file mode 100644 index 0000000000..46a5dd87f0 --- /dev/null +++ b/frontend/src/AppBuilder/_stores/slices/librarySlice.js @@ -0,0 +1,13 @@ +export const createLibrarySlice = (set, get) => ({ + jsLibraryRegistry: {}, + jsLibraryLoading: false, + jsLibraryError: null, + + setJsLibraryRegistry: (registry) => set(() => ({ jsLibraryRegistry: registry }), false, 'setJsLibraryRegistry'), + + setJsLibraryLoading: (loading) => set(() => ({ jsLibraryLoading: loading }), false, 'setJsLibraryLoading'), + + setJsLibraryError: (error) => set(() => ({ jsLibraryError: error }), false, 'setJsLibraryError'), + + getJsLibraryRegistry: () => get().jsLibraryRegistry, +}); diff --git a/frontend/src/AppBuilder/_stores/slices/queryPanelSlice.js b/frontend/src/AppBuilder/_stores/slices/queryPanelSlice.js index 3e83c3ca63..fc1ece5a73 100644 --- a/frontend/src/AppBuilder/_stores/slices/queryPanelSlice.js +++ b/frontend/src/AppBuilder/_stores/slices/queryPanelSlice.js @@ -748,6 +748,12 @@ export const createQueryPanelSlice = (set, get) => ({ const { error } = e; const errorMessage = typeof error === 'string' ? error : error?.message || 'Unknown error'; if (mode !== 'view') toast.error(errorMessage); + const result = handleFailure({ + status: 'failed', + message: errorMessage, + data: e?.data || {}, + description: errorMessage, + }); resolve({ status: 'failed', message: errorMessage }); }); }); @@ -1220,6 +1226,9 @@ export const createQueryPanelSlice = (set, get) => ({ const proxiedPage = deepClone(currentState?.page); const proxiedQueriesInResolvedState = queriesInResolvedState; + const hasJsLibrariesAccess = get().license?.featureAccess?.appJsLibraries; + const libraryRegistry = hasJsLibrariesAccess ? get().jsLibraryRegistry || {} : {}; + const evalFunction = Function( [ 'data', @@ -1233,6 +1242,7 @@ export const createQueryPanelSlice = (set, get) => ({ 'constants', ...(appType === 'module' ? ['input'] : []), 'actions', + ...Object.keys(libraryRegistry), ], transformation ); @@ -1258,7 +1268,8 @@ export const createQueryPanelSlice = (set, get) => ({ log: function (log) { return actions.log.call(actions, log, true); }, - } + }, + ...Object.values(libraryRegistry) ); } catch (err) { const stackLines = err.stack.split('\n'); @@ -1536,6 +1547,8 @@ export const createQueryPanelSlice = (set, get) => ({ try { const AsyncFunction = new Function(`return Object.getPrototypeOf(async function(){}).constructor`)(); + const hasJsLibrariesAccess = get().license?.featureAccess?.appJsLibraries; + const libraryRegistry = hasJsLibrariesAccess ? get().jsLibraryRegistry || {} : {}; const fnParams = [ 'moment', '_', @@ -1549,6 +1562,7 @@ export const createQueryPanelSlice = (set, get) => ({ 'constants', ...(!_.isEmpty(formattedParams) ? ['parameters'] : []), // Parameters are supported if builder has added atleast one parameter to the query ...(appType === 'module' ? ['input'] : []), // Include 'input' only for module, + ...Object.keys(libraryRegistry), code, ]; var evalFn = new AsyncFunction(...fnParams); @@ -1566,6 +1580,7 @@ export const createQueryPanelSlice = (set, get) => ({ resolvedState?.constants, ...(!_.isEmpty(formattedParams) ? [formattedParams] : []), // Parameters are supported if builder has added atleast one parameter to the query ...(appType === 'module' ? [resolvedState.input] : []), // Include 'input' only for module + ...Object.values(libraryRegistry), ]; result = { status: 'ok', diff --git a/frontend/src/AppBuilder/_stores/store.js b/frontend/src/AppBuilder/_stores/store.js index e4b34dd3b4..e4b77f46c6 100644 --- a/frontend/src/AppBuilder/_stores/store.js +++ b/frontend/src/AppBuilder/_stores/store.js @@ -33,6 +33,7 @@ import { createWhiteLabellingSlice } from './slices/whiteLabellingSlice'; import { createFormComponentSlice } from './slices/componentSlices/formComponentSlice'; import { createInspectorSlice } from './slices/inspectorSlice'; import { createModuleSlice } from './slices/moduleSlice'; +import { createLibrarySlice } from './slices/librarySlice'; import { createDataQueryFolderSlice } from './slices/dataQueryFolderSlice'; import { listViewComponentSlice } from './slices/componentSlices/listViewComponentSlice'; import { createBranchSlice } from './slices/branchSlice'; @@ -71,6 +72,7 @@ export default create( ...createWhiteLabellingSlice(...state), ...createInspectorSlice(...state), ...createModuleSlice(...state), + ...createLibrarySlice(...state), ...createDataQueryFolderSlice(...state), // component slices ...createFormComponentSlice(...state), diff --git a/plugins/packages/oracledb/lib/manifest.json b/plugins/packages/oracledb/lib/manifest.json index bd30e3e203..c95365c5a3 100644 --- a/plugins/packages/oracledb/lib/manifest.json +++ b/plugins/packages/oracledb/lib/manifest.json @@ -79,7 +79,7 @@ "default": "no", "list": [ { - "name": "Basic Connection (No TNS/Wallet)", + "name": "Basic Connection", "value": "no" }, { diff --git a/server/src/modules/licensing/configs/LicenseBase.ts b/server/src/modules/licensing/configs/LicenseBase.ts index a9dba47d58..cdbcacf7a9 100644 --- a/server/src/modules/licensing/configs/LicenseBase.ts +++ b/server/src/modules/licensing/configs/LicenseBase.ts @@ -597,6 +597,7 @@ export default class LicenseBase { scim: this.scim, observabilityEnabled: this.observabilityEnabled, appHistory: this.appHistory, + appJsLibraries: this.appJsLibraries, queryFolders: this.queryFolders, workspaceEnv: this.workspaceEnv, aiPlan: this.aiPlan, @@ -639,6 +640,7 @@ export default class LicenseBase { workflows: this.workflows, startDate: this.startDate, appHistoryEnabled: this.appHistory, + appJsLibrariesEnabled: this.appJsLibraries, }; } @@ -711,4 +713,15 @@ export default class LicenseBase { } return this._isEnvMapping; } + + public get appJsLibraries(): boolean { + if (this.IsBasicPlan) { + return !!this.BASIC_PLAN_TERMS.app?.features?.jsLibraries; + } + + if (this._app?.features?.jsLibraries === undefined) { + return false; + } + return !!this._app?.features?.jsLibraries; + } } diff --git a/server/src/modules/licensing/constants/PlanTerms.ts b/server/src/modules/licensing/constants/PlanTerms.ts index c1ad06fd0f..c46751c430 100644 --- a/server/src/modules/licensing/constants/PlanTerms.ts +++ b/server/src/modules/licensing/constants/PlanTerms.ts @@ -67,6 +67,7 @@ export const BASIC_PLAN_TERMS: Partial = { promote: false, release: false, history: false, + jsLibraries: false, }, components: { navigation: false, diff --git a/server/src/modules/licensing/constants/index.ts b/server/src/modules/licensing/constants/index.ts index eb7f6f5ab8..82da51ad78 100644 --- a/server/src/modules/licensing/constants/index.ts +++ b/server/src/modules/licensing/constants/index.ts @@ -125,6 +125,7 @@ export enum LICENSE_FIELD { AI_PLAN = 'aiPlan', EXTERNAL_API = 'externalApiEnabled', APP_HISTORY = 'appHistoryEnabled', + APP_JS_LIBRARIES = 'appJsLibrariesEnabled', SCIM = 'scimEnabled', PLAN = 'plan', MODULES = 'modulesEnabled', diff --git a/server/src/modules/licensing/helper.ts b/server/src/modules/licensing/helper.ts index d843ec06c3..f727b477d8 100644 --- a/server/src/modules/licensing/helper.ts +++ b/server/src/modules/licensing/helper.ts @@ -172,6 +172,9 @@ export function getLicenseFieldValue(type: LICENSE_FIELD, licenseInstance: Licen case LICENSE_FIELD.QUERY_FOLDERS: return licenseInstance.queryFolders; + case LICENSE_FIELD.APP_JS_LIBRARIES: + return licenseInstance.appJsLibraries; + default: return licenseInstance.terms; } diff --git a/server/src/modules/licensing/interfaces/terms.ts b/server/src/modules/licensing/interfaces/terms.ts index bbde04201f..dcc9897d27 100644 --- a/server/src/modules/licensing/interfaces/terms.ts +++ b/server/src/modules/licensing/interfaces/terms.ts @@ -67,6 +67,7 @@ export interface Terms { promote: boolean; release: boolean; history: boolean; + jsLibraries: boolean; }; components?: { navigation?: boolean;