diff --git a/app/client/src/ce/IDE/Interfaces/IDETypes.ts b/app/client/src/ce/IDE/Interfaces/IDETypes.ts index 03de61336173..5531ef152af7 100644 --- a/app/client/src/ce/IDE/Interfaces/IDETypes.ts +++ b/app/client/src/ce/IDE/Interfaces/IDETypes.ts @@ -2,6 +2,7 @@ export const IDE_TYPE = { None: "None", App: "App", UIPackage: "UIPackage", + Workspace: "Workspace", } as const; export type IDEType = keyof typeof IDE_TYPE; diff --git a/app/client/src/ce/IDE/constants/routes.ts b/app/client/src/ce/IDE/constants/routes.ts index 2f96deb54409..bf9f1179af8a 100644 --- a/app/client/src/ce/IDE/constants/routes.ts +++ b/app/client/src/ce/IDE/constants/routes.ts @@ -38,8 +38,16 @@ export const EntityPaths: string[] = [ WIDGETS_EDITOR_ID_PATH + ADD_PATH, ENTITY_PATH, ]; +import { + WORKSPACE_DATASOURCE_EDITOR_PAGE_URL, + WORKSPACE_DATASOURCES_PAGE_URL, +} from "constants/routes"; export const IDEBasePaths: Readonly> = { [IDE_TYPE.None]: [], [IDE_TYPE.App]: [BUILDER_PATH, BUILDER_PATH_DEPRECATED, BUILDER_CUSTOM_PATH], [IDE_TYPE.UIPackage]: [], + [IDE_TYPE.Workspace]: [ + WORKSPACE_DATASOURCES_PAGE_URL, + WORKSPACE_DATASOURCE_EDITOR_PAGE_URL, + ], }; diff --git a/app/client/src/ce/RouteBuilder.ts b/app/client/src/ce/RouteBuilder.ts index d61b619d4c45..10343c325e7a 100644 --- a/app/client/src/ce/RouteBuilder.ts +++ b/app/client/src/ce/RouteBuilder.ts @@ -197,6 +197,14 @@ export const queryAddURL = (props: URLBuilderParams): string => suffix: `queries/add`, }); +export const workspaceDatasourcesURL = (workspaceId: string): string => + `/workspace/${workspaceId}/datasources`; + +export const workspaceDatasourceEditorURL = ( + workspaceId: string, + datasourceId: string, +): string => `/workspace/${workspaceId}/datasource/${datasourceId}`; + export const appLibrariesURL = (): string => urlBuilder.build({ suffix: "libraries", diff --git a/app/client/src/ce/actions/workspaceIDEActions.ts b/app/client/src/ce/actions/workspaceIDEActions.ts new file mode 100644 index 000000000000..280d7a2befc3 --- /dev/null +++ b/app/client/src/ce/actions/workspaceIDEActions.ts @@ -0,0 +1,10 @@ +import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; + +export interface InitWorkspaceIDEPayload { + workspaceId: string; +} + +export const initWorkspaceIDE = (payload: InitWorkspaceIDEPayload) => ({ + type: ReduxActionTypes.INITIALIZE_WORKSPACE_IDE, + payload, +}); diff --git a/app/client/src/ce/constants/ReduxActionConstants.tsx b/app/client/src/ce/constants/ReduxActionConstants.tsx index 36f44caa2a15..eff576531162 100644 --- a/app/client/src/ce/constants/ReduxActionConstants.tsx +++ b/app/client/src/ce/constants/ReduxActionConstants.tsx @@ -411,6 +411,8 @@ const IDEActionTypes = { INITIALIZE_CURRENT_PAGE: "INITIALIZE_CURRENT_PAGE", INITIALIZE_EDITOR: "INITIALIZE_EDITOR", INITIALIZE_EDITOR_SUCCESS: "INITIALIZE_EDITOR_SUCCESS", + INITIALIZE_WORKSPACE_IDE: "INITIALIZE_WORKSPACE_IDE", + INITIALIZE_WORKSPACE_IDE_SUCCESS: "INITIALIZE_WORKSPACE_IDE_SUCCESS", INITIALIZE_PAGE_VIEWER: "INITIALIZE_PAGE_VIEWER", INITIALIZE_PAGE_VIEWER_SUCCESS: "INITIALIZE_PAGE_VIEWER_SUCCESS", SET_EXPLORER_PINNED: "SET_EXPLORER_PINNED", @@ -478,6 +480,7 @@ const IDEActionTypes = { const IDEActionErrorTypes = { SETUP_PAGE_ERROR: "SETUP_PAGE_ERROR", SETUP_PUBLISHED_PAGE_ERROR: "SETUP_PUBLISHED_PAGE_ERROR", + INITIALIZE_WORKSPACE_IDE_ERROR: "INITIALIZE_WORKSPACE_IDE_ERROR", }; const ErrorManagementActionTypes = { diff --git a/app/client/src/ce/entities/URLRedirect/URLAssembly.ts b/app/client/src/ce/entities/URLRedirect/URLAssembly.ts index e006897d61ef..0002b202ad40 100644 --- a/app/client/src/ce/entities/URLRedirect/URLAssembly.ts +++ b/app/client/src/ce/entities/URLRedirect/URLAssembly.ts @@ -13,6 +13,7 @@ import { APP_MODE } from "entities/App"; import { generatePath } from "react-router"; import getQueryParamsObject from "utils/getQueryParamsObject"; import { isNil } from "lodash"; +import { objectKeys } from "@appsmith/utils"; export interface URLBuilderParams { suffix?: string; @@ -26,6 +27,7 @@ export interface URLBuilderParams { // This is used to pass ID if the sender doesn't know the type of the entity // base version of parent entity id, can be basePageId or moduleId baseParentEntityId?: string; + workspaceId?: string; generateEditorPath?: boolean; } @@ -65,7 +67,7 @@ export interface PageURLParams { export function getQueryStringfromObject( params: Record = {}, ): string { - const paramKeys = Object.keys(params); + const paramKeys = objectKeys(params); const queryParams: string[] = []; if (paramKeys) { @@ -241,9 +243,24 @@ export class URLBuilder { } generateBasePath(basePageId: string, mode: APP_MODE) { + // Check if we're in workspace context + if (this.isWorkspaceContext()) { + return this.generateBasePathForWorkspace(basePageId); + } + return this.generateBasePathForApp(basePageId, mode); } + isWorkspaceContext(): boolean { + const currentUrl = window.location.pathname; + + return currentUrl.startsWith("/workspace"); + } + + generateBasePathForWorkspace(workspaceId: string) { + return `/workspace/${workspaceId}/datasources`; + } + getCustomSlugPathPreview(basePageId: string, customSlug: string) { const urlPattern = baseURLRegistry[URL_TYPE.CUSTOM_SLUG][APP_MODE.PUBLISHED]; @@ -284,9 +301,26 @@ export class URLBuilder { } resolveEntityId(builderParams: URLBuilderParams): string { + // Check if we're in workspace context + if (this.isWorkspaceContext()) { + return this.resolveEntityIdForWorkspace(builderParams); + } + return this.resolveEntityIdForApp(builderParams); } + resolveEntityIdForWorkspace(builderParams: URLBuilderParams): string { + // Extract workspaceId from current URL if not provided + if (builderParams?.workspaceId) { + return builderParams.workspaceId; + } + + const currentUrl = window.location.pathname; + const workspaceMatch = currentUrl.match(/^\/workspace\/([^\/]+)/); + + return workspaceMatch ? workspaceMatch[1] : ""; + } + /** * @throws {URIError} * @param builderParams @@ -304,7 +338,15 @@ export class URLBuilder { const entityId = this.resolveEntityId(builderParams); - const basePath = this.generateBasePath(entityId, mode); + // Handle workspace-specific URL generation + let basePath = this.generateBasePath(entityId, mode); + let suffixPath = suffix ? `/${suffix}` : ""; + + if (this.isWorkspaceContext() && suffix?.startsWith("datasource/")) { + // For workspace datasource URLs, use singular /datasource path + basePath = `/workspace/${entityId}/datasource`; + suffixPath = `/${suffix.replace("datasource/", "")}`; + } const queryParamsToPersist = fetchQueryParamsToPersist( persistExistingParams, @@ -320,8 +362,6 @@ export class URLBuilder { const queryString = getQueryStringfromObject(modifiedQueryParams); - const suffixPath = suffix ? `/${suffix}` : ""; - const hashPath = hash ? `#${hash}` : ""; // hash fragment should be at the end of the href diff --git a/app/client/src/ce/pages/Applications/WorkspaceMenu.tsx b/app/client/src/ce/pages/Applications/WorkspaceMenu.tsx index d9c137fc2c8d..1c1a289aca5b 100644 --- a/app/client/src/ce/pages/Applications/WorkspaceMenu.tsx +++ b/app/client/src/ce/pages/Applications/WorkspaceMenu.tsx @@ -166,6 +166,19 @@ function WorkspaceMenu({ workspaceId={workspace.id} workspacePermissions={workspace.userPermissions || []} /> + {hasManageWorkspacePermissions && ( + + getOnSelectAction(DropdownOnSelectActions.REDIRECT, { + path: `/workspace/${workspace.id}/datasources`, + }) + } + > + + Datasources + + )} {canInviteToWorkspace && ( { exact path={CUSTOM_WIDGETS_DEPRECATED_EDITOR_ID_PATH} /> + + diff --git a/app/client/src/ce/reducers/uiReducers/editorReducer.tsx b/app/client/src/ce/reducers/uiReducers/editorReducer.tsx index 9d7c3d98ba08..8cfb5ebaa176 100644 --- a/app/client/src/ce/reducers/uiReducers/editorReducer.tsx +++ b/app/client/src/ce/reducers/uiReducers/editorReducer.tsx @@ -15,6 +15,7 @@ import type { UpdateCanvasPayload } from "actions/pageActions"; export const initialState: EditorReduxState = { widgetConfigBuilt: false, initialized: false, + isWorkspaceEditorInitialized: false, loadingStates: { publishing: false, publishingError: false, @@ -65,6 +66,14 @@ export const handlers = { [ReduxActionTypes.INITIALIZE_EDITOR_SUCCESS]: (state: EditorReduxState) => { return { ...state, initialized: true }; }, + [ReduxActionTypes.INITIALIZE_WORKSPACE_IDE]: (state: EditorReduxState) => { + return { ...state, isWorkspaceEditorInitialized: false }; + }, + [ReduxActionTypes.INITIALIZE_WORKSPACE_IDE_SUCCESS]: ( + state: EditorReduxState, + ) => { + return { ...state, isWorkspaceEditorInitialized: true }; + }, [ReduxActionTypes.UPDATE_PAGE_SUCCESS]: ( state: EditorReduxState, action: ReduxAction, @@ -318,6 +327,7 @@ const editorReducer = createReducer(initialState, handlers); export interface EditorReduxState { widgetConfigBuilt: boolean; initialized: boolean; + isWorkspaceEditorInitialized: boolean; pageWidgetId?: string; currentLayoutId?: string; currentPageName?: string; diff --git a/app/client/src/ce/sagas/index.tsx b/app/client/src/ce/sagas/index.tsx index 985260fb14a9..2e42ce8a86c7 100644 --- a/app/client/src/ce/sagas/index.tsx +++ b/app/client/src/ce/sagas/index.tsx @@ -57,6 +57,7 @@ import PostEvaluationSagas from "sagas/PostEvaluationSagas"; /* Sagas that are registered by a module that is designed to be independent of the core platform */ import ternSagas from "sagas/TernSaga"; import gitApplicationSagas from "git-artifact-helpers/application/sagas"; +import workspaceIDESagas from "ee/sagas/workspaceIDESagas"; export const sagas = [ initSagas, @@ -75,6 +76,7 @@ export const sagas = [ templateSagas, pluginSagas, workspaceSagas, + workspaceIDESagas, curlImportSagas, snipingModeSagas, queryPaneSagas, diff --git a/app/client/src/ce/sagas/workspaceIDESagas.ts b/app/client/src/ce/sagas/workspaceIDESagas.ts new file mode 100644 index 000000000000..335877d37842 --- /dev/null +++ b/app/client/src/ce/sagas/workspaceIDESagas.ts @@ -0,0 +1,34 @@ +import { put, call } from "redux-saga/effects"; + +import { ReduxActionErrorTypes } from "ee/constants/ReduxActionConstants"; +import WorkspaceEditorEngine from "entities/Engine/WorkspaceEditorEngine"; +import type { ReduxAction } from "actions/ReduxActionTypes"; +import type { InitWorkspaceIDEPayload } from "ee/actions/workspaceIDEActions"; +import { resetEditorRequest } from "actions/initActions"; + +export function* startWorkspaceIDE( + action: ReduxAction, +) { + try { + const workspaceEngine = new WorkspaceEditorEngine(); + const { workspaceId } = action.payload; + + /** + * During editor switches like app (view mode) -> workspace + * there are certain cases were stale data stays in reducers. + * This ensures a clean state of reducers and avoids any dependency + */ + yield put(resetEditorRequest()); + yield call(workspaceEngine.setupEngine); + yield call(workspaceEngine.loadWorkspace, workspaceId); + yield call(workspaceEngine.loadPluginsAndDatasources, workspaceId); + yield call(workspaceEngine.completeChore); + } catch (error) { + yield put({ + type: ReduxActionErrorTypes.INITIALIZE_WORKSPACE_IDE_ERROR, + payload: { + error, + }, + }); + } +} diff --git a/app/client/src/ce/utils/BusinessFeatures/permissionPageHelpers.tsx b/app/client/src/ce/utils/BusinessFeatures/permissionPageHelpers.tsx index 14ba9b5d8607..a0f2a747af23 100644 --- a/app/client/src/ce/utils/BusinessFeatures/permissionPageHelpers.tsx +++ b/app/client/src/ce/utils/BusinessFeatures/permissionPageHelpers.tsx @@ -179,5 +179,6 @@ export const hasCreateDSActionPermissionInApp = ({ return !ideType || ideType === IDE_TYPE.App ? getHasCreateDatasourceActionPermission(isEnabled, dsPermissions) && getHasCreateActionPermission(isEnabled, pagePermissions) - : getHasCreateDatasourceActionPermission(isEnabled, dsPermissions); + : ideType !== IDE_TYPE.Workspace && + getHasCreateDatasourceActionPermission(isEnabled, dsPermissions); }; diff --git a/app/client/src/constants/routes/baseRoutes.ts b/app/client/src/constants/routes/baseRoutes.ts index 402924604c5b..66246fe1a749 100644 --- a/app/client/src/constants/routes/baseRoutes.ts +++ b/app/client/src/constants/routes/baseRoutes.ts @@ -28,6 +28,8 @@ export const WORKSPACE_INVITE_USERS_PAGE_URL = `${WORKSPACE_URL}/invite`; export const WORKSPACE_SETTINGS_PAGE_URL = `${WORKSPACE_URL}/settings`; export const WORKSPACE_SETTINGS_GENERAL_PAGE_URL = `${WORKSPACE_URL}/settings/general`; export const WORKSPACE_SETTINGS_MEMBERS_PAGE_URL = `${WORKSPACE_URL}/settings/members`; +export const WORKSPACE_DATASOURCES_PAGE_URL = `${WORKSPACE_URL}/:workspaceId/datasources`; +export const WORKSPACE_DATASOURCE_EDITOR_PAGE_URL = `${WORKSPACE_URL}/:workspaceId/datasource/:datasourceId`; export const WORKSPACE_SETTINGS_LICENSE_PAGE_URL = `/settings/license`; export const ORG_LOGIN_PATH = "/org"; diff --git a/app/client/src/ee/actions/workspaceIDEActions.ts b/app/client/src/ee/actions/workspaceIDEActions.ts new file mode 100644 index 000000000000..85cfdb9aea95 --- /dev/null +++ b/app/client/src/ee/actions/workspaceIDEActions.ts @@ -0,0 +1 @@ +export * from "ce/actions/workspaceIDEActions"; diff --git a/app/client/src/ee/sagas/workspaceIDESagas.ts b/app/client/src/ee/sagas/workspaceIDESagas.ts new file mode 100644 index 000000000000..34fd6bd127c2 --- /dev/null +++ b/app/client/src/ee/sagas/workspaceIDESagas.ts @@ -0,0 +1,11 @@ +export * from "ce/sagas/workspaceIDESagas"; + +import { startWorkspaceIDE } from "ce/sagas/workspaceIDESagas"; +import { ReduxActionTypes } from "ee/constants/ReduxActionConstants"; +import { all, takeLatest } from "redux-saga/effects"; + +export default function* workspaceIDESagas() { + yield all([ + takeLatest(ReduxActionTypes.INITIALIZE_WORKSPACE_IDE, startWorkspaceIDE), + ]); +} diff --git a/app/client/src/entities/Engine/WorkspaceEditorEngine.ts b/app/client/src/entities/Engine/WorkspaceEditorEngine.ts new file mode 100644 index 000000000000..92d69adfc523 --- /dev/null +++ b/app/client/src/entities/Engine/WorkspaceEditorEngine.ts @@ -0,0 +1,109 @@ +import { call, put } from "redux-saga/effects"; +import CodemirrorTernService from "utils/autocomplete/CodemirrorTernService"; +import { + ReduxActionErrorTypes, + ReduxActionTypes, +} from "ee/constants/ReduxActionConstants"; +import { isAirgapped } from "ee/utils/airgapHelpers"; +import { fetchPluginFormConfigs, fetchPlugins } from "actions/pluginActions"; +import { + fetchDatasources, + fetchMockDatasources, +} from "actions/datasourceActions"; +import { failFastApiCalls } from "sagas/InitSagas"; +import { + PluginFormConfigsNotFoundError, + PluginsNotFoundError, +} from "entities/Engine"; +import type { ReduxAction } from "actions/ReduxActionTypes"; +import { waitForFetchEnvironments } from "ee/sagas/EnvironmentSagas"; +import { fetchingEnvironmentConfigs } from "ee/actions/environmentAction"; + +export default class WorkspaceEditorEngine { + constructor() { + this.setupEngine = this.setupEngine.bind(this); + this.loadWorkspace = this.loadWorkspace.bind(this); + this.loadPluginsAndDatasources = this.loadPluginsAndDatasources.bind(this); + this.completeChore = this.completeChore.bind(this); + } + + *loadWorkspace(workspaceId: string) { + // Set the current workspace context + yield put({ + type: ReduxActionTypes.SET_CURRENT_WORKSPACE_ID, + payload: { + workspaceId, + editorId: workspaceId, // Use workspaceId as editorId for workspace context + }, + }); + + // Fetch environment configs for the workspace + yield put( + fetchingEnvironmentConfigs({ + workspaceId, + editorId: workspaceId, + fetchDatasourceMeta: true, + }), + ); + + // Wait for environments to be fetched + yield call(waitForFetchEnvironments); + } + + public *setupEngine() { + yield put({ type: ReduxActionTypes.START_EVALUATION }); + CodemirrorTernService.resetServer(); + } + + *loadPluginsAndDatasources(workspaceId: string) { + const isAirgappedInstance = isAirgapped(); + const initActions: ReduxAction[] = [ + fetchPlugins({ workspaceId }), + fetchDatasources({ workspaceId }), + ]; + + const successActions = [ + ReduxActionTypes.FETCH_PLUGINS_SUCCESS, + ReduxActionTypes.FETCH_DATASOURCES_SUCCESS, + ]; + + const errorActions = [ + ReduxActionErrorTypes.FETCH_PLUGINS_ERROR, + ReduxActionErrorTypes.FETCH_DATASOURCES_ERROR, + ]; + + if (!isAirgappedInstance) { + initActions.push(fetchMockDatasources()); + successActions.push(ReduxActionTypes.FETCH_MOCK_DATASOURCES_SUCCESS); + errorActions.push(ReduxActionErrorTypes.FETCH_MOCK_DATASOURCES_ERROR); + } + + const initActionCalls: boolean = yield call( + failFastApiCalls, + initActions, + successActions, + errorActions, + ); + + if (!initActionCalls) + throw new PluginsNotFoundError("Unable to fetch plugins"); + + const pluginFormCall: boolean = yield call( + failFastApiCalls, + [fetchPluginFormConfigs()], + [ReduxActionTypes.FETCH_PLUGIN_FORM_CONFIGS_SUCCESS], + [ReduxActionErrorTypes.FETCH_PLUGIN_FORM_CONFIGS_ERROR], + ); + + if (!pluginFormCall) + throw new PluginFormConfigsNotFoundError( + "Unable to fetch plugin form configs", + ); + } + + *completeChore() { + yield put({ + type: ReduxActionTypes.INITIALIZE_WORKSPACE_IDE_SUCCESS, + }); + } +} diff --git a/app/client/src/pages/AppIDE/layouts/components/Header/index.tsx b/app/client/src/pages/AppIDE/layouts/components/Header/index.tsx index 9b2c64ec1a24..349b98e5bcab 100644 --- a/app/client/src/pages/AppIDE/layouts/components/Header/index.tsx +++ b/app/client/src/pages/AppIDE/layouts/components/Header/index.tsx @@ -162,7 +162,10 @@ const Header = () => { {currentWorkspace.name && ( <> - + {currentWorkspace.name} {"/"} diff --git a/app/client/src/pages/Editor/IntegrationEditor/APIOrSaasPlugins.tsx b/app/client/src/pages/Editor/IntegrationEditor/APIOrSaasPlugins.tsx index 2070f9f4e47d..5950315c2624 100644 --- a/app/client/src/pages/Editor/IntegrationEditor/APIOrSaasPlugins.tsx +++ b/app/client/src/pages/Editor/IntegrationEditor/APIOrSaasPlugins.tsx @@ -52,6 +52,7 @@ import type { IDEType } from "ee/IDE/Interfaces/IDETypes"; import { filterSearch } from "./util"; import { selectFeatureFlagCheck } from "ee/selectors/featureFlagsSelectors"; import { FEATURE_FLAG } from "ee/entities/FeatureFlag"; +import urlBuilder from "ee/entities/URLRedirect/URLAssembly"; interface CreateAPIOrSaasPluginsProps { location: { @@ -365,14 +366,19 @@ const mapStateToProps = ( ) as UpcomingIntegration[]) : []; + // Check if we're on workspace datasources page + const isWorkspaceDatasourcesPage = urlBuilder.isWorkspaceContext(); + const restAPIVisible = !props.showSaasAPIs && + !isWorkspaceDatasourcesPage && filterSearch( [{ name: createMessage(CREATE_NEW_DATASOURCE_REST_API) }], searchedPlugin, ).length > 0; const graphQLAPIVisible = !props.showSaasAPIs && + !isWorkspaceDatasourcesPage && filterSearch( [{ name: createMessage(CREATE_NEW_DATASOURCE_GRAPHQL_API) }], searchedPlugin, diff --git a/app/client/src/pages/Editor/IntegrationEditor/DBOrMostPopularPlugins.tsx b/app/client/src/pages/Editor/IntegrationEditor/DBOrMostPopularPlugins.tsx index d923050ed0fb..be16630531f4 100644 --- a/app/client/src/pages/Editor/IntegrationEditor/DBOrMostPopularPlugins.tsx +++ b/app/client/src/pages/Editor/IntegrationEditor/DBOrMostPopularPlugins.tsx @@ -53,6 +53,7 @@ import { import { getIDETypeByUrl } from "ee/entities/IDE/utils"; import type { IDEType } from "ee/IDE/Interfaces/IDETypes"; import { filterSearch } from "./util"; +import urlBuilder from "ee/entities/URLRedirect/URLAssembly"; // This function remove the given key from queryParams and return string const removeQueryParams = (paramKeysToRemove: Array) => { @@ -314,6 +315,10 @@ const mapStateToProps = ( const searchedPlugin = ( pluginSearchSelector(state, "search") || "" ).toLocaleLowerCase(); + + // Check if we're on workspace datasources page + const isWorkspaceDatasourcesPage = urlBuilder.isWorkspaceContext(); + const filteredMostPopularPlugins: Plugin[] = !!isAirgappedInstance ? mostPopularPlugins.filter( (plugin: Plugin) => @@ -321,8 +326,15 @@ const mapStateToProps = ( ) : mostPopularPlugins; + // Filter out REST API from most popular plugins when on workspace datasources page + const finalFilteredMostPopularPlugins = isWorkspaceDatasourcesPage + ? filteredMostPopularPlugins.filter( + (plugin: Plugin) => plugin?.packageName !== PluginPackageName.REST_API, + ) + : filteredMostPopularPlugins; + let plugins = !!props?.showMostPopularPlugins - ? filteredMostPopularPlugins + ? finalFilteredMostPopularPlugins : getDBPlugins(state); plugins = filterSearch(plugins, searchedPlugin) as Plugin[]; diff --git a/app/client/src/pages/workspace/WorkspaceDataSidePane.tsx b/app/client/src/pages/workspace/WorkspaceDataSidePane.tsx new file mode 100644 index 000000000000..6b7e890f4809 --- /dev/null +++ b/app/client/src/pages/workspace/WorkspaceDataSidePane.tsx @@ -0,0 +1,193 @@ +import React, { useCallback, useEffect, useState } from "react"; +import styled from "styled-components"; +import { EntityGroupsList, Flex } from "@appsmith/ads"; +import { useSelector } from "react-redux"; +import { + getDatasources, + getDatasourcesGroupedByPluginCategory, + getPluginImages, + getPlugins, +} from "ee/selectors/entitiesSelector"; +import history from "utils/history"; +import { workspaceDatasourceEditorURL } from "ee/RouteBuilder"; +import { Button } from "@appsmith/ads"; +import { useLocation } from "react-router"; +import { + createMessage, + DATA_PANE_TITLE, + DATASOURCE_BLANK_STATE_CTA, + DATASOURCE_LIST_BLANK_DESCRIPTION, +} from "ee/constants/messages"; +import PaneHeader from "IDE/Components/PaneHeader"; +import type { DefaultRootState } from "react-redux"; +import { getCurrentAppWorkspace } from "ee/selectors/selectedWorkspaceSelectors"; +import { useFeatureFlag } from "utils/hooks/useFeatureFlag"; +import { FEATURE_FLAG } from "ee/entities/FeatureFlag"; +import { getHasCreateDatasourcePermission } from "ee/utils/BusinessFeatures/permissionPageHelpers"; +import { EmptyState } from "@appsmith/ads"; +import { getAssetUrl } from "ee/utils/airgapHelpers"; +import { getSelectedDatasourceId } from "ee/navigation/FocusSelectors"; +import PremiumFeatureTag from "components/editorComponents/PremiumFeatureTag"; +import { PluginType } from "entities/Plugin"; +import { selectFeatureFlagCheck } from "ee/selectors/featureFlagsSelectors"; +import type { Datasource } from "entities/Datasource"; + +const PaneBody = styled.div` + padding: var(--ads-v2-spaces-3) 0; + height: calc(100vh - 120px); + overflow-y: auto; +`; + +const DatasourceIcon = styled.img` + height: 16px; + width: 16px; +`; + +interface WorkspaceDataSidePaneProps { + workspaceId: string; +} + +export const WorkspaceDataSidePane = (props: WorkspaceDataSidePaneProps) => { + const { workspaceId } = props; + const [currentSelectedDatasource, setCurrentSelectedDatasource] = useState< + string | undefined + >(""); + const datasources = useSelector(getDatasources); + const groupedDatasources = useSelector(getDatasourcesGroupedByPluginCategory); + const pluginImages = useSelector(getPluginImages); + const plugins = useSelector(getPlugins); + const location = useLocation(); + + const isIntegrationsEnabledForPaid = useSelector((state: DefaultRootState) => + selectFeatureFlagCheck( + state, + FEATURE_FLAG.license_external_saas_plugins_enabled, + ), + ); + + const goToDatasource = useCallback( + (id: string) => { + history.push(workspaceDatasourceEditorURL(workspaceId, id)); + }, + [workspaceId], + ); + + useEffect(() => { + setCurrentSelectedDatasource(getSelectedDatasourceId(location.pathname)); + }, [location]); + + const currentWorkspace = useSelector(getCurrentAppWorkspace); + const userWorkspacePermissions = currentWorkspace?.userPermissions ?? []; + + const isFeatureEnabled = useFeatureFlag(FEATURE_FLAG.license_gac_enabled); + const isFetchingCurrentWorkspace = useSelector( + (state: DefaultRootState) => + state.ui.selectedWorkspace.loadingStates.isFetchingCurrentWorkspace, + ); + + const canCreateDatasource = getHasCreateDatasourcePermission( + isFeatureEnabled, + userWorkspacePermissions, + ); + + const addButtonClickHandler = useCallback(() => { + history.push(`/workspace/${workspaceId}/datasources/NEW`); + }, [workspaceId]); + + const blankStateButtonProps = { + className: "t--add-datasource-button-blank-screen", + testId: "t--add-datasource-button-blank-screen", + text: createMessage(DATASOURCE_BLANK_STATE_CTA), + onClick: canCreateDatasource ? addButtonClickHandler : undefined, + }; + + const shouldShowPremiumTag = useCallback( + (datasource: Datasource) => { + const plugin = plugins.find((p) => p.id === datasource.pluginId); + + if (!plugin) return false; + + if ( + plugin.type === PluginType.EXTERNAL_SAAS && + !isIntegrationsEnabledForPaid + ) { + return true; + } + + return false; + }, + [plugins, isIntegrationsEnabledForPaid], + ); + + // Show loading state if workspace is being fetched + if (isFetchingCurrentWorkspace) { + return ( + +
Loading workspace permissions...
+
+ ); + } + + return ( + + + history.push(`/workspace/${workspaceId}/datasources/NEW`) + } + size="sm" + startIcon="add-line" + /> + ) : undefined + } + title={createMessage(DATA_PANE_TITLE)} + /> + + {datasources.length === 0 ? ( + + ) : null} + { + return { + groupTitle: key, + items: value.map((data) => { + return { + id: data.id, + title: data.name, + startIcon: ( + + ), + className: "t--datasource", + isSelected: currentSelectedDatasource === data.id, + onClick: () => goToDatasource(data.id), + rightControl: shouldShowPremiumTag(data) && ( + + ), + }; + }), + className: "", + }; + })} + /> + + + ); +}; diff --git a/app/client/src/pages/workspace/WorkspaceDatasourceEditor.tsx b/app/client/src/pages/workspace/WorkspaceDatasourceEditor.tsx new file mode 100644 index 000000000000..5be4f90f8a83 --- /dev/null +++ b/app/client/src/pages/workspace/WorkspaceDatasourceEditor.tsx @@ -0,0 +1,41 @@ +import React, { useEffect } from "react"; +import { useParams } from "react-router"; +import { useDispatch, useSelector } from "react-redux"; +import DataSourceEditor from "pages/Editor/DataSourceEditor"; +import { setCurrentEditingEnvironmentID } from "ee/actions/environmentAction"; +import { getCurrentEnvironmentDetails } from "ee/selectors/environmentSelectors"; + +const WorkspaceDatasourceEditor = () => { + const { datasourceId, workspaceId } = useParams<{ + datasourceId: string; + workspaceId: string; + }>(); + const dispatch = useDispatch(); + const currentEnvironmentDetails = useSelector(getCurrentEnvironmentDetails); + + // Update environment whenever it changes in parent + useEffect(() => { + if ( + currentEnvironmentDetails.id && + currentEnvironmentDetails.id !== "unused_env" + ) { + dispatch(setCurrentEditingEnvironmentID(currentEnvironmentDetails.id)); + } + }, [dispatch, currentEnvironmentDetails.id]); + + // For workspace context, we need to provide a minimal applicationId and pageId + // that won't trigger unnecessary API calls but will satisfy the DataSourceEditor + const workspaceApplicationId = `workspace-app-${workspaceId}`; + const workspacePageId = `workspace-page-${workspaceId}`; + + return ( + + ); +}; + +export default WorkspaceDatasourceEditor; diff --git a/app/client/src/pages/workspace/WorkspaceDatasourceHeader.tsx b/app/client/src/pages/workspace/WorkspaceDatasourceHeader.tsx new file mode 100644 index 000000000000..5478f8c113be --- /dev/null +++ b/app/client/src/pages/workspace/WorkspaceDatasourceHeader.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { useSelector } from "react-redux"; +import { IDEHeader, IDEHeaderTitle, Link } from "@appsmith/ads"; +import { APPLICATIONS_URL } from "constants/routes"; +import { getCurrentAppWorkspace } from "ee/selectors/selectedWorkspaceSelectors"; +import { AppsmithLink } from "pages/Editor/AppsmithLink"; +import styled from "styled-components"; + +const StyledWorkspaceHeader = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: var(--ads-v2-z-index-9); + background: var(--ads-v2-color-bg); +`; + +const WorkspaceDatasourceHeader = () => { + const currentWorkspace = useSelector(getCurrentAppWorkspace); + + return ( + + + }> + + + + {currentWorkspace?.name && ( + + {currentWorkspace.name} + + )} + + +
+ + + + ); +}; + +export default WorkspaceDatasourceHeader; diff --git a/app/client/src/pages/workspace/WorkspaceDatasourcesPage.tsx b/app/client/src/pages/workspace/WorkspaceDatasourcesPage.tsx new file mode 100644 index 000000000000..eb77a76eb033 --- /dev/null +++ b/app/client/src/pages/workspace/WorkspaceDatasourcesPage.tsx @@ -0,0 +1,135 @@ +import React, { useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { Switch } from "react-router"; +import { SentryRoute } from "components/SentryRoute"; +import styled from "styled-components"; +import { Spinner, IDE_HEADER_HEIGHT } from "@appsmith/ads"; +import CenteredWrapper from "components/designSystems/appsmith/CenteredWrapper"; + +// Import the workspace-adapted DataSidePane +import { WorkspaceDataSidePane } from "./WorkspaceDataSidePane"; +import CreateNewDatasourceTab from "pages/Editor/IntegrationEditor/CreateNewDatasourceTab"; +import WorkspaceDatasourceEditor from "./WorkspaceDatasourceEditor"; + +import { getFetchedWorkspaces } from "ee/selectors/workspaceSelectors"; +import { fetchAllWorkspaces } from "ee/actions/workspaceActions"; +import { initWorkspaceIDE } from "ee/actions/workspaceIDEActions"; +import type { DefaultRootState } from "react-redux"; +import { + WORKSPACE_DATASOURCES_PAGE_URL, + WORKSPACE_DATASOURCE_EDITOR_PAGE_URL, +} from "constants/routes"; + +// Page container for full viewport layout +const PageContainer = styled.div` + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; + padding-top: ${IDE_HEADER_HEIGHT}px; +`; + +// Use the SAME layout structure as AppIDE +const IDEContainer = styled.div` + display: grid; + grid-template-columns: 300px 1fr; + grid-template-rows: 100%; + height: 100vh; + overflow: hidden; + flex: 1; +`; + +const LeftPane = styled.div` + background-color: var(--ads-v2-color-bg); + border-right: 1px solid var(--ads-v2-color-border); + overflow: hidden; + display: flex; + flex-direction: column; +`; + +const MainPane = styled.div` + background-color: var(--ads-v2-color-bg); + overflow: hidden; + display: flex; + flex-direction: column; +`; + +interface WorkspaceDatasourcesPageProps { + workspaceId: string; +} + +export const WorkspaceDatasourcesPage = ( + props: WorkspaceDatasourcesPageProps, +) => { + const { workspaceId } = props; + const dispatch = useDispatch(); + + // Check if workspace editor is initialized + const isWorkspaceEditorInitialized = useSelector( + (state: DefaultRootState) => state.ui.editor.isWorkspaceEditorInitialized, + ); + + const currentWorkspace = useSelector((state: DefaultRootState) => { + const workspaces = getFetchedWorkspaces(state); + + return workspaces.find((ws) => ws.id === workspaceId); + }); + + // Initialize workspace IDE once when workspaceId changes + useEffect(() => { + if (workspaceId && !isWorkspaceEditorInitialized) { + dispatch(initWorkspaceIDE({ workspaceId })); + } + }, [dispatch, workspaceId, isWorkspaceEditorInitialized]); + + // Fetch workspaces if not loaded (same pattern as settings.tsx) + useEffect(() => { + if (!currentWorkspace) { + dispatch(fetchAllWorkspaces({ workspaceId, fetchEntities: true })); + } + }, [currentWorkspace, dispatch, workspaceId]); + + // Show loading state while workspace editor is initializing + if (!isWorkspaceEditorInitialized) { + return ( + + + + + + ); + } + + return ( + + + + + + + + {/* Create new datasource - use the exact same component */} + } + /> + {/* Edit existing datasource - use workspace-specific editor */} + } + /> + {/* Default list view - show "Connect a datasource" page by default */} + } + /> + + + + + ); +}; diff --git a/app/client/src/pages/workspace/index.tsx b/app/client/src/pages/workspace/index.tsx index 04537f9e8fc2..535ca16c8ea4 100644 --- a/app/client/src/pages/workspace/index.tsx +++ b/app/client/src/pages/workspace/index.tsx @@ -3,6 +3,7 @@ import { Switch, useRouteMatch, useLocation } from "react-router-dom"; import PageWrapper from "pages/common/PageWrapper"; import DefaultWorkspacePage from "./defaultWorkspacePage"; import Settings from "./settings"; +import { WorkspaceDatasourcesPage } from "./WorkspaceDatasourcesPage"; import { SentryRoute } from "components/SentryRoute"; export function Workspace() { @@ -10,15 +11,43 @@ export function Workspace() { const location = useLocation(); return ( - - - - - - + + ( + + + + )} + path={`${path}/:workspaceId/settings`} + /> + ( + + )} + path={`${path}/:workspaceId/datasources`} + /> + ( + + )} + path={`${path}/:workspaceId/datasource`} + /> + ( + + + + )} + /> + ); }