From bda1aa4b58779a0b7e7fcc4188103b024ab06d93 Mon Sep 17 00:00:00 2001 From: Jacques Ikot Date: Thu, 24 Apr 2025 03:57:03 -0700 Subject: [PATCH 1/8] fix: JSON form generate form flow broken for tables without primaryKey and READ_WRITE permissions (#40275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 🧩 Problem - Tables **without primary keys** were previously selectable in the form generator, leading to issues with data operations that require unique identifiers. - **Snowflake datasources** defaulted to `READ_ONLY` mode, unnecessarily limiting users from performing query generation operations. --- ### βœ… Solution - Disabled selection of tables that **lack primary keys** in the form generator dropdown. - Added **tooltips and helper text** to explain why certain tables are disabled. - Updated the **Snowflake query generator** to default to `READ_WRITE` mode. - Improved UI components to: - Gracefully handle disabled options - Clearly communicate restrictions to users --- ### πŸ’‘ Why This Solution Works - Prevents users from selecting **invalid or unsuitable tables**, reducing runtime errors. - Improves the **user experience** through helpful visual cues and explanations. - Aligns Snowflake’s behavior with user expectations for **write capabilities**. - Maintains **backward compatibility** for both new and existing configurations. Fixes - https://github.com/appsmithorg/appsmith-ee/issues/6913 ## Automation /ok-to-test tags="@tag.JSONForm, @tag.PropertyPane, @tag.GenerateCRUD, @tag.Datasource, @tag.Sanity, @tag.Widget" ### :mag: Cypress test results > [!TIP] > 🟒 🟒 🟒 All cypress tests have passed! πŸŽ‰ πŸŽ‰ πŸŽ‰ > Workflow run: > Commit: 380e8515ae5ba4ad737c8e41f2678eb43dce22ea > Cypress dashboard. > Tags: `@tag.JSONForm, @tag.PropertyPane, @tag.GenerateCRUD, @tag.Datasource, @tag.Sanity, @tag.Widget` > Spec: >
Thu, 17 Apr 2025 08:51:45 UTC ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No ## Summary by CodeRabbit - **New Features** - Dropdown options now visually indicate when a table lacks primary keys, disabling selection and displaying an explanatory message. - An informational message about primary key requirements is shown below the table selection dropdown. - Dropdown options can now display additional subtext for improved context. - **Enhancements** - Improved handling of datasource-specific behaviors, including support for MongoDB and Google Sheets. - Added connection mode detection for Snowflake datasources. - **Chores** - Added new utility functions and constants to support enhanced dropdown behavior and messaging. - **Tests** - Added tests to verify connection mode detection for Snowflake datasources. - Added comprehensive tests for datasource-specific dropdown option behavior in the table/spreadsheet selector. --- .../Snowflake/index.test.ts | 33 ++ .../WidgetQueryGenerators/Snowflake/index.ts | 24 +- .../DatasourceDropdown/DropdownOption.tsx | 23 +- .../TableOrSpreadsheetDropdown/index.tsx | 24 +- .../useTableOrSpreadsheet.test.ts | 326 ++++++++++++++++++ .../useTableOrSpreadsheet.tsx | 60 +++- .../WidgetQueryGeneratorForm/constants.ts | 5 + .../WidgetQueryGeneratorForm/styles.tsx | 7 + app/client/src/utils/editorContextUtils.ts | 4 + 9 files changed, 475 insertions(+), 31 deletions(-) create mode 100644 app/client/src/components/editorComponents/WidgetQueryGeneratorForm/CommonControls/TableOrSpreadsheetDropdown/useTableOrSpreadsheet.test.ts 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/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/utils/editorContextUtils.ts b/app/client/src/utils/editorContextUtils.ts index 8e40845065..dd7099681c 100644 --- a/app/client/src/utils/editorContextUtils.ts +++ b/app/client/src/utils/editorContextUtils.ts @@ -168,6 +168,10 @@ export function isGoogleSheetPluginDS(pluginPackageName?: string) { return pluginPackageName === PluginPackageName.GOOGLE_SHEETS; } +export function isMongoDBPluginDS(pluginPackageName?: string) { + return pluginPackageName === PluginPackageName.MONGO; +} + /** * Returns datasource property value from datasource?.datasourceConfiguration?.properties * @param datasource Datasource From b2d89007462fd23aafe8e2c3d53da2239483f9dd Mon Sep 17 00:00:00 2001 From: Pawan Kumar Date: Thu, 24 Apr 2025 17:34:15 +0530 Subject: [PATCH 2/8] chore: update messages for chrome extension (#40364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /ok-to-test tags="@tag.AIAgents" ## Summary by CodeRabbit - **New Features** - The Chrome extension banner now displays different titles and button texts based on whether the extension is installed or not, providing a more tailored user experience. > [!TIP] > 🟒 🟒 🟒 All cypress tests have passed! πŸŽ‰ πŸŽ‰ πŸŽ‰ > Workflow run: > Commit: 794b6f80c6a4dac61e7d699b0fb404899f7900a2 > Cypress dashboard. > Tags: `@tag.AIAgents` > Spec: >
Thu, 24 Apr 2025 11:42:35 UTC --- app/client/src/ce/constants/messages.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts index f52663c740..b554479be6 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 = { From 49a926e08f8a0d9bc8589d56e4fc9a7d059b4a2d Mon Sep 17 00:00:00 2001 From: Ankita Kinger Date: Thu, 24 Apr 2025 17:54:22 +0530 Subject: [PATCH 3/8] fix: Handling erroneous state in Entity item ADS template (#40363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Handling erroneous state in Entity item ADS template Fixes [#40362](https://github.com/appsmithorg/appsmith/issues/40362) ## Automation /ok-to-test tags="@tag.IDE" ### :mag: Cypress test results > [!TIP] > 🟒 🟒 🟒 All cypress tests have passed! πŸŽ‰ πŸŽ‰ πŸŽ‰ > Workflow run: > Commit: c9b2437308f4108108ff362f0e8b51220d2dbc17 > Cypress dashboard. > Tags: `@tag.IDE` > Spec: >
Thu, 24 Apr 2025 11:32:13 UTC ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No ## Summary by CodeRabbit - **New Features** - Entity names now visually indicate errors by displaying in a distinct color when an error state is present. - **Style** - Enhanced visual feedback for entities with errors, improving clarity when issues occur. --- .../ads/src/Templates/EditableEntityName/EditableEntityName.tsx | 2 ++ .../Templates/EditableEntityName/EditableEntityName.types.ts | 2 ++ .../ads/src/Templates/EntityExplorer/EntityItem/EntityItem.tsx | 2 ++ .../EntityExplorer/EntityListTree/EntityListTree.types.ts | 1 + 4 files changed, 7 insertions(+) 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 { From d1f55d2cc9e6656886a601271118ea8dd2861f69 Mon Sep 17 00:00:00 2001 From: Nidhi Date: Thu, 24 Apr 2025 21:34:50 +0530 Subject: [PATCH 4/8] Update CODEOWNERS (#40366) --- CODEOWNERS | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) 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 From cf90a8e98c2d8d24340ed9fc632bdcc5b95e272b Mon Sep 17 00:00:00 2001 From: Aman Agarwal Date: Fri, 25 Apr 2025 09:38:07 +0530 Subject: [PATCH 5/8] fix: Updated the copy text if plugin not installed, disabled new query and edit for unavailable plugin ds (#40360) --- .../PluginActionEditor/PluginActionEditor.tsx | 7 ++- app/client/src/ce/constants/messages.ts | 3 + .../QueryAdd/useGroupedAddQueryOperations.tsx | 58 ++++++++++--------- .../GlobalSearch/GlobalSearchHooks.tsx | 5 +- .../editorComponents/GlobalSearch/index.tsx | 16 +++-- .../Editor/DataSourceEditor/DSFormHeader.tsx | 8 +++ .../DataSourceEditor/NewActionButton.tsx | 15 +++-- 7 files changed, 74 insertions(+), 38 deletions(-) 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/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts index b554479be6..7967bb21fb 100644 --- a/app/client/src/ce/constants/messages.ts +++ b/app/client/src/ce/constants/messages.ts @@ -2693,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/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) => { )}