feat: Remove hardcoding of upcoming integrations from client codebase #40047 (#40271)

This commit is contained in:
vivek-appsmith 2025-04-17 20:11:06 +05:30 committed by GitHub
parent a94c75b868
commit c8a132f88d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 216 additions and 54 deletions

View File

@ -84,3 +84,7 @@ export const fetchPluginFormConfig = ({
export const fetchDefaultPlugins = (): ReduxActionWithoutPayload => ({
type: ReduxActionTypes.GET_DEFAULT_PLUGINS_REQUEST,
});
export const fetchUpcomingPlugins = (): ReduxActionWithoutPayload => ({
type: ReduxActionTypes.GET_UPCOMING_PLUGINS_REQUEST,
});

View File

@ -3,7 +3,12 @@ import type { AxiosPromise } from "axios";
import type { ApiResponse } from "api/ApiResponses";
import type { DependencyMap } from "utils/DynamicBindingUtils";
import { FILE_UPLOAD_TRIGGER_TIMEOUT_MS } from "ee/constants/ApiConstants";
import type { DefaultPlugin, Plugin } from "entities/Plugin";
import type {
DefaultPlugin,
Plugin,
UpcomingIntegration,
} from "entities/Plugin";
import { objectKeys } from "@appsmith/utils";
export interface PluginFormPayload {
// TODO: Fix this the next time the file is edited
@ -55,6 +60,12 @@ class PluginsApi extends Api {
return Api.get(PluginsApi.url + `/default/icons`);
}
static async fetchUpcomingIntegrations(): Promise<
AxiosPromise<ApiResponse<UpcomingIntegration[]>>
> {
return Api.get(PluginsApi.url + "/upcoming-integrations");
}
static async uploadFiles(
pluginId: string,
files: File[],
@ -70,7 +81,7 @@ class PluginsApi extends Api {
});
if (params) {
Object.keys(params).forEach((key) => {
objectKeys(params).forEach((key) => {
formData.append(key, params[key]);
});
}

View File

@ -0,0 +1,73 @@
import PluginsApi from "api/PluginApi";
import Api from "api/Api";
import type { UpcomingIntegration } from "entities/Plugin";
// Mock the Api module with a class that can be extended
jest.mock("api/Api", () => {
return {
// Export a class that can be extended
__esModule: true,
default: class MockApi {
static get: jest.Mock = jest.fn();
},
};
});
describe("PluginsApi", () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe("fetchUpcomingIntegrations", () => {
it("should call the correct API endpoint", async () => {
// Setup mock API response
const mockResponse = {
data: {
responseMeta: {
success: true,
},
data: [
{
name: "Test Integration",
iconLocation: "test-icon-location",
},
{
name: "Another Test",
iconLocation: "another-test-icon",
},
] as UpcomingIntegration[],
},
};
(Api.get as jest.Mock).mockResolvedValue(mockResponse);
// Call the function
const result = await PluginsApi.fetchUpcomingIntegrations();
// Verify API was called correctly
expect(Api.get).toHaveBeenCalledWith(
PluginsApi.url + "/upcoming-integrations",
);
// Verify response matches mock
expect(result).toEqual(mockResponse);
});
it("should handle API errors", async () => {
// Setup mock API to throw error
const mockError = new Error("API error");
(Api.get as jest.Mock).mockRejectedValue(mockError);
// Call the function and expect it to throw
await expect(PluginsApi.fetchUpcomingIntegrations()).rejects.toThrow(
mockError,
);
// Verify API was called
expect(Api.get).toHaveBeenCalledWith(
PluginsApi.url + "/upcoming-integrations",
);
});
});
});

View File

@ -751,12 +751,15 @@ const PluginActionTypes = {
GET_PLUGIN_FORM_CONFIG_INIT: "GET_PLUGIN_FORM_CONFIG_INIT",
GET_DEFAULT_PLUGINS_REQUEST: "GET_DEFAULT_PLUGINS_REQUEST",
GET_DEFAULT_PLUGINS_SUCCESS: "GET_DEFAULT_PLUGINS_SUCCESS",
GET_UPCOMING_PLUGINS_REQUEST: "GET_UPCOMING_PLUGINS_REQUEST",
GET_UPCOMING_PLUGINS_SUCCESS: "GET_UPCOMING_PLUGINS_SUCCESS",
};
const PluginActionErrorTypes = {
FETCH_PLUGINS_ERROR: "FETCH_PLUGINS_ERROR",
FETCH_PLUGIN_FORM_CONFIGS_ERROR: "FETCH_PLUGIN_FORM_CONFIGS_ERROR",
FETCH_PLUGIN_FORM_ERROR: "FETCH_PLUGIN_FORM_ERROR",
GET_DEFAULT_PLUGINS_ERROR: "GET_DEFAULT_PLUGINS_ERROR",
GET_UPCOMING_PLUGINS_ERROR: "GET_UPCOMING_PLUGINS_ERROR",
};
const UQIFormActionTypes = {

View File

@ -8,7 +8,7 @@ import type { ExplorerURLParams } from "ee/pages/Editor/Explorer/helpers";
import type { DependentFeatureFlags } from "ee/selectors/engineSelectors";
import { fetchDatasources } from "actions/datasourceActions";
import { fetchPageDSLs } from "actions/pageActions";
import { fetchPlugins } from "actions/pluginActions";
import { fetchPlugins, fetchUpcomingPlugins } from "actions/pluginActions";
import type { Plugin } from "entities/Plugin";
import { useSelector } from "react-redux";
import { useParams } from "react-router";
@ -37,6 +37,7 @@ export const getPageDependencyActions = (
fetchPlugins({ plugins }),
fetchDatasources({ datasources }),
fetchPageDSLs({ pagesWithMigratedDsl }),
fetchUpcomingPlugins(), // Not adding success and error actions for this as it's not a blocker for the app to load
] as Array<ReduxAction<unknown>>;
const successActions = [

View File

@ -22,6 +22,10 @@ const defaultStoreState = {
...unitTestBaseMockStore.entities,
plugins: {
list: [],
upcomingPlugins: {
list: [],
loading: false,
},
},
datasources: {
list: [],

View File

@ -1799,3 +1799,8 @@ export const getJSCollectionActionSchemaDirtyState = createSelector(
return action.isDirtyMap?.SCHEMA_GENERATION;
},
);
export const getUpcomingPlugins = createSelector(
(state: AppState) => state.entities.plugins.upcomingPlugins,
(upcomingPlugins) => upcomingPlugins.list,
);

View File

@ -91,3 +91,8 @@ export interface DefaultPlugin {
iconLocation?: string;
allowUserDatasources?: boolean;
}
export interface UpcomingIntegration {
name: string;
iconLocation: string;
}

View File

@ -11,11 +11,13 @@ import {
type Plugin,
PluginPackageName,
PluginType,
type UpcomingIntegration,
} from "entities/Plugin";
import { getQueryParams } from "utils/URLUtils";
import {
getGenerateCRUDEnabledPluginMap,
getPlugins,
getUpcomingPlugins,
} from "ee/selectors/entitiesSelector";
import { getIsGeneratePageInitiator } from "utils/GenerateCrudUtil";
import { getAssetUrl, isAirgapped } from "ee/utils/airgapHelpers";
@ -43,10 +45,7 @@ import {
import scrollIntoView from "scroll-into-view-if-needed";
import PremiumDatasources from "./PremiumDatasources";
import { pluginSearchSelector } from "./CreateNewDatasourceHeader";
import {
getFilteredPremiumIntegrations,
type PremiumIntegration,
} from "./PremiumDatasources/Constants";
import { getFilteredUpcomingIntegrations } from "./PremiumDatasources/Constants";
import { getDatasourcesLoadingState } from "selectors/datasourceSelectors";
import { getIDETypeByUrl } from "ee/entities/IDE/utils";
import type { IDEType } from "ee/IDE/Interfaces/IDETypes";
@ -75,7 +74,7 @@ interface CreateAPIOrSaasPluginsProps {
apiType: string,
) => void;
isPremiumDatasourcesViewEnabled?: boolean;
premiumPlugins: PremiumIntegration[];
upcomingIntegrations: UpcomingIntegration[];
authApiPlugin?: Plugin;
restAPIVisible?: boolean;
graphQLAPIVisible?: boolean;
@ -235,7 +234,7 @@ function APIOrSaasPlugins(props: CreateAPIOrSaasPluginsProps) {
/>
))}
{!props.isIntegrationsEnabledForPaid && (
<PremiumDatasources plugins={props.premiumPlugins} />
<PremiumDatasources plugins={props.upcomingIntegrations} />
)}
</DatasourceContainer>
);
@ -263,7 +262,7 @@ function CreateAPIOrSaasPlugins(props: CreateAPIOrSaasPluginsProps) {
if (isAirgappedInstance && props.showSaasAPIs) return null;
if (
props.premiumPlugins.length === 0 &&
props.upcomingIntegrations.length === 0 &&
props.plugins.length === 0 &&
!props.restAPIVisible &&
!props.graphQLAPIVisible
@ -281,7 +280,8 @@ function CreateAPIOrSaasPlugins(props: CreateAPIOrSaasPluginsProps) {
</DatasourceSectionHeading>
<APIOrSaasPlugins {...props} />
</DatasourceSection>
{props.premiumPlugins.length > 0 && props.isIntegrationsEnabledForPaid ? (
{props.upcomingIntegrations.length > 0 &&
props.isIntegrationsEnabledForPaid ? (
<DatasourceSection id="upcoming-saas-integrations">
<DatasourceSectionHeading kind="heading-m">
{createMessage(UPCOMING_SAAS_INTEGRATIONS)}
@ -289,7 +289,7 @@ function CreateAPIOrSaasPlugins(props: CreateAPIOrSaasPluginsProps) {
<DatasourceContainer data-testid="upcoming-datasource-card-container">
<PremiumDatasources
isIntegrationsEnabledForPaid
plugins={props.premiumPlugins}
plugins={props.upcomingIntegrations}
/>
</DatasourceContainer>
</DatasourceSection>
@ -310,6 +310,8 @@ const mapStateToProps = (
pluginSearchSelector(state, "search") || ""
).toLocaleLowerCase();
const upcomingPlugins = getUpcomingPlugins(state);
const allPlugins = getPlugins(state);
let plugins = allPlugins.filter((p) =>
@ -354,15 +356,16 @@ const mapStateToProps = (
plugin.name.toLocaleLowerCase(),
);
const premiumPlugins =
const upcomingIntegrations =
props.showSaasAPIs && props.isPremiumDatasourcesViewEnabled
? (filterSearch(
getFilteredPremiumIntegrations(
getFilteredUpcomingIntegrations(
isExternalSaasEnabled || isIntegrationsEnabledForPaid,
pluginNames,
upcomingPlugins,
),
searchedPlugin,
) as PremiumIntegration[])
) as UpcomingIntegration[])
: [];
const restAPIVisible =
@ -380,7 +383,7 @@ const mapStateToProps = (
return {
plugins,
premiumPlugins,
upcomingIntegrations,
authApiPlugin,
restAPIVisible,
graphQLAPIVisible,

View File

@ -10,8 +10,8 @@ import {
} from "ee/constants/messages";
import { useSelector } from "react-redux";
import { pluginSearchSelector } from "./CreateNewDatasourceHeader";
import { getPlugins } from "ee/selectors/entitiesSelector";
import { getFilteredPremiumIntegrations } from "./PremiumDatasources/Constants";
import { getPlugins, getUpcomingPlugins } from "ee/selectors/entitiesSelector";
import { getFilteredUpcomingIntegrations } from "./PremiumDatasources/Constants";
import styled from "styled-components";
import { filterSearch } from "./util";
import type { MockDatasource } from "entities/Datasource";
@ -34,6 +34,8 @@ export default function EmptySearchedPlugins({
pluginSearchSelector(state, "search"),
);
const upcomingPlugins = useSelector(getUpcomingPlugins);
searchedPlugin = (searchedPlugin || "").toLocaleLowerCase();
const plugins = useSelector(getPlugins);
@ -59,9 +61,10 @@ export default function EmptySearchedPlugins({
{ name: createMessage(CREATE_NEW_DATASOURCE_AUTHENTICATED_REST_API) },
...mockDatasources,
...(isPremiumDatasourcesViewEnabled
? getFilteredPremiumIntegrations(
? getFilteredUpcomingIntegrations(
isExternalSaasEnabled || isIntegrationsEnabledForPaid,
pluginNames,
upcomingPlugins,
)
: []),
],

View File

@ -1,40 +1,24 @@
import { getAssetUrl } from "ee/utils/airgapHelpers";
import { ASSETS_CDN_URL } from "../../../../constants/ThirdPartyConstants";
import type { UpcomingIntegration } from "entities/Plugin";
export interface PremiumIntegration {
name: string;
icon: string;
}
const PREMIUM_INTEGRATIONS: PremiumIntegration[] = [
{
name: "Zendesk",
icon: getAssetUrl(`${ASSETS_CDN_URL}/zendesk-icon.png`),
},
{
name: "Salesforce",
icon: getAssetUrl(`${ASSETS_CDN_URL}/salesforce-image.png`),
},
{
name: "Slack",
icon: getAssetUrl(`${ASSETS_CDN_URL}/slack.png`),
},
{
name: "Jira",
icon: getAssetUrl(`${ASSETS_CDN_URL}/jira.png`),
},
];
export const getFilteredPremiumIntegrations = (
/**
* Filters upcoming integrations based on available plugins.
* Returns cached integrations synchronously.
*
* @param isExternalSaasEnabled Whether external SaaS integrations are enabled
* @param pluginNames List of installed plugin names (lowercase)
* @returns Filtered list of upcoming integrations
*/
export const getFilteredUpcomingIntegrations = (
isExternalSaasEnabled: boolean,
pluginNames: string[],
) => {
upcomingPlugins: UpcomingIntegration[],
): UpcomingIntegration[] => {
return isExternalSaasEnabled
? PREMIUM_INTEGRATIONS.filter(
? upcomingPlugins.filter(
(integration) =>
!pluginNames.includes(integration.name.toLocaleLowerCase()),
)
: PREMIUM_INTEGRATIONS;
: upcomingPlugins;
};
export const PREMIUM_INTEGRATION_CONTACT_FORM =

View File

@ -5,9 +5,9 @@ import styled from "styled-components";
import ContactForm from "./ContactForm";
import { handlePremiumDatasourceClick } from "./Helpers";
import DatasourceItem from "../DatasourceItem";
import type { PremiumIntegration } from "./Constants";
import { createMessage } from "ee/constants/messages";
import { PREMIUM_DATASOURCES } from "ee/constants/messages";
import type { UpcomingIntegration } from "entities/Plugin";
const ModalContentWrapper = styled(ModalContent)`
max-width: 518px;
@ -26,7 +26,7 @@ const PremiumTag = styled(Tag)`
`;
export default function PremiumDatasources(props: {
plugins: PremiumIntegration[];
plugins: UpcomingIntegration[];
isIntegrationsEnabledForPaid?: boolean;
}) {
const [selectedIntegration, setSelectedIntegration] = useState<string>("");
@ -49,7 +49,7 @@ export default function PremiumDatasources(props: {
handleOnClick={() => {
handleOnClick(integration.name);
}}
icon={getAssetUrl(integration.icon)}
icon={getAssetUrl(integration.iconLocation)}
key={integration.name}
name={integration.name}
rightSibling={

View File

@ -4,7 +4,11 @@ import {
ReduxActionTypes,
ReduxActionErrorTypes,
} from "ee/constants/ReduxActionConstants";
import type { DefaultPlugin, Plugin } from "entities/Plugin";
import type {
DefaultPlugin,
Plugin,
UpcomingIntegration,
} from "entities/Plugin";
import type {
PluginFormPayloadWithId,
PluginFormsPayload,
@ -30,6 +34,10 @@ export interface PluginDataState {
datasourceFormButtonConfigs: FormDatasourceButtonConfigs;
fetchingSinglePluginForm: Record<string, boolean>;
fetchingDefaultPlugins: boolean;
upcomingPlugins: {
list: UpcomingIntegration[];
loading: boolean;
};
}
const initialState: PluginDataState = {
@ -43,6 +51,10 @@ const initialState: PluginDataState = {
dependencies: {},
fetchingSinglePluginForm: {},
fetchingDefaultPlugins: false,
upcomingPlugins: {
list: [],
loading: false,
},
};
const pluginsReducer = createReducer(initialState, {
@ -142,6 +154,33 @@ const pluginsReducer = createReducer(initialState, {
defaultPluginList: action.payload,
};
},
[ReduxActionTypes.GET_UPCOMING_PLUGINS_REQUEST]: (state: PluginDataState) => {
return {
...state,
upcomingPlugins: { ...state.upcomingPlugins, loading: true },
};
},
[ReduxActionTypes.GET_UPCOMING_PLUGINS_SUCCESS]: (
state: PluginDataState,
action: ReduxAction<UpcomingIntegration[]>,
) => {
return {
...state,
upcomingPlugins: {
...state.upcomingPlugins,
loading: false,
list: action.payload,
},
};
},
[ReduxActionErrorTypes.GET_UPCOMING_PLUGINS_ERROR]: (
state: PluginDataState,
) => {
return {
...state,
upcomingPlugins: { ...state.upcomingPlugins, loading: false },
};
},
});
export default pluginsReducer;

View File

@ -31,7 +31,12 @@ import type { ApiResponse } from "api/ApiResponses";
import PluginApi from "api/PluginApi";
import log from "loglevel";
import { getAppsmithAIPlugin, getGraphQLPlugin } from "entities/Action";
import { type DefaultPlugin, type Plugin, PluginType } from "entities/Plugin";
import {
type DefaultPlugin,
type Plugin,
PluginType,
type UpcomingIntegration,
} from "entities/Plugin";
import type {
FormEditorConfigs,
FormSettingsConfigs,
@ -305,6 +310,24 @@ function* getDefaultPluginsSaga() {
}
}
function* getUpcomingPluginsSaga() {
try {
const response: ApiResponse<UpcomingIntegration[]> = yield call(
PluginsApi.fetchUpcomingIntegrations,
);
yield put({
type: ReduxActionTypes.GET_UPCOMING_PLUGINS_SUCCESS,
payload: response.data || [],
});
} catch (error) {
yield put({
type: ReduxActionErrorTypes.GET_UPCOMING_PLUGINS_ERROR,
payload: { error },
});
}
}
function* root() {
yield all([
takeEvery(ReduxActionTypes.FETCH_PLUGINS_REQUEST, fetchPluginsSaga),
@ -320,6 +343,10 @@ function* root() {
ReduxActionTypes.GET_DEFAULT_PLUGINS_REQUEST,
getDefaultPluginsSaga,
),
takeEvery(
ReduxActionTypes.GET_UPCOMING_PLUGINS_REQUEST,
getUpcomingPluginsSaga,
),
]);
}