Merge branch 'lts-3.16' into rebase/lts-main-15-mgs

This commit is contained in:
gsmithun4 2026-04-17 15:05:40 +05:30
commit 102c4e7cab
18 changed files with 307 additions and 61 deletions

View file

@ -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'}
>
<Suspense fallback={null}>
{isMobileLayout ? (
<MobileLayout
pageKey={pageKey}
showCanvasHeader={showCanvasHeader}
showCanvasFooter={showCanvasFooter}
isMobileLayout={isMobileLayout}
currentMode={currentMode}
appType={appType}
currentPageId={currentPageId}
homePageId={homePageId}
switchDarkMode={switchDarkMode}
darkMode={darkMode}
canvasMaxWidth={canvasMaxWidth}
isAppDarkMode={isAppDarkMode}
mainCanvasContainer={mainCanvasContainer}
canvasHeaderHeight={canvasHeaderHeight}
/>
) : (
<DesktopLayout
pageKey={pageKey}
isModuleMode={isModuleMode}
isMobileLayout={isMobileLayout}
showCanvasHeader={showCanvasHeader}
showCanvasFooter={showCanvasFooter}
position={position}
isPagesSidebarHidden={isPagesSidebarHidden}
appType={appType}
sideBarVisibleHeight={sideBarVisibleHeight}
currentPageId={currentPageId}
homePageId={homePageId}
switchDarkMode={switchDarkMode}
isViewerSidebarPinned={isViewerSidebarPinned}
setIsSidebarPinned={setIsSidebarPinned}
darkMode={darkMode}
canvasMaxWidth={canvasMaxWidth}
canvasContentRef={canvasContentRef}
currentMode={currentMode}
isAppDarkMode={isAppDarkMode}
mainCanvasContainer={mainCanvasContainer}
canvasHeaderHeight={canvasHeaderHeight}
/>
)}
</Suspense>
{isMobileLayout ? (
<MobileLayout
pageKey={pageKey}
showCanvasHeader={showCanvasHeader}
showCanvasFooter={showCanvasFooter}
isMobileLayout={isMobileLayout}
currentMode={currentMode}
appType={appType}
currentPageId={currentPageId}
homePageId={homePageId}
switchDarkMode={switchDarkMode}
darkMode={darkMode}
canvasMaxWidth={canvasMaxWidth}
isAppDarkMode={isAppDarkMode}
mainCanvasContainer={mainCanvasContainer}
canvasHeaderHeight={canvasHeaderHeight}
/>
) : (
<DesktopLayout
pageKey={pageKey}
isModuleMode={isModuleMode}
isMobileLayout={isMobileLayout}
showCanvasHeader={showCanvasHeader}
showCanvasFooter={showCanvasFooter}
position={position}
isPagesSidebarHidden={isPagesSidebarHidden}
appType={appType}
sideBarVisibleHeight={sideBarVisibleHeight}
currentPageId={currentPageId}
homePageId={homePageId}
switchDarkMode={switchDarkMode}
isViewerSidebarPinned={isViewerSidebarPinned}
setIsSidebarPinned={setIsSidebarPinned}
darkMode={darkMode}
canvasMaxWidth={canvasMaxWidth}
canvasContentRef={canvasContentRef}
currentMode={currentMode}
isAppDarkMode={isAppDarkMode}
mainCanvasContainer={mainCanvasContainer}
canvasHeaderHeight={canvasHeaderHeight}
/>
)}
</SuspenseCountProvider>
)}

View file

@ -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' }}
>
<PageCanvasHeader showCanvasHeader={showCanvasHeader} isMobileLayout={isMobileLayout} currentMode={currentMode} />
<Suspense fallback={null}>
<PageCanvasHeader showCanvasHeader={showCanvasHeader} isMobileLayout={isMobileLayout} currentMode={currentMode} />
</Suspense>
<div
className={cx('canvas-wrapper tw-w-full tw-h-full d-flex', {
'tw-flex-col': position === 'top' || isPagesSidebarHidden,
@ -70,6 +73,8 @@ export const DesktopLayout = ({
{mainCanvasContainer}
</CanvasContentTail>
</div>
<PageCanvasFooter showCanvasFooter={showCanvasFooter} isMobileLayout={isMobileLayout} currentMode={currentMode} />
<Suspense fallback={null}>
<PageCanvasFooter showCanvasFooter={showCanvasFooter} isMobileLayout={isMobileLayout} currentMode={currentMode} />
</Suspense>
</div>
);

View file

@ -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 */}
<PageCanvasHeader showCanvasHeader={showCanvasHeader} isMobileLayout={isMobileLayout} currentMode={currentMode} />
<Suspense fallback={null}>
<PageCanvasHeader
showCanvasHeader={showCanvasHeader}
isMobileLayout={isMobileLayout}
currentMode={currentMode}
/>
</Suspense>
{/* Mobile nav — sticky below header */}
{appType !== 'module' && (
<div
@ -65,7 +72,13 @@ export const MobileLayout = ({
<CanvasContentTail currentMode={currentMode} appType={appType} isAppDarkMode={isAppDarkMode}>
{mainCanvasContainer}
</CanvasContentTail>
<PageCanvasFooter showCanvasFooter={showCanvasFooter} isMobileLayout={isMobileLayout} currentMode={currentMode} />
<Suspense fallback={null}>
<PageCanvasFooter
showCanvasFooter={showCanvasFooter}
isMobileLayout={isMobileLayout}
currentMode={currentMode}
/>
</Suspense>
</div>
);
};

View file

@ -0,0 +1,7 @@
import { withEditionSpecificComponent } from '@/modules/common/helpers/withEditionSpecificComponent';
const AppLibrariesIcon = () => {
return null;
};
export default withEditionSpecificComponent(AppLibrariesIcon, 'AppLibraries');

View file

@ -0,0 +1,7 @@
import { withEditionSpecificComponent } from '@/modules/common/helpers/withEditionSpecificComponent';
const AppLibraries = () => {
return null;
};
export default withEditionSpecificComponent(AppLibraries, 'AppLibraries');

View file

@ -267,4 +267,5 @@
width: 158px;
}
}
}

View file

@ -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 <AppHistory darkMode={darkMode} setPinned={setPinned} pinned={pinned} />;
case 'libraries':
return <AppLibraries darkMode={darkMode} onClose={() => toggleLeftSidebar(false)} />;
case 'debugger':
return <Debugger onClose={() => toggleLeftSidebar(false)} darkMode={darkMode} />;
case 'settings':
@ -189,6 +193,14 @@ export const BaseLeftSidebar = ({
setSideBarBtnRefs={setSideBarBtnRefs}
/>
)}
{featureAccess?.appJsLibraries && (
<AppLibrariesIcon
darkMode={darkMode}
selectedSidebarItem={selectedSidebarItem}
handleSelectedSidebarItem={handleSelectedSidebarItem}
setSideBarBtnRefs={setSideBarBtnRefs}
/>
)}
<SidebarItem
icon="settings"
selectedSidebarItem={selectedSidebarItem}
@ -218,7 +230,7 @@ export const BaseLeftSidebar = ({
<Popover
onInteractOutside={(e) => {
// 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;

View file

@ -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 {};
}
}

View file

@ -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]);

View file

@ -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,
});

View file

@ -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',

View file

@ -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),

View file

@ -79,7 +79,7 @@
"default": "no",
"list": [
{
"name": "Basic Connection (No TNS/Wallet)",
"name": "Basic Connection",
"value": "no"
},
{

View file

@ -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;
}
}

View file

@ -67,6 +67,7 @@ export const BASIC_PLAN_TERMS: Partial<Terms> = {
promote: false,
release: false,
history: false,
jsLibraries: false,
},
components: {
navigation: false,

View file

@ -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',

View file

@ -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;
}

View file

@ -67,6 +67,7 @@ export interface Terms {
promote: boolean;
release: boolean;
history: boolean;
jsLibraries: boolean;
};
components?: {
navigation?: boolean;