Merge branch 'release' of https://github.com/appsmithorg/appsmith into feat/reactive-actions-run-behaviour

This commit is contained in:
Ankita Kinger 2025-04-25 16:03:42 +05:30
commit f61747a597
26 changed files with 726 additions and 105 deletions

View File

@ -191,16 +191,21 @@ app/client/src/ce/JSFunctionExecutionSaga.ts @ApekshaBhosale
app/client/src/ee/JSFunctionExecutionSaga.ts @ApekshaBhosale app/client/src/ee/JSFunctionExecutionSaga.ts @ApekshaBhosale
# Enterprise Success # 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 # DevOps
deploy/**/* @sharat87 @pratapaprasanna deploy/**/* @sharat87 @pratapaprasanna @nidhi-nair
.github/workflows/*.yml @sharat87 .github/workflows/*.yml @sharat87 @nidhi-nair
app/client/packages/ctl/**/* @sharat87 @pratapaprasanna app/client/packages/ctl/**/* @sharat87 @pratapaprasanna @nidhi-nair
app/server/**/pom.xml @sharat87 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/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/repositories/ce/params/QueryAllParams.java @sharat87
app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/bridge/**/* @sharat87 app/server/appsmith-server/src/main/java/com/appsmith/server/helpers/ce/bridge/**/* @sharat87
#Cypress # Cypress
app/client/cypress/**/* @ApekshaBhosale @sagar-qa007 app/client/cypress/**/* @ApekshaBhosale

View File

@ -16,6 +16,7 @@ export const isEllipsisActive = (element: HTMLElement | null) => {
export const EditableEntityName = (props: EditableEntityNameProps) => { export const EditableEntityName = (props: EditableEntityNameProps) => {
const { const {
canEdit, canEdit,
hasError,
icon, icon,
inputTestId, inputTestId,
isEditing, isEditing,
@ -106,6 +107,7 @@ export const EditableEntityName = (props: EditableEntityNameProps) => {
<Styled.Text <Styled.Text
aria-invalid={Boolean(validationError)} aria-invalid={Boolean(validationError)}
className={clsx("t--entity-name", { editing: inEditMode })} className={clsx("t--entity-name", { editing: inEditMode })}
color={hasError ? "var(--ads-v2-color-fg-error)" : undefined}
data-isediting={inEditMode} data-isediting={inEditMode}
data-isfixedwidth={isFixedWidth} data-isfixedwidth={isFixedWidth}
inputProps={inputProps} inputProps={inputProps}

View File

@ -27,4 +27,6 @@ export interface EditableEntityNameProps {
normalizeName?: boolean; normalizeName?: boolean;
/** Used for showing ellipsis for longer names */ /** Used for showing ellipsis for longer names */
showEllipsis?: boolean; showEllipsis?: boolean;
/** Whether to show the entity is in error state */
hasError?: boolean;
} }

View File

@ -30,6 +30,7 @@ export const EntityItem = (props: EntityItemProps) => {
return ( return (
<EditableEntityName <EditableEntityName
canEdit={canEdit} canEdit={canEdit}
hasError={props.hasError}
icon={startIcon} icon={startIcon}
isEditing={isEditing} isEditing={isEditing}
isFixedWidth isFixedWidth
@ -50,6 +51,7 @@ export const EntityItem = (props: EntityItemProps) => {
normalizeName, normalizeName,
onEditComplete, onEditComplete,
onNameSave, onNameSave,
props.hasError,
props.title, props.title,
startIcon, startIcon,
validateName, validateName,

View File

@ -6,6 +6,7 @@ export interface EntityListTreeItem {
id: string; id: string;
name: string; name: string;
type: string; type: string;
hasError?: boolean;
} }
export interface EntityListTreeProps { export interface EntityListTreeProps {

View File

@ -15,6 +15,8 @@ import CenteredWrapper from "components/designSystems/appsmith/CenteredWrapper";
import { Text } from "@appsmith/ads"; import { Text } from "@appsmith/ads";
import { useIsEditorInitialised } from "IDE/hooks"; import { useIsEditorInitialised } from "IDE/hooks";
import { useActionSettingsConfig } from "./hooks"; import { useActionSettingsConfig } from "./hooks";
import { createMessage, PLUGIN_NOT_INSTALLED } from "ee/constants/messages";
import { ShowUpgradeMenuItem } from "ee/utils/licenseHelpers";
interface ChildrenProps { interface ChildrenProps {
children: React.ReactNode | React.ReactNode[]; children: React.ReactNode | React.ReactNode[];
@ -54,10 +56,11 @@ const PluginActionEditor = (props: ChildrenProps) => {
if (!plugin) { if (!plugin) {
return ( return (
<CenteredWrapper> <CenteredWrapper className="flex-col">
<Text color="var(--ads-v2-color-fg-error)" kind="heading-m"> <Text color="var(--ads-v2-color-fg-error)" kind="heading-m">
Plugin not installed! {createMessage(PLUGIN_NOT_INSTALLED)}
</Text> </Text>
<ShowUpgradeMenuItem />
</CenteredWrapper> </CenteredWrapper>
); );
} }

View File

@ -1,5 +1,6 @@
import { DatasourceConnectionMode } from "entities/Datasource"; import { DatasourceConnectionMode } from "entities/Datasource";
import Snowflake from "."; import Snowflake from ".";
import { SSLType } from "entities/Datasource/RestAPIForm";
describe("Snowflake WidgetQueryGenerator", () => { describe("Snowflake WidgetQueryGenerator", () => {
const initialValues = { 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);
});
}); });

View File

@ -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 { formatDialect, snowflake } from "sql-formatter";
import { QUERY_TYPE } from "../types"; import { removeSpecialChars } from "utils/helpers";
import { BaseQueryGenerator } from "../BaseQueryGenerator";
import type { import type {
ActionConfigurationSQL,
WidgetQueryGenerationConfig, WidgetQueryGenerationConfig,
WidgetQueryGenerationFormConfig, WidgetQueryGenerationFormConfig,
ActionConfigurationSQL,
} from "../types"; } from "../types";
import { removeSpecialChars } from "utils/helpers"; import { QUERY_TYPE } from "../types";
import { without } from "lodash";
import { DatasourceConnectionMode } from "entities/Datasource";
export default abstract class Snowflake extends BaseQueryGenerator { export default abstract class Snowflake extends BaseQueryGenerator {
private static buildSelect( private static buildSelect(
@ -249,4 +252,13 @@ export default abstract class Snowflake extends BaseQueryGenerator {
static getTotalRecordExpression(binding: string) { static getTotalRecordExpression(binding: string) {
return `${binding}[0].count`; return `${binding}[0].count`;
} }
static getConnectionMode(
datasourceConfiguration: DatasourceStorage["datasourceConfiguration"],
) {
return (
datasourceConfiguration?.connection?.mode ||
DatasourceConnectionMode.READ_WRITE
);
}
} }

View File

@ -2002,10 +2002,12 @@ export const IN_APP_EMBED_SETTING = {
"Make your app public to embed your Appsmith app into legacy applications", "Make your app public to embed your Appsmith app into legacy applications",
secondaryHeading: () => secondaryHeading: () =>
"Embedding in public mode is supported in the free plan. To make your app public, please contact your administrator.", "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: () => chromeExtensionBannerDescription: () =>
"Bring powerful AI assistance to the tools you and your teams use.", "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 = { export const APP_NAVIGATION_SETTING = {
@ -2691,3 +2693,6 @@ export const GOOGLE_RECAPTCHA_FAILED = () =>
"Google reCAPTCHA verification failed"; "Google reCAPTCHA verification failed";
export const PASSWORD_INSUFFICIENT_STRENGTH = () => export const PASSWORD_INSUFFICIENT_STRENGTH = () =>
"Insufficient password strength"; "Insufficient password strength";
export const PLUGIN_NOT_INSTALLED = () =>
"Upgrade your plan to unlock access to these integrations.";

View File

@ -45,34 +45,40 @@ export const useAddQueryListItems = () => {
); );
const getListItems = (data: ActionOperation[]) => { const getListItems = (data: ActionOperation[]) => {
return data.map((fileOperation) => { return data
let title = .map((fileOperation) => {
fileOperation.entityExplorerTitle || let title =
fileOperation.dsName || fileOperation.entityExplorerTitle ||
fileOperation.title; fileOperation.dsName ||
fileOperation.title;
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:
fileOperation.focusEntityType === FocusEntity.QUERY_MODULE_INSTANCE fileOperation.focusEntityType === FocusEntity.QUERY_MODULE_INSTANCE
? fileOperation.dsName ? fileOperation.title
: "", : title;
descriptionType: "inline", const className = createAddClassName(title);
onClick: onCreateItemClick.bind(null, fileOperation), const icon =
} as ListItemProps; 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 }; return { getListItems };

View File

@ -64,6 +64,9 @@ export const useFilteredFileOperations = ({
}: FilterFileOperationsProps) => { }: FilterFileOperationsProps) => {
const { appWideDS = [], otherDS = [] } = useAppWideAndOtherDatasource(); const { appWideDS = [], otherDS = [] } = useAppWideAndOtherDatasource();
const plugins = useSelector(getPlugins); const plugins = useSelector(getPlugins);
const pluginById = useMemo(() => {
return keyBy(plugins, "id");
}, [plugins]);
const moduleOptions = useModuleOptions(); const moduleOptions = useModuleOptions();
const workflowOptions = useWorkflowOptions(); const workflowOptions = useWorkflowOptions();
@ -106,7 +109,7 @@ export const useFilteredFileOperations = ({
return AiPlugin.id !== ds.pluginId; return AiPlugin.id !== ds.pluginId;
} }
return true; return !!pluginById[ds.pluginId]?.id;
}); });
return useFilteredAndSortedFileOperations({ return useFilteredAndSortedFileOperations({

View File

@ -43,7 +43,7 @@ import type { ExplorerURLParams } from "ee/pages/Editor/Explorer/helpers";
import { getLastSelectedWidget } from "selectors/ui"; import { getLastSelectedWidget } from "selectors/ui";
import AnalyticsUtil from "ee/utils/AnalyticsUtil"; import AnalyticsUtil from "ee/utils/AnalyticsUtil";
import useRecentEntities from "./useRecentEntities"; import useRecentEntities from "./useRecentEntities";
import { noop } from "lodash"; import { keyBy, noop } from "lodash";
import { import {
getCurrentPageId, getCurrentPageId,
getPagePermissions, getPagePermissions,
@ -179,6 +179,9 @@ function GlobalSearch() {
(state: AppState) => state.ui.globalSearch.filterContext.category, (state: AppState) => state.ui.globalSearch.filterContext.category,
); );
const plugins = useSelector(getPlugins); const plugins = useSelector(getPlugins);
const pluginById = useMemo(() => {
return keyBy(plugins, "id");
}, [plugins]);
const setCategory = useCallback( const setCategory = useCallback(
(category: SearchCategory) => { (category: SearchCategory) => {
dispatch(setGlobalSearchFilterContext({ category: category })); dispatch(setGlobalSearchFilterContext({ category: category }));
@ -233,10 +236,15 @@ function GlobalSearch() {
}, [basePageIdToPageIdMap, params?.basePageId, reducerDatasources]); }, [basePageIdToPageIdMap, params?.basePageId, reducerDatasources]);
const filteredDatasources = useMemo(() => { const filteredDatasources = useMemo(() => {
if (!query) return datasourcesList; if (!query)
return datasourcesList.filter(
(datasource) => pluginById[datasource.pluginId]?.id,
);
return datasourcesList.filter((datasource) => return datasourcesList.filter(
isMatching(datasource.name, query), (datasource) =>
isMatching(datasource.name, query) &&
pluginById[datasource.pluginId]?.id,
); );
}, [datasourcesList, query]); }, [datasourcesList, query]);
const recentEntities = useRecentEntities(); const recentEntities = useRecentEntities();

View File

@ -22,27 +22,42 @@ const IconContainer = styled.div`
align-items: center; align-items: center;
`; `;
const Label = styled.div` const LabelContainer = styled.div`
width: calc(100% - 40px); width: calc(100% - 40px);
display: flex;
flex-direction: column;
justify-content: center;
`;
const Label = styled.div`
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
`; `;
const SubText = styled.span`
font-size: 12px;
color: var(--ads-v2-color-fg-muted);
`;
interface Props { interface Props {
className?: string;
label?: JSX.Element | string; label?: JSX.Element | string;
leftIcon?: JSX.Element; leftIcon?: JSX.Element;
rightIcon?: JSX.Element; rightIcon?: JSX.Element;
className?: string; subText?: string;
} }
export function DropdownOption(props: Props) { export function DropdownOption(props: Props) {
const { className, label, leftIcon, rightIcon } = props; const { className, label, leftIcon, rightIcon, subText } = props;
return ( return (
<Container className={className}> <Container className={className}>
<LeftSection> <LeftSection>
{leftIcon && <IconContainer>{leftIcon}</IconContainer>} {leftIcon && <IconContainer>{leftIcon}</IconContainer>}
<Label>{label}</Label> <LabelContainer>
<Label>{label}</Label>
{subText && <SubText>{subText}</SubText>}
</LabelContainer>
</LeftSection> </LeftSection>
{rightIcon && <IconContainer>{rightIcon}</IconContainer>} {rightIcon && <IconContainer>{rightIcon}</IconContainer>}
</Container> </Container>

View File

@ -1,11 +1,19 @@
import React, { memo, useContext } from "react"; 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 { useTableOrSpreadsheet } from "./useTableOrSpreadsheet";
import { Select, Option, Tooltip } from "@appsmith/ads"; import { Select, Option, Tooltip } from "@appsmith/ads";
import { DropdownOption } from "../DatasourceDropdown/DropdownOption"; import { DropdownOption } from "../DatasourceDropdown/DropdownOption";
import type { DefaultOptionType } from "rc-select/lib/Select"; import type { DefaultOptionType } from "rc-select/lib/Select";
import { ColumnSelectorModal } from "../ColumnSelectorModal"; import { ColumnSelectorModal } from "../ColumnSelectorModal";
import { WidgetQueryGeneratorFormContext } from "components/editorComponents/WidgetQueryGeneratorForm/index"; 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() { function TableOrSpreadsheetDropdown() {
const { const {
@ -55,10 +63,18 @@ function TableOrSpreadsheetDropdown() {
return ( return (
<Option <Option
data-testid="t--one-click-binding-table-selector--table" data-testid="t--one-click-binding-table-selector--table"
disabled={option.disabled}
key={option.id} key={option.id}
value={option.value} value={option.value}
> >
<DropdownOption label={option.label} /> <DropdownOption
label={option.label}
subText={
option.disabled
? createMessage(NO_PRIMARY_KEYS_MESSAGE)
: undefined
}
/>
</Option> </Option>
); );
})} })}
@ -66,6 +82,10 @@ function TableOrSpreadsheetDropdown() {
<ErrorMessage data-testid="t--one-click-binding-table-selector--error"> <ErrorMessage data-testid="t--one-click-binding-table-selector--error">
{error} {error}
</ErrorMessage> </ErrorMessage>
<PrimaryKeysMessage>
{createMessage(PRIMARY_KEYS_MESSAGE)}
</PrimaryKeysMessage>
</SelectWrapper> </SelectWrapper>
); );
} else { } else {

View File

@ -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: <T extends (...args: unknown[]) => 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);
});
});

View File

@ -1,7 +1,5 @@
import React from "react";
import { fetchGheetSheets } from "actions/datasourceActions"; import { fetchGheetSheets } from "actions/datasourceActions";
import { useCallback, useContext, useMemo } from "react"; import type { AppState } from "ee/reducers";
import { useDispatch, useSelector } from "react-redux";
import { import {
getDatasource, getDatasource,
getDatasourceLoading, getDatasourceLoading,
@ -9,21 +7,25 @@ import {
getIsFetchingDatasourceStructure, getIsFetchingDatasourceStructure,
getPluginPackageFromDatasourceId, getPluginPackageFromDatasourceId,
} from "ee/selectors/entitiesSelector"; } from "ee/selectors/entitiesSelector";
import { WidgetQueryGeneratorFormContext } from "../.."; import AnalyticsUtil from "ee/utils/AnalyticsUtil";
import { Bold, Label } from "../../styles"; import type { DatasourceStructure, DatasourceTable } from "entities/Datasource";
import { PluginFormInputFieldMap } from "../../constants"; import React, { useCallback, useContext, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { getWidget } from "sagas/selectors";
import { import {
getGsheetSpreadsheets, getGsheetSpreadsheets,
getIsFetchingGsheetSpreadsheets, getIsFetchingGsheetSpreadsheets,
} from "selectors/datasourceSelectors"; } 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 { getisOneClickBindingConnectingForWidget } from "selectors/oneClickBindingSelectors";
import AnalyticsUtil from "ee/utils/AnalyticsUtil"; import {
import { getWidget } from "sagas/selectors"; isGoogleSheetPluginDS,
import type { DatasourceStructure } from "entities/Datasource"; 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() { export function useTableOrSpreadsheet() {
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -63,6 +65,10 @@ export function useTableOrSpreadsheet() {
).TABLE ).TABLE
: "table"; : "table";
const tableHasPrimaryKeys = (table: DatasourceTable) => {
return table.keys && table.keys.length > 0;
};
const options = useMemo(() => { const options = useMemo(() => {
if ( if (
isGoogleSheetPluginDS(selectedDatasourcePluginPackageName) && isGoogleSheetPluginDS(selectedDatasourcePluginPackageName) &&
@ -72,19 +78,35 @@ export function useTableOrSpreadsheet() {
id: value, id: value,
label: label, label: label,
value: label, value: label,
disabled: false,
data: { data: {
tableName: value, tableName: value,
}, },
})); }));
} else if (datasourceStructure) { } else if (isMongoDBPluginDS(selectedDatasourcePluginPackageName)) {
return (datasourceStructure.tables || []).map(({ name }) => ({ return (datasourceStructure.tables || []).map((table) => ({
id: name, id: table.name,
label: name, label: table.name,
value: name, value: table.name,
data: { 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 { } else {
return []; return [];
} }

View File

@ -39,3 +39,8 @@ export const PluginFormInputFieldMap: Record<
}; };
export const DEFAULT_QUERY_OPTIONS_COUNTS_TO_SHOW = 4; 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";

View File

@ -66,6 +66,13 @@ export const ErrorMessage = styled.div`
margin-top: 5px; 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` export const Placeholder = styled.div`
color: var(--ads-v2-color-fg-subtle); color: var(--ads-v2-color-fg-subtle);
`; `;

View File

@ -4,6 +4,7 @@ describe("ComputeTablePropertyControlV2.getInputComputedValue", () => {
const tableName = "Table1"; const tableName = "Table1";
const inputVariations = [ const inputVariations = [
"currentRow.price", "currentRow.price",
`JSObject1.somefunction(currentRow["id"] || 0)`,
` `
[ [
{ {
@ -58,4 +59,45 @@ describe("ComputeTablePropertyControlV2.getInputComputedValue", () => {
ComputeTablePropertyControlV2.getInputComputedValue(computedValue), ComputeTablePropertyControlV2.getInputComputedValue(computedValue),
).toBe(`{{currentRow.quantity}}{{5}}`); ).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}}}`);
});
}); });

View File

@ -161,25 +161,86 @@ class ComputeTablePropertyControlV2 extends BaseControl<ComputeTablePropertyCont
if (!isComputedValue) return propertyValue; if (!isComputedValue) return propertyValue;
// Check if the entire structure of the expression looks valid before attempting to parse
if (!this.isLikelyValidComputedValue(propertyValue)) {
return propertyValue;
}
// Extract the computation logic from the full binding string // Extract the computation logic from the full binding string
// Input example: "{{(() => { const tableData = Table1.processedTableData || []; return tableData.length > 0 ? tableData.map((currentRow, currentIndex) => (currentRow.price * 2)) : currentRow.price * 2 })()}}" // Input example: "{{(() => { 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); const mapSignatureIndex = propertyValue.indexOf(MAP_FUNCTION_SIGNATURE);
// Find the actual computation expression between the map parentheses // Find the actual computation expression between the map parentheses
const computationStart = mapSignatureIndex + MAP_FUNCTION_SIGNATURE.length; 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 // 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( const computationExpression = propertyValue.substring(
computationStart, computationStart,
computationEnd, endIndex,
); );
return JSToString(computationExpression); 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) => { getComputedValue = (value: string, tableName: string) => {
// Return raw value if: // Return raw value if:
// 1. The value is not a dynamic binding (not wrapped in {{...}}) // 1. The value is not a dynamic binding (not wrapped in {{...}})

View File

@ -28,6 +28,7 @@ import type { PluginType } from "entities/Plugin";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { useHeaderActions } from "ee/hooks/datasourceEditorHooks"; import { useHeaderActions } from "ee/hooks/datasourceEditorHooks";
import { getIDETypeByUrl } from "ee/entities/IDE/utils"; import { getIDETypeByUrl } from "ee/entities/IDE/utils";
import { getPlugin } from "ee/selectors/entitiesSelector";
export const ActionWrapper = styled.div` export const ActionWrapper = styled.div`
display: flex; display: flex;
@ -118,6 +119,10 @@ export const DSFormHeader = (props: DSFormHeaderProps) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const location = useLocation(); const location = useLocation();
const ideType = getIDETypeByUrl(location.pathname); const ideType = getIDETypeByUrl(location.pathname);
const plugin = useSelector((state) =>
getPlugin(state, datasource?.pluginId || ""),
);
const isEditDisabled = !plugin?.id;
const deleteAction = () => { const deleteAction = () => {
if (isDeleting) return; if (isDeleting) return;
@ -210,8 +215,11 @@ export const DSFormHeader = (props: DSFormHeaderProps) => {
)} )}
<Button <Button
className="t--edit-datasource" className="t--edit-datasource"
isDisabled={isEditDisabled}
kind="secondary" kind="secondary"
onClick={() => { onClick={() => {
if (isEditDisabled) return;
setDatasourceViewMode({ setDatasourceViewMode({
datasourceId: datasourceId, datasourceId: datasourceId,
viewMode: false, viewMode: false,

View File

@ -24,7 +24,7 @@ import { getCurrentPageId, getPageList } from "selectors/editorSelectors";
import type { Datasource } from "entities/Datasource"; import type { Datasource } from "entities/Datasource";
import type { EventLocation } from "ee/utils/analyticsUtilTypes"; import type { EventLocation } from "ee/utils/analyticsUtilTypes";
import { getCurrentEnvironmentId } from "ee/selectors/environmentSelectors"; import { getCurrentEnvironmentId } from "ee/selectors/environmentSelectors";
import { getSelectedTableName } from "ee/selectors/entitiesSelector"; import { getPlugin, getSelectedTableName } from "ee/selectors/entitiesSelector";
interface NewActionButtonProps { interface NewActionButtonProps {
datasource?: Datasource; datasource?: Datasource;
@ -75,6 +75,11 @@ function NewActionButton(props: NewActionButtonProps) {
...pages.filter((p) => p.pageId !== currentPageId), ...pages.filter((p) => p.pageId !== currentPageId),
]; ];
const queryDefaultTableName = useSelector(getSelectedTableName); const queryDefaultTableName = useSelector(getSelectedTableName);
const plugin = useSelector((state) =>
getPlugin(state, datasource?.pluginId || ""),
);
const isDisabled = !!disabled || !plugin?.id;
const createQueryAction = useCallback( const createQueryAction = useCallback(
(pageId: string) => { (pageId: string) => {
@ -106,7 +111,7 @@ function NewActionButton(props: NewActionButtonProps) {
const handleOnInteraction = useCallback( const handleOnInteraction = useCallback(
(open: boolean) => { (open: boolean) => {
if (disabled || isLoading) return; if (isDisabled || isLoading) return;
if (!open) { if (!open) {
setIsPageSelectionOpen(false); setIsPageSelectionOpen(false);
@ -122,7 +127,7 @@ function NewActionButton(props: NewActionButtonProps) {
setIsPageSelectionOpen(true); setIsPageSelectionOpen(true);
}, },
[pages, createQueryAction, disabled, isLoading], [pages, createQueryAction, isDisabled, isLoading],
); );
const getCreateButtonText = () => { const getCreateButtonText = () => {
@ -139,11 +144,11 @@ function NewActionButton(props: NewActionButtonProps) {
return ( return (
<Menu onOpenChange={handleOnInteraction} open={isPageSelectionOpen}> <Menu onOpenChange={handleOnInteraction} open={isPageSelectionOpen}>
<MenuTrigger disabled={disabled}> <MenuTrigger disabled={isDisabled}>
<Button <Button
className="t--create-query" className="t--create-query"
id={"create-query"} id={"create-query"}
isDisabled={!!disabled} isDisabled={isDisabled}
isLoading={isSelected || isLoading} isLoading={isSelected || isLoading}
kind={isNewQuerySecondaryButton ? "secondary" : "primary"} kind={isNewQuerySecondaryButton ? "secondary" : "primary"}
onClick={() => handleOnInteraction(true)} onClick={() => handleOnInteraction(true)}

View File

@ -7,6 +7,8 @@ import LeftSideContent from "./LeftSideContent";
import { getAppsmithConfigs } from "ee/configs"; import { getAppsmithConfigs } from "ee/configs";
import { useIsMobileDevice } from "utils/hooks/useDeviceDetect"; import { useIsMobileDevice } from "utils/hooks/useDeviceDetect";
import styled from "styled-components"; import styled from "styled-components";
import { getIsAiAgentFlowEnabled } from "ee/selectors/aiAgentSelectors";
import clsx from "clsx";
interface ContainerProps { interface ContainerProps {
title: string; title: string;
@ -43,10 +45,15 @@ function Container(props: ContainerProps) {
const organizationConfig = useSelector(getOrganizationConfig); const organizationConfig = useSelector(getOrganizationConfig);
const { cloudHosting } = getAppsmithConfigs(); const { cloudHosting } = getAppsmithConfigs();
const isMobileDevice = useIsMobileDevice(); const isMobileDevice = useIsMobileDevice();
const isAiAgentFlowEnabled = useSelector(getIsAiAgentFlowEnabled);
return ( return (
<ContainerWrapper <ContainerWrapper
className={`gap-14 my-auto flex items-center justify-center min-w-min`} className={clsx({
"my-auto flex items-center justify-center min-w-min": true,
"flex-col-reverse gap-4": isAiAgentFlowEnabled,
"flex-row gap-14": !isAiAgentFlowEnabled,
})}
data-testid={testId} data-testid={testId}
> >
{cloudHosting && !isMobileDevice && <LeftSideContent />} {cloudHosting && !isMobileDevice && <LeftSideContent />}

View File

@ -71,33 +71,30 @@ const QUOTE = {
authorImage: `${getAssetUrl(`${ASSETS_CDN_URL}/thomas-zwick.png`)}`, authorImage: `${getAssetUrl(`${ASSETS_CDN_URL}/thomas-zwick.png`)}`,
}; };
const QUOTE_FOR_AGENTS = {
quote: `Our goal was to have an omni-channel AI system that could help our usersin every step of the journey. Appsmith serves as a command center for us to control the behavior of the agent. This is a competitive advantage. We're able to serve our customers much faster than our competitors`,
author: "Shawn Lim",
authorTitle: "VP, Platform & AI, Funding Societies",
authorImage: "https://assets.appsmith.com/fundingsocieties-logo.svg",
};
function LeftSideContent() { function LeftSideContent() {
const isAiAgentFlowEnabled = useSelector(getIsAiAgentFlowEnabled); const isAiAgentFlowEnabled = useSelector(getIsAiAgentFlowEnabled);
const quote = isAiAgentFlowEnabled ? QUOTE_FOR_AGENTS : QUOTE;
return ( return (
<Wrapper> <Wrapper>
<div className="left-description"> {!isAiAgentFlowEnabled && (
<div className="left-description-container"> <div className="left-description">
&quot;{quote.quote}&quot; <div className="left-description-container">
&quot;{QUOTE.quote}&quot;
</div>
<div className="left-description-author">
{QUOTE.authorImage && (
<Avatar
image={QUOTE.authorImage}
label={QUOTE.author}
size="sm"
/>
)}
<div>{QUOTE.author}</div>
<div className="dot">&#183;</div>
<div>{QUOTE.authorTitle}</div>
</div>
</div> </div>
<div className="left-description-author"> )}
{quote.authorImage && (
<Avatar image={quote.authorImage} label={quote.author} size="sm" />
)}
<div>{quote.author}</div>
<div className="dot">&#183;</div>
<div>{quote.authorTitle}</div>
</div>
</div>
<div className="client-logo-container"> <div className="client-logo-container">
<div className="client-heading"> <div className="client-heading">

View File

@ -168,6 +168,10 @@ export function isGoogleSheetPluginDS(pluginPackageName?: string) {
return pluginPackageName === PluginPackageName.GOOGLE_SHEETS; return pluginPackageName === PluginPackageName.GOOGLE_SHEETS;
} }
export function isMongoDBPluginDS(pluginPackageName?: string) {
return pluginPackageName === PluginPackageName.MONGO;
}
/** /**
* Returns datasource property value from datasource?.datasourceConfiguration?.properties * Returns datasource property value from datasource?.datasourceConfiguration?.properties
* @param datasource Datasource * @param datasource Datasource

View File

@ -213,4 +213,24 @@ public class WidgetRefactorUtil {
throw new AppsmithException(AppsmithError.JSON_PROCESSING_ERROR); throw new AppsmithException(AppsmithError.JSON_PROCESSING_ERROR);
} }
} }
public Set<String> extractWidgetNamesFromDsl(JsonNode dsl) {
Set<String> widgetNames = new HashSet<>();
extractWidgetNamesRecursive(dsl, widgetNames);
return widgetNames;
}
private void extractWidgetNamesRecursive(JsonNode dsl, Set<String> widgetNames) {
if (dsl == null) {
return;
}
if (dsl.has(FieldName.WIDGET_NAME)) {
widgetNames.add(dsl.get(FieldName.WIDGET_NAME).asText());
}
if (dsl.has("children")) {
dsl.get("children").forEach(child -> extractWidgetNamesRecursive(child, widgetNames));
}
}
} }