diff --git a/app/client/src/actions/pluginActions.ts b/app/client/src/actions/pluginActions.ts index 6de49c2b2c..655919a305 100644 --- a/app/client/src/actions/pluginActions.ts +++ b/app/client/src/actions/pluginActions.ts @@ -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, +}); diff --git a/app/client/src/api/PluginApi.ts b/app/client/src/api/PluginApi.ts index 690314ce49..391a0acf69 100644 --- a/app/client/src/api/PluginApi.ts +++ b/app/client/src/api/PluginApi.ts @@ -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> + > { + 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]); }); } diff --git a/app/client/src/api/__tests__/PluginApi.test.ts b/app/client/src/api/__tests__/PluginApi.test.ts new file mode 100644 index 0000000000..8d87c4fa74 --- /dev/null +++ b/app/client/src/api/__tests__/PluginApi.test.ts @@ -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", + ); + }); + }); +}); diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx index e4e571d0d5..57ad73c96b 100644 --- a/app/client/src/ce/constants/ReduxActionConstants.tsx +++ b/app/client/src/ce/constants/ReduxActionConstants.tsx @@ -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 = { diff --git a/app/client/src/ce/entities/Engine/actionHelpers.ts b/app/client/src/ce/entities/Engine/actionHelpers.ts index 87636eea3b..41498790f0 100644 --- a/app/client/src/ce/entities/Engine/actionHelpers.ts +++ b/app/client/src/ce/entities/Engine/actionHelpers.ts @@ -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>; const successActions = [ diff --git a/app/client/src/ce/pages/Applications/CreateNewAppsOption.test.tsx b/app/client/src/ce/pages/Applications/CreateNewAppsOption.test.tsx index b80fdc7c7b..c1135ad7f6 100644 --- a/app/client/src/ce/pages/Applications/CreateNewAppsOption.test.tsx +++ b/app/client/src/ce/pages/Applications/CreateNewAppsOption.test.tsx @@ -22,6 +22,10 @@ const defaultStoreState = { ...unitTestBaseMockStore.entities, plugins: { list: [], + upcomingPlugins: { + list: [], + loading: false, + }, }, datasources: { list: [], diff --git a/app/client/src/ce/selectors/entitiesSelector.ts b/app/client/src/ce/selectors/entitiesSelector.ts index 3055955c4e..d565cded45 100644 --- a/app/client/src/ce/selectors/entitiesSelector.ts +++ b/app/client/src/ce/selectors/entitiesSelector.ts @@ -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, +); diff --git a/app/client/src/entities/Plugin/index.ts b/app/client/src/entities/Plugin/index.ts index 1c807fea6f..93ad7cfb04 100644 --- a/app/client/src/entities/Plugin/index.ts +++ b/app/client/src/entities/Plugin/index.ts @@ -91,3 +91,8 @@ export interface DefaultPlugin { iconLocation?: string; allowUserDatasources?: boolean; } + +export interface UpcomingIntegration { + name: string; + iconLocation: string; +} diff --git a/app/client/src/pages/Editor/IntegrationEditor/APIOrSaasPlugins.tsx b/app/client/src/pages/Editor/IntegrationEditor/APIOrSaasPlugins.tsx index 4ac31c26be..932fdbce8b 100644 --- a/app/client/src/pages/Editor/IntegrationEditor/APIOrSaasPlugins.tsx +++ b/app/client/src/pages/Editor/IntegrationEditor/APIOrSaasPlugins.tsx @@ -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 && ( - + )} ); @@ -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) { - {props.premiumPlugins.length > 0 && props.isIntegrationsEnabledForPaid ? ( + {props.upcomingIntegrations.length > 0 && + props.isIntegrationsEnabledForPaid ? ( {createMessage(UPCOMING_SAAS_INTEGRATIONS)} @@ -289,7 +289,7 @@ function CreateAPIOrSaasPlugins(props: CreateAPIOrSaasPluginsProps) { @@ -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, diff --git a/app/client/src/pages/Editor/IntegrationEditor/EmptySearchedPlugins.tsx b/app/client/src/pages/Editor/IntegrationEditor/EmptySearchedPlugins.tsx index 589ab60c64..7e6e941ac7 100644 --- a/app/client/src/pages/Editor/IntegrationEditor/EmptySearchedPlugins.tsx +++ b/app/client/src/pages/Editor/IntegrationEditor/EmptySearchedPlugins.tsx @@ -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, ) : []), ], diff --git a/app/client/src/pages/Editor/IntegrationEditor/PremiumDatasources/Constants.ts b/app/client/src/pages/Editor/IntegrationEditor/PremiumDatasources/Constants.ts index 4ab0332462..d394e3230e 100644 --- a/app/client/src/pages/Editor/IntegrationEditor/PremiumDatasources/Constants.ts +++ b/app/client/src/pages/Editor/IntegrationEditor/PremiumDatasources/Constants.ts @@ -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 = diff --git a/app/client/src/pages/Editor/IntegrationEditor/PremiumDatasources/index.tsx b/app/client/src/pages/Editor/IntegrationEditor/PremiumDatasources/index.tsx index 45836c51d2..087cbc3835 100644 --- a/app/client/src/pages/Editor/IntegrationEditor/PremiumDatasources/index.tsx +++ b/app/client/src/pages/Editor/IntegrationEditor/PremiumDatasources/index.tsx @@ -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(""); @@ -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={ diff --git a/app/client/src/reducers/entityReducers/pluginsReducer.ts b/app/client/src/reducers/entityReducers/pluginsReducer.ts index ff546296ad..1276aad94b 100644 --- a/app/client/src/reducers/entityReducers/pluginsReducer.ts +++ b/app/client/src/reducers/entityReducers/pluginsReducer.ts @@ -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; 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, + ) => { + 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; diff --git a/app/client/src/sagas/PluginSagas.ts b/app/client/src/sagas/PluginSagas.ts index 028cbda17b..9c887c2c49 100644 --- a/app/client/src/sagas/PluginSagas.ts +++ b/app/client/src/sagas/PluginSagas.ts @@ -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 = 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, + ), ]); }