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
# 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

View File

@ -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) => {
<Styled.Text
aria-invalid={Boolean(validationError)}
className={clsx("t--entity-name", { editing: inEditMode })}
color={hasError ? "var(--ads-v2-color-fg-error)" : undefined}
data-isediting={inEditMode}
data-isfixedwidth={isFixedWidth}
inputProps={inputProps}

View File

@ -27,4 +27,6 @@ export interface EditableEntityNameProps {
normalizeName?: boolean;
/** Used for showing ellipsis for longer names */
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 (
<EditableEntityName
canEdit={canEdit}
hasError={props.hasError}
icon={startIcon}
isEditing={isEditing}
isFixedWidth
@ -50,6 +51,7 @@ export const EntityItem = (props: EntityItemProps) => {
normalizeName,
onEditComplete,
onNameSave,
props.hasError,
props.title,
startIcon,
validateName,

View File

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

View File

@ -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 (
<CenteredWrapper>
<CenteredWrapper className="flex-col">
<Text color="var(--ads-v2-color-fg-error)" kind="heading-m">
Plugin not installed!
{createMessage(PLUGIN_NOT_INSTALLED)}
</Text>
<ShowUpgradeMenuItem />
</CenteredWrapper>
);
}

View File

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

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

View File

@ -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.";

View File

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

View File

@ -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({

View File

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

View File

@ -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 (
<Container className={className}>
<LeftSection>
{leftIcon && <IconContainer>{leftIcon}</IconContainer>}
<Label>{label}</Label>
<LabelContainer>
<Label>{label}</Label>
{subText && <SubText>{subText}</SubText>}
</LabelContainer>
</LeftSection>
{rightIcon && <IconContainer>{rightIcon}</IconContainer>}
</Container>

View File

@ -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 (
<Option
data-testid="t--one-click-binding-table-selector--table"
disabled={option.disabled}
key={option.id}
value={option.value}
>
<DropdownOption label={option.label} />
<DropdownOption
label={option.label}
subText={
option.disabled
? createMessage(NO_PRIMARY_KEYS_MESSAGE)
: undefined
}
/>
</Option>
);
})}
@ -66,6 +82,10 @@ function TableOrSpreadsheetDropdown() {
<ErrorMessage data-testid="t--one-click-binding-table-selector--error">
{error}
</ErrorMessage>
<PrimaryKeysMessage>
{createMessage(PRIMARY_KEYS_MESSAGE)}
</PrimaryKeysMessage>
</SelectWrapper>
);
} 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 { 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 [];
}

View File

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

View File

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

View File

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

View File

@ -161,25 +161,86 @@ class ComputeTablePropertyControlV2 extends BaseControl<ComputeTablePropertyCont
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
// 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);
// 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 {{...}})

View File

@ -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) => {
)}
<Button
className="t--edit-datasource"
isDisabled={isEditDisabled}
kind="secondary"
onClick={() => {
if (isEditDisabled) return;
setDatasourceViewMode({
datasourceId: datasourceId,
viewMode: false,

View File

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

View File

@ -7,6 +7,8 @@ import LeftSideContent from "./LeftSideContent";
import { getAppsmithConfigs } from "ee/configs";
import { useIsMobileDevice } from "utils/hooks/useDeviceDetect";
import styled from "styled-components";
import { getIsAiAgentFlowEnabled } from "ee/selectors/aiAgentSelectors";
import clsx from "clsx";
interface ContainerProps {
title: string;
@ -43,10 +45,15 @@ function Container(props: ContainerProps) {
const organizationConfig = useSelector(getOrganizationConfig);
const { cloudHosting } = getAppsmithConfigs();
const isMobileDevice = useIsMobileDevice();
const isAiAgentFlowEnabled = useSelector(getIsAiAgentFlowEnabled);
return (
<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}
>
{cloudHosting && !isMobileDevice && <LeftSideContent />}

View File

@ -71,33 +71,30 @@ const QUOTE = {
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() {
const isAiAgentFlowEnabled = useSelector(getIsAiAgentFlowEnabled);
const quote = isAiAgentFlowEnabled ? QUOTE_FOR_AGENTS : QUOTE;
return (
<Wrapper>
<div className="left-description">
<div className="left-description-container">
&quot;{quote.quote}&quot;
{!isAiAgentFlowEnabled && (
<div className="left-description">
<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 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-heading">

View File

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

View File

@ -213,4 +213,24 @@ public class WidgetRefactorUtil {
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));
}
}
}