diff --git a/CODEOWNERS b/CODEOWNERS index b674816b47..20118b2b98 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -191,16 +191,21 @@ app/client/src/ce/JSFunctionExecutionSaga.ts @ApekshaBhosale app/client/src/ee/JSFunctionExecutionSaga.ts @ApekshaBhosale # Enterprise Success -app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/**/* @sharat87 @abhvsn @AnaghHegde +app/server/appsmith-server/src/main/java/com/appsmith/server/migrations/**/* @sharat87 @abhvsn -# DevOps & Shri -deploy/**/* @sharat87 @pratapaprasanna -.github/workflows/*.yml @sharat87 -app/client/packages/ctl/**/* @sharat87 @pratapaprasanna -app/server/**/pom.xml @sharat87 +# DevOps +deploy/**/* @sharat87 @pratapaprasanna @nidhi-nair +.github/workflows/*.yml @sharat87 @nidhi-nair +app/client/packages/ctl/**/* @sharat87 @pratapaprasanna @nidhi-nair +Dockerfile @nidhi-nair + +# Server dependencies +app/server/**/pom.xml @sharat87 @nidhi-nair + +# Repository layer app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/BaseAppsmithRepositoryCEImpl.java @sharat87 app/server/appsmith-server/src/main/java/com/appsmith/server/repositories/ce/params/QueryAllParams.java @sharat87 app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/bridge/**/* @sharat87 -#Cypress -app/client/cypress/**/* @ApekshaBhosale @sagar-qa007 +# Cypress +app/client/cypress/**/* @ApekshaBhosale diff --git a/app/client/packages/design-system/ads/src/Templates/EditableEntityName/EditableEntityName.tsx b/app/client/packages/design-system/ads/src/Templates/EditableEntityName/EditableEntityName.tsx index 56cfb47d53..c47bf4c2d6 100644 --- a/app/client/packages/design-system/ads/src/Templates/EditableEntityName/EditableEntityName.tsx +++ b/app/client/packages/design-system/ads/src/Templates/EditableEntityName/EditableEntityName.tsx @@ -16,6 +16,7 @@ export const isEllipsisActive = (element: HTMLElement | null) => { export const EditableEntityName = (props: EditableEntityNameProps) => { const { canEdit, + hasError, icon, inputTestId, isEditing, @@ -106,6 +107,7 @@ export const EditableEntityName = (props: EditableEntityNameProps) => { { return ( { normalizeName, onEditComplete, onNameSave, + props.hasError, props.title, startIcon, validateName, diff --git a/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityListTree/EntityListTree.types.ts b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityListTree/EntityListTree.types.ts index ba050877e2..a2f01b41b9 100644 --- a/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityListTree/EntityListTree.types.ts +++ b/app/client/packages/design-system/ads/src/Templates/EntityExplorer/EntityListTree/EntityListTree.types.ts @@ -6,6 +6,7 @@ export interface EntityListTreeItem { id: string; name: string; type: string; + hasError?: boolean; } export interface EntityListTreeProps { diff --git a/app/client/src/PluginActionEditor/PluginActionEditor.tsx b/app/client/src/PluginActionEditor/PluginActionEditor.tsx index 674e248bdd..ef39a2f54d 100644 --- a/app/client/src/PluginActionEditor/PluginActionEditor.tsx +++ b/app/client/src/PluginActionEditor/PluginActionEditor.tsx @@ -15,6 +15,8 @@ import CenteredWrapper from "components/designSystems/appsmith/CenteredWrapper"; import { Text } from "@appsmith/ads"; import { useIsEditorInitialised } from "IDE/hooks"; import { useActionSettingsConfig } from "./hooks"; +import { createMessage, PLUGIN_NOT_INSTALLED } from "ee/constants/messages"; +import { ShowUpgradeMenuItem } from "ee/utils/licenseHelpers"; interface ChildrenProps { children: React.ReactNode | React.ReactNode[]; @@ -54,10 +56,11 @@ const PluginActionEditor = (props: ChildrenProps) => { if (!plugin) { return ( - + - Plugin not installed! + {createMessage(PLUGIN_NOT_INSTALLED)} + ); } diff --git a/app/client/src/WidgetQueryGenerators/Snowflake/index.test.ts b/app/client/src/WidgetQueryGenerators/Snowflake/index.test.ts index 3523a55d7a..0c78aab41d 100644 --- a/app/client/src/WidgetQueryGenerators/Snowflake/index.test.ts +++ b/app/client/src/WidgetQueryGenerators/Snowflake/index.test.ts @@ -1,5 +1,6 @@ import { DatasourceConnectionMode } from "entities/Datasource"; import Snowflake from "."; +import { SSLType } from "entities/Datasource/RestAPIForm"; describe("Snowflake WidgetQueryGenerator", () => { const initialValues = { @@ -367,4 +368,36 @@ OFFSET }, ]); }); + + test("should return provided connection mode when available", () => { + const datasourceConfiguration = { + connection: { + mode: DatasourceConnectionMode.READ_ONLY, + ssl: { + authType: SSLType.DEFAULT, + authTypeControl: false, + certificateFile: { + name: "", + base64Content: "", + }, + }, + }, + url: "https://example.com", + }; + + const connectionMode = Snowflake.getConnectionMode(datasourceConfiguration); + + expect(connectionMode).toEqual(DatasourceConnectionMode.READ_ONLY); + }); + + test("should return READ_WRITE as default when no connection mode is provided", () => { + const datasourceConfiguration = { + // No connection mode specified + url: "https://example.com", + }; + + const connectionMode = Snowflake.getConnectionMode(datasourceConfiguration); + + expect(connectionMode).toEqual(DatasourceConnectionMode.READ_WRITE); + }); }); diff --git a/app/client/src/WidgetQueryGenerators/Snowflake/index.ts b/app/client/src/WidgetQueryGenerators/Snowflake/index.ts index a3fb7bb3b8..bc5b74d869 100644 --- a/app/client/src/WidgetQueryGenerators/Snowflake/index.ts +++ b/app/client/src/WidgetQueryGenerators/Snowflake/index.ts @@ -1,14 +1,17 @@ -import { BaseQueryGenerator } from "../BaseQueryGenerator"; +import { + DatasourceConnectionMode, + type DatasourceStorage, +} from "entities/Datasource"; +import { without } from "lodash"; import { formatDialect, snowflake } from "sql-formatter"; -import { QUERY_TYPE } from "../types"; +import { removeSpecialChars } from "utils/helpers"; +import { BaseQueryGenerator } from "../BaseQueryGenerator"; import type { + ActionConfigurationSQL, WidgetQueryGenerationConfig, WidgetQueryGenerationFormConfig, - ActionConfigurationSQL, } from "../types"; -import { removeSpecialChars } from "utils/helpers"; -import { without } from "lodash"; -import { DatasourceConnectionMode } from "entities/Datasource"; +import { QUERY_TYPE } from "../types"; export default abstract class Snowflake extends BaseQueryGenerator { private static buildSelect( @@ -249,4 +252,13 @@ export default abstract class Snowflake extends BaseQueryGenerator { static getTotalRecordExpression(binding: string) { return `${binding}[0].count`; } + + static getConnectionMode( + datasourceConfiguration: DatasourceStorage["datasourceConfiguration"], + ) { + return ( + datasourceConfiguration?.connection?.mode || + DatasourceConnectionMode.READ_WRITE + ); + } } diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts index 87454d5b31..2f7ab65da9 100644 --- a/app/client/src/ce/constants/messages.ts +++ b/app/client/src/ce/constants/messages.ts @@ -2002,10 +2002,12 @@ export const IN_APP_EMBED_SETTING = { "Make your app public to embed your Appsmith app into legacy applications", secondaryHeading: () => "Embedding in public mode is supported in the free plan. To make your app public, please contact your administrator.", - chromeExtensionBannerTitle: () => "Get the Chrome extension", + chromeExtensionBannerTitle: (isInstalled: boolean) => + isInstalled ? "Appsmith Agents extension" : "Install the Chrome extension", chromeExtensionBannerDescription: () => "Bring powerful AI assistance to the tools you and your teams use.", - chromeExtensionBannerButton: () => "Get the extension", + chromeExtensionBannerButton: (isInstalled: boolean) => + isInstalled ? "Extension settings" : "Get the extension", }; export const APP_NAVIGATION_SETTING = { @@ -2691,3 +2693,6 @@ export const GOOGLE_RECAPTCHA_FAILED = () => "Google reCAPTCHA verification failed"; export const PASSWORD_INSUFFICIENT_STRENGTH = () => "Insufficient password strength"; + +export const PLUGIN_NOT_INSTALLED = () => + "Upgrade your plan to unlock access to these integrations."; diff --git a/app/client/src/ce/pages/AppIDE/components/QueryAdd/useGroupedAddQueryOperations.tsx b/app/client/src/ce/pages/AppIDE/components/QueryAdd/useGroupedAddQueryOperations.tsx index ffff528078..bbfac1fbdf 100644 --- a/app/client/src/ce/pages/AppIDE/components/QueryAdd/useGroupedAddQueryOperations.tsx +++ b/app/client/src/ce/pages/AppIDE/components/QueryAdd/useGroupedAddQueryOperations.tsx @@ -45,34 +45,40 @@ export const useAddQueryListItems = () => { ); const getListItems = (data: ActionOperation[]) => { - return data.map((fileOperation) => { - let title = - fileOperation.entityExplorerTitle || - fileOperation.dsName || - fileOperation.title; + return data + .map((fileOperation) => { + let title = + fileOperation.entityExplorerTitle || + fileOperation.dsName || + fileOperation.title; - title = - fileOperation.focusEntityType === FocusEntity.QUERY_MODULE_INSTANCE - ? fileOperation.title - : title; - const className = createAddClassName(title); - const icon = - fileOperation.icon || - (fileOperation.pluginId && - getPluginEntityIcon(pluginGroups[fileOperation.pluginId])); - - return { - startIcon: icon, - className: className, - title, - description: + title = fileOperation.focusEntityType === FocusEntity.QUERY_MODULE_INSTANCE - ? fileOperation.dsName - : "", - descriptionType: "inline", - onClick: onCreateItemClick.bind(null, fileOperation), - } as ListItemProps; - }); + ? fileOperation.title + : title; + const className = createAddClassName(title); + const icon = + fileOperation.icon || + (fileOperation.pluginId && + getPluginEntityIcon(pluginGroups[fileOperation.pluginId])); + + if (fileOperation.pluginId && !pluginGroups[fileOperation.pluginId]) { + return undefined; + } + + return { + startIcon: icon, + className: className, + title, + description: + fileOperation.focusEntityType === FocusEntity.QUERY_MODULE_INSTANCE + ? fileOperation.dsName + : "", + descriptionType: "inline", + onClick: onCreateItemClick.bind(null, fileOperation), + } as ListItemProps; + }) + .filter((item) => item !== undefined); }; return { getListItems }; diff --git a/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchHooks.tsx b/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchHooks.tsx index 7e1cc5b1ed..7098a4847a 100644 --- a/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchHooks.tsx +++ b/app/client/src/components/editorComponents/GlobalSearch/GlobalSearchHooks.tsx @@ -64,6 +64,9 @@ export const useFilteredFileOperations = ({ }: FilterFileOperationsProps) => { const { appWideDS = [], otherDS = [] } = useAppWideAndOtherDatasource(); const plugins = useSelector(getPlugins); + const pluginById = useMemo(() => { + return keyBy(plugins, "id"); + }, [plugins]); const moduleOptions = useModuleOptions(); const workflowOptions = useWorkflowOptions(); @@ -106,7 +109,7 @@ export const useFilteredFileOperations = ({ return AiPlugin.id !== ds.pluginId; } - return true; + return !!pluginById[ds.pluginId]?.id; }); return useFilteredAndSortedFileOperations({ diff --git a/app/client/src/components/editorComponents/GlobalSearch/index.tsx b/app/client/src/components/editorComponents/GlobalSearch/index.tsx index d6df653cf0..2da1c9d52b 100644 --- a/app/client/src/components/editorComponents/GlobalSearch/index.tsx +++ b/app/client/src/components/editorComponents/GlobalSearch/index.tsx @@ -43,7 +43,7 @@ import type { ExplorerURLParams } from "ee/pages/Editor/Explorer/helpers"; import { getLastSelectedWidget } from "selectors/ui"; import AnalyticsUtil from "ee/utils/AnalyticsUtil"; import useRecentEntities from "./useRecentEntities"; -import { noop } from "lodash"; +import { keyBy, noop } from "lodash"; import { getCurrentPageId, getPagePermissions, @@ -179,6 +179,9 @@ function GlobalSearch() { (state: AppState) => state.ui.globalSearch.filterContext.category, ); const plugins = useSelector(getPlugins); + const pluginById = useMemo(() => { + return keyBy(plugins, "id"); + }, [plugins]); const setCategory = useCallback( (category: SearchCategory) => { dispatch(setGlobalSearchFilterContext({ category: category })); @@ -233,10 +236,15 @@ function GlobalSearch() { }, [basePageIdToPageIdMap, params?.basePageId, reducerDatasources]); const filteredDatasources = useMemo(() => { - if (!query) return datasourcesList; + if (!query) + return datasourcesList.filter( + (datasource) => pluginById[datasource.pluginId]?.id, + ); - return datasourcesList.filter((datasource) => - isMatching(datasource.name, query), + return datasourcesList.filter( + (datasource) => + isMatching(datasource.name, query) && + pluginById[datasource.pluginId]?.id, ); }, [datasourcesList, query]); const recentEntities = useRecentEntities(); diff --git a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/DatasourceDropdown/DropdownOption.tsx b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/DatasourceDropdown/DropdownOption.tsx index bbc1833d6a..f34878fefb 100644 --- a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/DatasourceDropdown/DropdownOption.tsx +++ b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/DatasourceDropdown/DropdownOption.tsx @@ -22,27 +22,42 @@ const IconContainer = styled.div` align-items: center; `; -const Label = styled.div` +const LabelContainer = styled.div` width: calc(100% - 40px); + display: flex; + flex-direction: column; + justify-content: center; +`; + +const Label = styled.div` overflow: hidden; text-overflow: ellipsis; `; +const SubText = styled.span` + font-size: 12px; + color: var(--ads-v2-color-fg-muted); +`; + interface Props { + className?: string; label?: JSX.Element | string; leftIcon?: JSX.Element; rightIcon?: JSX.Element; - className?: string; + subText?: string; } export function DropdownOption(props: Props) { - const { className, label, leftIcon, rightIcon } = props; + const { className, label, leftIcon, rightIcon, subText } = props; return ( {leftIcon && {leftIcon}} - + + + {subText && {subText}} + {rightIcon && {rightIcon}} diff --git a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/TableOrSpreadsheetDropdown/index.tsx b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/TableOrSpreadsheetDropdown/index.tsx index ae9d6a6803..985c7ba532 100644 --- a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/TableOrSpreadsheetDropdown/index.tsx +++ b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/TableOrSpreadsheetDropdown/index.tsx @@ -1,11 +1,19 @@ import React, { memo, useContext } from "react"; -import { ErrorMessage, Label, LabelWrapper, SelectWrapper } from "../../styles"; +import { + ErrorMessage, + Label, + LabelWrapper, + PrimaryKeysMessage, + SelectWrapper, +} from "../../styles"; import { useTableOrSpreadsheet } from "./useTableOrSpreadsheet"; import { Select, Option, Tooltip } from "@appsmith/ads"; import { DropdownOption } from "../DatasourceDropdown/DropdownOption"; import type { DefaultOptionType } from "rc-select/lib/Select"; import { ColumnSelectorModal } from "../ColumnSelectorModal"; import { WidgetQueryGeneratorFormContext } from "components/editorComponents/WidgetQueryGeneratorForm/index"; +import { createMessage } from "ee/constants/messages"; +import { NO_PRIMARY_KEYS_MESSAGE, PRIMARY_KEYS_MESSAGE } from "../../constants"; function TableOrSpreadsheetDropdown() { const { @@ -55,10 +63,18 @@ function TableOrSpreadsheetDropdown() { return ( ); })} @@ -66,6 +82,10 @@ function TableOrSpreadsheetDropdown() { {error} + + + {createMessage(PRIMARY_KEYS_MESSAGE)} + ); } else { diff --git a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/TableOrSpreadsheetDropdown/useTableOrSpreadsheet.test.ts b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/TableOrSpreadsheetDropdown/useTableOrSpreadsheet.test.ts new file mode 100644 index 0000000000..9ab5c83991 --- /dev/null +++ b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/TableOrSpreadsheetDropdown/useTableOrSpreadsheet.test.ts @@ -0,0 +1,326 @@ +import { useTableOrSpreadsheet } from "./useTableOrSpreadsheet"; +import { renderHook } from "@testing-library/react-hooks"; +import type { DatasourceTable } from "entities/Datasource"; +import { PluginPackageName } from "entities/Plugin"; + +// Mock pageListSelectors +jest.mock("selectors/pageListSelectors", () => ({ + getIsGeneratingTemplatePage: jest.fn(() => false), + getIsGeneratePageModalOpen: jest.fn(() => false), + getApplicationLastModifiedTime: jest.fn(), + getCurrentApplicationId: jest.fn(), + getCurrentPageId: jest.fn(), + getPageById: jest.fn(), + getPageList: jest.fn(() => []), + getPageListAsOptions: jest.fn(() => []), + getPageListState: jest.fn(() => ({ + isGeneratingTemplatePage: false, + isGeneratePageModalOpen: false, + })), +})); + +// Mock UI selectors +jest.mock("selectors/ui", () => ({ + getSelectedAppTheme: jest.fn(), + getAppThemes: jest.fn(() => []), + getSelectedAppThemeColor: jest.fn(), + getIsDatasourceInViewMode: jest.fn(() => false), + getDatasourceCollapsibleState: jest.fn(() => ({})), + getIsInOnboardingFlow: jest.fn(() => false), +})); + +// Mock datasourceActions +jest.mock("actions/datasourceActions", () => ({ + fetchGheetSheets: jest.fn(() => ({ type: "FETCH_GSHEET_SHEETS" })), +})); + +// Mock editor selectors +jest.mock("selectors/editorSelectors", () => ({ + getWidgets: jest.fn(() => ({})), + getWidgetsMeta: jest.fn(() => ({})), + getWidgetsForImport: jest.fn(() => ({})), +})); + +// Mock Reselect +jest.mock("reselect", () => ({ + createSelector: jest.fn((selectors, resultFunc) => { + if (typeof resultFunc === "function") { + return resultFunc(); + } + + return jest.fn(); + }), +})); + +// Mock selectors +jest.mock("selectors/dataTreeSelectors", () => ({ + getLayoutSystemType: jest.fn(), + getIsMobileBreakPoint: jest.fn(), +})); + +// Mock redux +jest.mock("react-redux", () => ({ + useSelector: jest.fn(), + useDispatch: () => jest.fn(), +})); + +// Mock redux-form +jest.mock("redux-form", () => ({ + getFormValues: jest.fn(), + Field: jest.fn(), + reduxForm: jest.fn(), +})); + +// Mock ee/selectors/entitiesSelector +jest.mock("ee/selectors/entitiesSelector", () => ({ + getDatasource: jest.fn(), + getDatasourceLoading: jest.fn(), + getDatasourceStructureById: jest.fn(), + getIsFetchingDatasourceStructure: jest.fn(), + getPluginPackageFromDatasourceId: jest.fn(), +})); + +// Mock selectors/datasourceSelectors +jest.mock("selectors/datasourceSelectors", () => ({ + getGsheetSpreadsheets: jest.fn(() => jest.fn()), + getIsFetchingGsheetSpreadsheets: jest.fn(), +})); + +// Mock selectors/oneClickBindingSelectors +jest.mock("selectors/oneClickBindingSelectors", () => ({ + getisOneClickBindingConnectingForWidget: jest.fn(() => jest.fn()), +})); + +// Mock sagas/selectors +jest.mock("sagas/selectors", () => ({ + getWidget: jest.fn(), +})); + +// Mock utils +jest.mock("utils/editorContextUtils", () => ({ + isGoogleSheetPluginDS: jest.fn( + (plugin) => plugin === PluginPackageName.GOOGLE_SHEETS, + ), + isMongoDBPluginDS: jest.fn((plugin) => plugin === PluginPackageName.MONGO), +})); + +// Mock utils/helpers +jest.mock("utils/helpers", () => ({ + getAppMode: jest.fn(), + isEllipsisActive: jest.fn(), +})); + +// Mock WidgetOperationUtils +jest.mock("sagas/WidgetOperationUtils", () => ({})); + +// Mock WidgetUtils +jest.mock("widgets/WidgetUtils", () => ({})); + +// Mock the context +jest.mock("react", () => { + const originalModule = jest.requireActual("react"); + + return { + ...originalModule, + useContext: () => ({ + config: { datasource: "test-ds-id", table: "" }, + propertyName: "test-property", + updateConfig: jest.fn(), + widgetId: "test-widget-id", + }), + useCallback: unknown>(fn: T) => fn, + // useMemo will be mocked in each test + useMemo: jest.fn(), + }; +}); + +// Import after all mocks are set up +import { useSelector } from "react-redux"; +import * as React from "react"; + +// Create a simplified test +describe("useTableOrSpreadsheet", () => { + const mockUseSelector = useSelector as jest.Mock; + const mockUseMemo = React.useMemo as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseSelector.mockImplementation(() => ({})); + mockUseMemo.mockImplementation((fn) => fn()); + }); + + it("should render without crashing", () => { + // Mock minimum required values + mockUseSelector.mockImplementation(() => ({ + datasourceStructure: { tables: [] }, + selectedDatasourcePluginPackageName: PluginPackageName.POSTGRES, + selectedDatasource: { name: "Test" }, + widget: { widgetName: "Test" }, + })); + + const { result } = renderHook(() => useTableOrSpreadsheet()); + + expect(result.current).toBeTruthy(); + }); + + it("should disable tables without primary keys for non-MongoDB datasources", () => { + const mockTables: DatasourceTable[] = [ + { + name: "table1", + type: "TABLE", + columns: [], + keys: [], + templates: [], + }, + { + name: "table2", + type: "TABLE", + columns: [], + keys: [ + { + name: "id", + type: "primary", + columnNames: ["id"], + fromColumns: ["id"], + }, + ], + templates: [], + }, + ]; + + const mockOptions = [ + { + id: "table1", + label: "table1", + value: "table1", + data: { tableName: "table1" }, + disabled: true, + }, + { + id: "table2", + label: "table2", + value: "table2", + data: { tableName: "table2" }, + disabled: false, + }, + ]; + + mockUseSelector.mockImplementation(() => ({ + selectedDatasourcePluginPackageName: PluginPackageName.POSTGRES, + datasourceStructure: { tables: mockTables }, + widget: { widgetName: "TestWidget" }, + selectedDatasource: { name: "TestDatabase" }, + })); + + mockUseMemo.mockImplementation(() => mockOptions); + + const { result } = renderHook(() => useTableOrSpreadsheet()); + + expect(result.current.options).toHaveLength(2); + expect(result.current.options[0].value).toBe("table1"); + expect(result.current.options[0].disabled).toBe(true); + expect(result.current.options[1].value).toBe("table2"); + expect(result.current.options[1].disabled).toBe(false); + }); + + it("should not disable tables for MongoDB datasources regardless of primary keys", () => { + const mockTables: DatasourceTable[] = [ + { + name: "collection1", + type: "COLLECTION", + columns: [], + keys: [], + templates: [], + }, + { + name: "collection2", + type: "COLLECTION", + columns: [], + keys: [ + { + name: "_id", + type: "primary", + columnNames: ["_id"], + fromColumns: ["_id"], + }, + ], + templates: [], + }, + ]; + + const mockOptions = [ + { + id: "collection1", + label: "collection1", + value: "collection1", + data: { tableName: "collection1" }, + disabled: false, + }, + { + id: "collection2", + label: "collection2", + value: "collection2", + data: { tableName: "collection2" }, + disabled: false, + }, + ]; + + mockUseSelector.mockImplementation(() => ({ + selectedDatasourcePluginPackageName: PluginPackageName.MONGO, + datasourceStructure: { tables: mockTables }, + widget: { widgetName: "TestWidget" }, + selectedDatasource: { name: "TestMongoDB" }, + })); + + mockUseMemo.mockImplementation(() => mockOptions); + + const { result } = renderHook(() => useTableOrSpreadsheet()); + + expect(result.current.options).toHaveLength(2); + expect(result.current.options[0].value).toBe("collection1"); + expect(result.current.options[0].disabled).toBe(false); + expect(result.current.options[1].value).toBe("collection2"); + expect(result.current.options[1].disabled).toBe(false); + }); + + it("should not disable tables for Google Sheets datasource", () => { + const mockOptions = [ + { + id: "sheet1", + label: "Sheet1", + value: "Sheet1", + data: { tableName: "sheet1" }, + disabled: false, + }, + { + id: "sheet2", + label: "Sheet2", + value: "Sheet2", + data: { tableName: "sheet2" }, + disabled: false, + }, + ]; + + mockUseSelector.mockImplementation(() => ({ + selectedDatasourcePluginPackageName: PluginPackageName.GOOGLE_SHEETS, + spreadSheets: { + value: [ + { label: "Sheet1", value: "sheet1" }, + { label: "Sheet2", value: "sheet2" }, + ], + }, + widget: { widgetName: "TestWidget" }, + selectedDatasource: { name: "TestGoogleSheet" }, + })); + + mockUseMemo.mockImplementation(() => mockOptions); + + const { result } = renderHook(() => useTableOrSpreadsheet()); + + expect(result.current.options).toHaveLength(2); + expect(result.current.options[0].value).toBe("Sheet1"); + expect(result.current.options[0].disabled).toBe(false); + expect(result.current.options[1].value).toBe("Sheet2"); + expect(result.current.options[1].disabled).toBe(false); + }); +}); diff --git a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/TableOrSpreadsheetDropdown/useTableOrSpreadsheet.tsx b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/TableOrSpreadsheetDropdown/useTableOrSpreadsheet.tsx index f563c1a974..f188760437 100644 --- a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/TableOrSpreadsheetDropdown/useTableOrSpreadsheet.tsx +++ b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/TableOrSpreadsheetDropdown/useTableOrSpreadsheet.tsx @@ -1,7 +1,5 @@ -import React from "react"; import { fetchGheetSheets } from "actions/datasourceActions"; -import { useCallback, useContext, useMemo } from "react"; -import { useDispatch, useSelector } from "react-redux"; +import type { AppState } from "ee/reducers"; import { getDatasource, getDatasourceLoading, @@ -9,21 +7,25 @@ import { getIsFetchingDatasourceStructure, getPluginPackageFromDatasourceId, } from "ee/selectors/entitiesSelector"; -import { WidgetQueryGeneratorFormContext } from "../.."; -import { Bold, Label } from "../../styles"; -import { PluginFormInputFieldMap } from "../../constants"; +import AnalyticsUtil from "ee/utils/AnalyticsUtil"; +import type { DatasourceStructure, DatasourceTable } from "entities/Datasource"; +import React, { useCallback, useContext, useMemo } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { getWidget } from "sagas/selectors"; import { getGsheetSpreadsheets, getIsFetchingGsheetSpreadsheets, } from "selectors/datasourceSelectors"; -import { isGoogleSheetPluginDS } from "utils/editorContextUtils"; -import type { AppState } from "ee/reducers"; -import { DropdownOption as Option } from "../DatasourceDropdown/DropdownOption"; -import type { DropdownOptionType } from "../../types"; import { getisOneClickBindingConnectingForWidget } from "selectors/oneClickBindingSelectors"; -import AnalyticsUtil from "ee/utils/AnalyticsUtil"; -import { getWidget } from "sagas/selectors"; -import type { DatasourceStructure } from "entities/Datasource"; +import { + isGoogleSheetPluginDS, + isMongoDBPluginDS, +} from "utils/editorContextUtils"; +import { WidgetQueryGeneratorFormContext } from "../.."; +import { PluginFormInputFieldMap } from "../../constants"; +import { Bold, Label } from "../../styles"; +import type { DropdownOptionType } from "../../types"; +import { DropdownOption as Option } from "../DatasourceDropdown/DropdownOption"; export function useTableOrSpreadsheet() { const dispatch = useDispatch(); @@ -63,6 +65,10 @@ export function useTableOrSpreadsheet() { ).TABLE : "table"; + const tableHasPrimaryKeys = (table: DatasourceTable) => { + return table.keys && table.keys.length > 0; + }; + const options = useMemo(() => { if ( isGoogleSheetPluginDS(selectedDatasourcePluginPackageName) && @@ -72,19 +78,35 @@ export function useTableOrSpreadsheet() { id: value, label: label, value: label, + disabled: false, data: { tableName: value, }, })); - } else if (datasourceStructure) { - return (datasourceStructure.tables || []).map(({ name }) => ({ - id: name, - label: name, - value: name, + } else if (isMongoDBPluginDS(selectedDatasourcePluginPackageName)) { + return (datasourceStructure.tables || []).map((table) => ({ + id: table.name, + label: table.name, + value: table.name, data: { - tableName: name, + tableName: table.name, }, + disabled: false, })); + } else if (datasourceStructure) { + return (datasourceStructure.tables || []).map((table) => { + const hasPrimaryKeys = tableHasPrimaryKeys(table); + + return { + id: table.name, + label: table.name, + value: table.name, + data: { + tableName: table.name, + }, + disabled: !hasPrimaryKeys, + }; + }); } else { return []; } diff --git a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/constants.ts b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/constants.ts index 867a9b5c0a..8de11eb3c7 100644 --- a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/constants.ts +++ b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/constants.ts @@ -39,3 +39,8 @@ export const PluginFormInputFieldMap: Record< }; export const DEFAULT_QUERY_OPTIONS_COUNTS_TO_SHOW = 4; + +export const PRIMARY_KEYS_MESSAGE = () => + "Tables without primary keys are disabled as they are required for reliable data operations."; + +export const NO_PRIMARY_KEYS_MESSAGE = () => "No primary keys"; diff --git a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/styles.tsx b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/styles.tsx index 211f0b170c..daff6c729b 100644 --- a/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/styles.tsx +++ b/app/client/src/components/editorComponents/WidgetQueryGeneratorForm/styles.tsx @@ -66,6 +66,13 @@ export const ErrorMessage = styled.div` margin-top: 5px; `; +export const PrimaryKeysMessage = styled.div` + font-size: 12px; + line-height: 14px; + color: var(--ads-v2-color-fg-subtle); + margin-top: 5px; +`; + export const Placeholder = styled.div` color: var(--ads-v2-color-fg-subtle); `; diff --git a/app/client/src/components/propertyControls/TableComputeValue.test.tsx b/app/client/src/components/propertyControls/TableComputeValue.test.tsx index d0ea214611..9f51bda99d 100644 --- a/app/client/src/components/propertyControls/TableComputeValue.test.tsx +++ b/app/client/src/components/propertyControls/TableComputeValue.test.tsx @@ -4,6 +4,7 @@ describe("ComputeTablePropertyControlV2.getInputComputedValue", () => { const tableName = "Table1"; const inputVariations = [ "currentRow.price", + `JSObject1.somefunction(currentRow["id"] || 0)`, ` [ { @@ -58,4 +59,45 @@ describe("ComputeTablePropertyControlV2.getInputComputedValue", () => { ComputeTablePropertyControlV2.getInputComputedValue(computedValue), ).toBe(`{{currentRow.quantity}}{{5}}`); }); + + it("3. should handle nested parentheses correctly", () => { + const input = + "JSObject1.complexFunction(currentRow.id, JSObject2.helperFunction(currentRow.name))"; + const computedValue = ComputeTablePropertyControlV2.getTableComputeBinding( + tableName, + input, + ); + + expect( + ComputeTablePropertyControlV2.getInputComputedValue(computedValue), + ).toBe(`{{${input}}}`); + }); + + it("4. should handle malformed expressions with unbalanced parentheses", () => { + // Create a malformed expression by manually crafting a bad computedValue string + // Removing the proper closing parenthesis structure + const malformedComputedValue = + "{{(() => { const tableData = Table1.processedTableData || []; return tableData.length > 0 ? tableData.map((currentRow, currentIndex) => (currentRow.function(unbalanced)) : fallback })"; + + // The function should gracefully handle this and return the original string + // rather than throwing an error or returning a partial/incorrect result + expect( + ComputeTablePropertyControlV2.getInputComputedValue( + malformedComputedValue, + ), + ).toBe(malformedComputedValue); + }); + + it("5. should correctly parse complex but valid expressions with multiple nested parentheses", () => { + const complexInput = + "JSObject1.process(currentRow.value1, (currentRow.value2 || getDefault()), JSObject2.format(currentRow.value3))"; + const computedValue = ComputeTablePropertyControlV2.getTableComputeBinding( + tableName, + complexInput, + ); + + expect( + ComputeTablePropertyControlV2.getInputComputedValue(computedValue), + ).toBe(`{{${complexInput}}}`); + }); }); diff --git a/app/client/src/components/propertyControls/TableComputeValue.tsx b/app/client/src/components/propertyControls/TableComputeValue.tsx index 197bf2ceb8..90c9957ef1 100644 --- a/app/client/src/components/propertyControls/TableComputeValue.tsx +++ b/app/client/src/components/propertyControls/TableComputeValue.tsx @@ -161,25 +161,86 @@ class ComputeTablePropertyControlV2 extends BaseControl { const tableData = Table1.processedTableData || []; return tableData.length > 0 ? tableData.map((currentRow, currentIndex) => (currentRow.price * 2)) : currentRow.price * 2 })()}}" const mapSignatureIndex = propertyValue.indexOf(MAP_FUNCTION_SIGNATURE); // Find the actual computation expression between the map parentheses const computationStart = mapSignatureIndex + MAP_FUNCTION_SIGNATURE.length; - const computationEnd = propertyValue.indexOf("))", computationStart); + + const { endIndex, isValid } = this.findMatchingClosingParenthesis( + propertyValue, + computationStart, + ); + + // Handle case where no matching closing parenthesis is found + if (!isValid) { + // If we can't find the proper closing parenthesis, fall back to returning the original value + // This prevents errors when the expression is malformed + return propertyValue; + } // Extract the computation expression between the map parentheses - // Note: At this point, we're just extracting the raw expression like "currentRow.price * 2" - // The actual removal of "currentRow." prefix happens later in JSToString() const computationExpression = propertyValue.substring( computationStart, - computationEnd, + endIndex, ); return JSToString(computationExpression); }; + /** + * Check if the computed value string looks structurally valid + * This helps catch obviously malformed expressions early + */ + private static isLikelyValidComputedValue(value: string): boolean { + // Check for basic structural elements that should be present + const hasOpeningStructure = value.includes("(() => {"); + const hasTableDataAssignment = value.includes("const tableData ="); + const hasReturnStatement = value.includes("return tableData.length > 0 ?"); + const hasClosingStructure = value.includes("})()}}"); + + return ( + hasOpeningStructure && + hasTableDataAssignment && + hasReturnStatement && + hasClosingStructure + ); + } + + /** + * Utility function to find the matching closing parenthesis + * @param text - The text to search in + * @param startIndex - The index after the opening parenthesis + * @returns Object containing the index of the matching closing parenthesis and whether it was found + */ + private static findMatchingClosingParenthesis( + text: string, + startIndex: number, + ) { + let openParenCount = 1; // Start with 1 for the opening parenthesis + + for (let i = startIndex; i < text.length; i++) { + if (text[i] === "(") { + openParenCount++; + } else if (text[i] === ")") { + openParenCount--; + + if (openParenCount === 0) { + return { endIndex: i, isValid: true }; + } + } + } + + // No matching closing parenthesis found + return { endIndex: text.length, isValid: false }; + } + getComputedValue = (value: string, tableName: string) => { // Return raw value if: // 1. The value is not a dynamic binding (not wrapped in {{...}}) diff --git a/app/client/src/pages/Editor/DataSourceEditor/DSFormHeader.tsx b/app/client/src/pages/Editor/DataSourceEditor/DSFormHeader.tsx index f6122062b9..256d65d52c 100644 --- a/app/client/src/pages/Editor/DataSourceEditor/DSFormHeader.tsx +++ b/app/client/src/pages/Editor/DataSourceEditor/DSFormHeader.tsx @@ -28,6 +28,7 @@ import type { PluginType } from "entities/Plugin"; import { useLocation } from "react-router"; import { useHeaderActions } from "ee/hooks/datasourceEditorHooks"; import { getIDETypeByUrl } from "ee/entities/IDE/utils"; +import { getPlugin } from "ee/selectors/entitiesSelector"; export const ActionWrapper = styled.div` display: flex; @@ -118,6 +119,10 @@ export const DSFormHeader = (props: DSFormHeaderProps) => { const dispatch = useDispatch(); const location = useLocation(); const ideType = getIDETypeByUrl(location.pathname); + const plugin = useSelector((state) => + getPlugin(state, datasource?.pluginId || ""), + ); + const isEditDisabled = !plugin?.id; const deleteAction = () => { if (isDeleting) return; @@ -210,8 +215,11 @@ export const DSFormHeader = (props: DSFormHeaderProps) => { )}