diff --git a/app/client/cypress/fixtures/testdata.json b/app/client/cypress/fixtures/testdata.json index fa12a4d4f8..dfc3f9e090 100644 --- a/app/client/cypress/fixtures/testdata.json +++ b/app/client/cypress/fixtures/testdata.json @@ -17,6 +17,7 @@ "responsetext2": "qui est esse", "baseUrl3": "https://reqres.in", "methods2": "api/users/2", + "invalidPath": "api/users/a", "responsetext3": "Josh M Krantz", "postUrl": "https://reqres.in", "deleteUrl": "", diff --git a/app/client/cypress/integration/Smoke_TestSuite/ApiPaneTests/API_Edit_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ApiPaneTests/API_Edit_spec.js index 79cf5c43a0..505ac2f1d5 100644 --- a/app/client/cypress/integration/Smoke_TestSuite/ApiPaneTests/API_Edit_spec.js +++ b/app/client/cypress/integration/Smoke_TestSuite/ApiPaneTests/API_Edit_spec.js @@ -23,7 +23,7 @@ describe("API Panel Test Functionality", function() { cy.ClearSearch(); cy.SearchAPIandClick("SecondAPI"); //invalid api end point check - cy.EditSourceDetail(testdata.baseUrl3, testdata.methods2); + cy.EditSourceDetail(testdata.baseUrl3, testdata.invalidPath); cy.ResponseStatusCheck("404 NOT_FOUND"); cy.DeleteAPI(); }); diff --git a/app/client/cypress/support/commands.js b/app/client/cypress/support/commands.js index 073866fae4..e7d3e2ea1b 100644 --- a/app/client/cypress/support/commands.js +++ b/app/client/cypress/support/commands.js @@ -202,7 +202,8 @@ Cypress.Commands.add("enterDatasourceAndPath", (datasource, path) => { cy.xpath(apiwidget.autoSuggest) .first() .click({ force: true }); - cy.get(apiwidget.path) + cy.get(apiwidget.editResourceUrl) + .first() .click({ force: true }) .type(path, { parseSpecialCharSequences: false }); }); @@ -228,16 +229,17 @@ Cypress.Commands.add( Cypress.Commands.add("EditSourceDetail", (baseUrl, v1method) => { cy.get(apiwidget.editResourceUrl) .first() - .clear() .click({ force: true }) - .type(baseUrl); + .clear() + .type(`{backspace}${baseUrl}`); cy.xpath(apiwidget.autoSuggest) .first() .click({ force: true }); cy.get(ApiEditor.ApiRunBtn).scrollIntoView(); - cy.get(apiwidget.path) + cy.get(apiwidget.editResourceUrl) + .first() .focus() - .type("{selectall}{backspace}api/users/2") + .type(v1method) .should("have.value", v1method); cy.SaveAPI(); }); @@ -905,7 +907,7 @@ Cypress.Commands.add("createApi", (url, parameters) => { cy.contains(url).click({ force: true, }); - cy.get(".CodeMirror.CodeMirror-empty textarea") + cy.get(apiwidget.editResourceUrl) .first() .click({ force: true }) .type(parameters, { force: true }); diff --git a/app/client/src/actions/apiPaneActions.ts b/app/client/src/actions/apiPaneActions.ts index f1b870d7ab..839e433647 100644 --- a/app/client/src/actions/apiPaneActions.ts +++ b/app/client/src/actions/apiPaneActions.ts @@ -48,6 +48,16 @@ export const createNewApiAction = ( payload: { pageId }, }); +export const setDatasourceFieldText = ( + apiId: string, + value: string, +): ReduxAction<{ apiId: string; value: string }> => { + return { + type: ReduxActionTypes.SET_DATASOURCE_FIELD_TEXT, + payload: { apiId, value }, + }; +}; + export const setExtraFormData = ( apiId: string, extraformData: {}, diff --git a/app/client/src/actions/datasourceActions.ts b/app/client/src/actions/datasourceActions.ts index f6044a47ea..0df3f50d36 100644 --- a/app/client/src/actions/datasourceActions.ts +++ b/app/client/src/actions/datasourceActions.ts @@ -77,6 +77,12 @@ export const initDatasourcePane = ( }; }; +export const storeAsDatasource = () => { + return { + type: ReduxActionTypes.STORE_AS_DATASOURCE_INIT, + }; +}; + export default { createDatasource, fetchDatasources, diff --git a/app/client/src/assets/icons/menu/storage.svg b/app/client/src/assets/icons/menu/storage.svg new file mode 100644 index 0000000000..a856115925 --- /dev/null +++ b/app/client/src/assets/icons/menu/storage.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/client/src/components/designSystems/appsmith/CreatableDropdown.tsx b/app/client/src/components/designSystems/appsmith/CreatableDropdown.tsx index 9182c6e375..6088c374b5 100644 --- a/app/client/src/components/designSystems/appsmith/CreatableDropdown.tsx +++ b/app/client/src/components/designSystems/appsmith/CreatableDropdown.tsx @@ -1,7 +1,9 @@ import React from "react"; -import Creatable from "react-select/creatable"; +import Select, { InputActionMeta } from "react-select"; import { WrappedFieldInputProps, WrappedFieldMetaProps } from "redux-form"; + import { theme } from "constants/DefaultTheme"; +import { SelectComponents } from "react-select/src/components"; type DropdownProps = { options: Array<{ @@ -12,9 +14,12 @@ type DropdownProps = { isLoading?: boolean; input: WrappedFieldInputProps; meta: WrappedFieldMetaProps; + components: SelectComponents; onCreateOption: (inputValue: string) => void; formatCreateLabel?: (value: string) => React.ReactNode; noOptionsMessage?: (obj: { inputValue: string }) => string; + inputValue?: string; + onInputChange: (value: string, actionMeta: InputActionMeta) => void; }; const selectStyles = { @@ -22,7 +27,7 @@ const selectStyles = { ...provided, color: "#a3b3bf", }), - singleValue: (provided: any) => ({ + multiValue: (provided: any) => ({ ...provided, backgroundColor: "rgba(104,113,239,0.1)", border: "1px solid rgba(104, 113, 239, 0.5)", @@ -34,9 +39,15 @@ const selectStyles = { display: "inline-block", transform: "none", }), + multiValueRemove: () => { + return { + display: "none", + }; + }, container: (styles: any) => ({ ...styles, flex: 1, + zIndex: "5", }), control: (styles: any, state: any) => ({ ...styles, @@ -69,23 +80,34 @@ class CreatableDropdown extends React.Component { placeholder, options, isLoading, - onCreateOption, input, - formatCreateLabel, noOptionsMessage, + components, + inputValue, + onInputChange, } = this.props; const optionalProps: Partial = {}; - if (formatCreateLabel) optionalProps.formatCreateLabel = formatCreateLabel; if (noOptionsMessage) optionalProps.noOptionsMessage = noOptionsMessage; + if (components) optionalProps.components = components; + if (inputValue) optionalProps.inputValue = inputValue; + if (onInputChange) optionalProps.onInputChange = onInputChange; + return ( - input.onChange(value)} + onChange={value => { + const formattedValue = value; + if (formattedValue && formattedValue.length > 1) { + formattedValue.shift(); + } + + input.onChange(formattedValue); + }} onBlur={() => input.value} isClearable {...optionalProps} diff --git a/app/client/src/components/editorComponents/ApiResponseView.tsx b/app/client/src/components/editorComponents/ApiResponseView.tsx index c581a0f3cf..120357a131 100644 --- a/app/client/src/components/editorComponents/ApiResponseView.tsx +++ b/app/client/src/components/editorComponents/ApiResponseView.tsx @@ -9,7 +9,6 @@ import { AppState } from "reducers"; import { ActionResponse } from "api/ActionAPI"; import { formatBytes } from "utils/helpers"; import { APIEditorRouteParams } from "constants/routes"; -import { ApiPaneReduxState } from "reducers/uiReducers/apiPaneReducer"; import LoadingOverlayScreen from "components/editorComponents/LoadingOverlayScreen"; import CodeEditor from "components/editorComponents/CodeEditor"; import { getActionResponses } from "selectors/entitiesSelector"; @@ -53,7 +52,7 @@ const TableWrapper = styled.div` interface ReduxStateProps { responses: Record; - apiPane: ApiPaneReduxState; + isRunning: Record; } const ResponseHeadersView = (props: { data: Record }) => { @@ -109,14 +108,13 @@ const ApiResponseView = (props: Props) => { params: { apiId }, }, responses, - apiPane, } = props; let response: ActionResponse = EMPTY_RESPONSE; let isRunning = false; let hasFailed = false; if (apiId && apiId in responses) { response = responses[apiId] || EMPTY_RESPONSE; - isRunning = apiPane.isRunning[apiId]; + isRunning = props.isRunning[apiId]; hasFailed = response.statusCode ? response.statusCode[0] !== "2" : false; } @@ -217,7 +215,7 @@ const ApiResponseView = (props: Props) => { const mapStateToProps = (state: AppState): ReduxStateProps => { return { responses: getActionResponses(state), - apiPane: state.ui.apiPane, + isRunning: state.ui.apiPane.isRunning, }; }; diff --git a/app/client/src/components/editorComponents/form/fields/DatasourcesField.tsx b/app/client/src/components/editorComponents/form/fields/DatasourcesField.tsx index fd29624c8c..513cf85604 100644 --- a/app/client/src/components/editorComponents/form/fields/DatasourcesField.tsx +++ b/app/client/src/components/editorComponents/form/fields/DatasourcesField.tsx @@ -1,65 +1,266 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; import CreatableDropdown from "components/designSystems/appsmith/CreatableDropdown"; import { connect } from "react-redux"; -import { Field } from "redux-form"; +import { Field, formValueSelector, change } from "redux-form"; import { AppState } from "reducers"; +import { ReactComponent as StorageIcon } from "assets/icons/menu/storage.svg"; import { DatasourceDataState } from "reducers/entityReducers/datasourceReducer"; import { Plugin } from "api/PluginApi"; import { getDatasourcePlugins } from "selectors/entitiesSelector"; import _ from "lodash"; -import { createDatasource } from "actions/datasourceActions"; +import { createDatasource, storeAsDatasource } from "actions/datasourceActions"; import AnalyticsUtil from "utils/AnalyticsUtil"; +import { Datasource, CreateDatasourceConfig } from "api/DatasourcesApi"; +import styled, { createGlobalStyle } from "styled-components"; +import { MenuItem, Menu, Popover, Position } from "@blueprintjs/core"; +import { IconWrapper } from "constants/IconConstants"; +import { theme } from "constants/DefaultTheme"; +import { ControlIcons } from "icons/ControlIcons"; +import { API_EDITOR_FORM_NAME } from "constants/forms"; +import { InputActionMeta } from "react-select"; +import { setDatasourceFieldText } from "actions/apiPaneActions"; interface ReduxStateProps { datasources: DatasourceDataState; validDatasourcePlugins: Plugin[]; + apiId: string; + value: Datasource; } interface ReduxActionProps { createDatasource: (value: string) => void; + storeAsDatasource: () => void; + changeDatasource: (value: Datasource | CreateDatasourceConfig) => void; + changePath: (value: string) => void; + setDatasourceFieldText: (apiId: string, value: string) => void; } interface ComponentProps { name: string; pluginId: string; appName: string; + datasourceFieldText: string; } +const StyledMenuItem = styled(MenuItem)` + &&&&.bp3-menu-item { + align-items: center; + width: 202px; + justify-content: center; + } +`; + +const StyledMenu = styled(Menu)` + &&&&.bp3-menu { + padding: 8px; + background: #ffffff; + border: 1px solid #ebeff2; + box-sizing: border-box; + box-shadow: 0px 2px 4px rgba(67, 70, 74, 0.14); + border-radius: 4px; + } +`; + +const TooltipStyles = createGlobalStyle` + .helper-tooltip{ + .bp3-popover { + margin-right: 10px; + margin-top: 5px; + } + } +`; + const DatasourcesField = ( props: ReduxActionProps & ReduxStateProps & ComponentProps, ) => { - const options = props.datasources.list - .filter(r => r.pluginId === props.pluginId) - .filter(r => - props.validDatasourcePlugins.some(plugin => plugin.id === r.pluginId), - ) - .filter(r => r.datasourceConfiguration) - .filter(r => r.datasourceConfiguration.url) - .map(r => ({ - label: r.datasourceConfiguration?.url.endsWith("/") - ? r.datasourceConfiguration?.url.slice(0, -1) - : r.datasourceConfiguration.url, - value: r.id, - })); + const [inputValue, setValue] = useState(props.datasourceFieldText); + + useEffect(() => { + setValue(props.datasourceFieldText); + }, [props.datasourceFieldText]); + + const options = React.useMemo(() => { + return props.datasources.list + .filter(r => r.pluginId === props.pluginId) + .filter(r => { + return props.validDatasourcePlugins.some( + plugin => plugin.id === r.pluginId, + ); + }) + .filter(r => r.datasourceConfiguration) + .filter(r => r.datasourceConfiguration.url) + .map(r => ({ + label: r.datasourceConfiguration?.url, + value: r.id, + })); + }, [props.datasources.list, props.validDatasourcePlugins, props.pluginId]); + + const { storeAsDatasource } = props; + let isEmbeddedDatasource = true; + + if (props.value && props.value.id) { + isEmbeddedDatasource = false; + } else if (props.value && props.value.datasourceConfiguration) { + isEmbeddedDatasource = true; + } + + const DropdownIndicator = (props: any) => { + if (props.hasValue) return null; + + const MenuContainer = ( + + + + + } + text="Store as datasource" + onClick={storeAsDatasource} + /> + + ); + + return ( + <> + + + { + e.stopPropagation(); + }} + > + + + + > + ); + }; return ( null, + IndicatorSeparator: () => null, + DropdownIndicator, + }} placeholder="https://.com" - onCreateOption={props.createDatasource} - format={(value: string) => _.find(options, { value }) || ""} - parse={(option: { value: string }) => (option ? option.value : null)} - formatCreateLabel={(value: string) => `Create data source "${value}"`} - noOptionsMessage={() => "No data sources created"} + onInputChange={(value: string, actionMeta: InputActionMeta) => { + const { action } = actionMeta; + if (action === "input-blur") { + props.setDatasourceFieldText(props.apiId, inputValue); + + return value; + } else if (action === "set-value") { + setValue(""); + + return ""; + } else if (action === "menu-close") { + return value; + } + setValue(value); + if (isEmbeddedDatasource) { + let datasourcePayload: Datasource | CreateDatasourceConfig; + let pathPayload: string; + + try { + const url = new URL(value); + const path = url.pathname === "/" ? "" : url.pathname; + const params = url.search; + const baseUrl = url.origin; + + datasourcePayload = { + name: baseUrl, + datasourceConfiguration: { + url: baseUrl, + }, + pluginId: props.pluginId, + appName: props.appName, + }; + pathPayload = path + params; + } catch (e) { + datasourcePayload = { + name: value, + datasourceConfiguration: { + url: value, + }, + pluginId: props.pluginId, + appName: props.appName, + }; + pathPayload = ""; + } + + const updateValues = _.debounce(() => { + props.changeDatasource(datasourcePayload); + props.changePath(pathPayload); + }, 50); + + updateValues(); + } else { + const updatePath = _.debounce(() => { + props.changePath(value); + }, 50); + + updatePath(); + } + }} + format={(value: Datasource) => { + if (!value || !value.datasourceConfiguration) return ""; + + if (!value.id) { + return ""; + } + + const option = _.find(options, { value: value.id }); + + return option ? [option] : ""; + }} + parse={(option: { value: string }[]) => { + if (!option) return null; + + if (option.length) { + const datasources = props.datasources.list; + + return datasources.find( + datasource => datasource.id === option[0].value, + ); + } + }} + inputValue={inputValue} + noOptionsMessage={() => null} /> ); }; -const mapStateToProps = (state: AppState): ReduxStateProps => ({ - datasources: state.entities.datasources, - validDatasourcePlugins: getDatasourcePlugins(state), -}); +const mapStateToProps = (state: AppState): ReduxStateProps => { + const selector = formValueSelector(API_EDITOR_FORM_NAME); + const apiId = selector(state, "id"); + const datasource = selector(state, "datasource"); + return { + datasources: state.entities.datasources, + validDatasourcePlugins: getDatasourcePlugins(state), + apiId, + value: datasource, + }; +}; const mapDispatchToProps = ( dispatch: any, @@ -88,6 +289,16 @@ const mapDispatchToProps = ( }), ); }, + storeAsDatasource: () => dispatch(storeAsDatasource()), + changeDatasource: value => { + dispatch(change(API_EDITOR_FORM_NAME, "datasource", value)); + }, + changePath: (value: string) => { + dispatch(change(API_EDITOR_FORM_NAME, "actionConfiguration.path", value)); + }, + setDatasourceFieldText: (apiId, value) => { + dispatch(setDatasourceFieldText(apiId, value)); + }, }); export default connect(mapStateToProps, mapDispatchToProps)(DatasourcesField); diff --git a/app/client/src/constants/ReduxActionConstants.tsx b/app/client/src/constants/ReduxActionConstants.tsx index 6e93b5070e..c18c6f6d8b 100644 --- a/app/client/src/constants/ReduxActionConstants.tsx +++ b/app/client/src/constants/ReduxActionConstants.tsx @@ -77,6 +77,10 @@ export const ReduxActionTypes: { [key: string]: string } = { FETCH_PUBLISHED_PAGE_SUCCESS: "FETCH_PUBLISHED_PAGE_SUCCESS", DELETE_DATASOURCE_INIT: "DELETE_DATASOURCE_INIT", DELETE_DATASOURCE_SUCCESS: "DELETE_DATASOURCE_SUCCESS", + STORE_AS_DATASOURCE_INIT: "STORE_AS_DATASOURCE_INIT", + STORE_AS_DATASOURCE_UPDATE: "STORE_AS_DATASOURCE_UPDATE", + STORE_AS_DATASOURCE_COMPLETE: "STORE_AS_DATASOURCE_COMPLETE", + SET_DATASOURCE_FIELD_TEXT: "SET_DATASOURCE_FIELD_TEXT", PUBLISH_APPLICATION_INIT: "PUBLISH_APPLICATION_INIT", PUBLISH_APPLICATION_SUCCESS: "PUBLISH_APPLICATION_SUCCESS", CREATE_PAGE_INIT: "CREATE_PAGE_INIT", diff --git a/app/client/src/pages/Editor/APIEditor/Form.tsx b/app/client/src/pages/Editor/APIEditor/Form.tsx index 0b2639df6d..3f4b402f0d 100644 --- a/app/client/src/pages/Editor/APIEditor/Form.tsx +++ b/app/client/src/pages/Editor/APIEditor/Form.tsx @@ -9,29 +9,26 @@ import { import { HTTP_METHOD_OPTIONS, HTTP_METHODS, - CONTENT_TYPE, } from "constants/ApiEditorConstants"; import styled from "styled-components"; -import PostBodyData from "./PostBodyData"; import FormLabel from "components/editorComponents/FormLabel"; import FormRow from "components/editorComponents/FormRow"; import { BaseButton } from "components/designSystems/blueprint/ButtonComponent"; import { RestAction, PaginationField } from "api/ActionAPI"; import { ReduxActionTypes } from "constants/ReduxActionConstants"; import TextField from "components/editorComponents/form/fields/TextField"; -import DynamicTextField from "components/editorComponents/form/fields/DynamicTextField"; import DropdownField from "components/editorComponents/form/fields/DropdownField"; import DatasourcesField from "components/editorComponents/form/fields/DatasourcesField"; -import KeyValueFieldArray from "components/editorComponents/form/fields/KeyValueFieldArray"; -import ApiResponseView from "components/editorComponents/ApiResponseView"; import { API_EDITOR_FORM_NAME } from "constants/forms"; import LoadingOverlayScreen from "components/editorComponents/LoadingOverlayScreen"; -import { FormIcons } from "icons/FormIcons"; -import { BaseTabbedView } from "components/designSystems/appsmith/TabbedView"; import Pagination, { PaginationType } from "./Pagination"; import { Icon } from "@blueprintjs/core"; import { HelpMap, HelpBaseURL } from "constants/HelpConstants"; import CollapsibleHelp from "components/designSystems/appsmith/help/CollapsibleHelp"; +import { BaseTabbedView } from "components/designSystems/appsmith/TabbedView"; +import KeyValueFieldArray from "components/editorComponents/form/fields/KeyValueFieldArray"; +import PostBodyData from "./PostBodyData"; +import ApiResponseView from "components/editorComponents/ApiResponseView"; const Form = styled.form` display: flex; @@ -59,23 +56,6 @@ const MainConfiguration = styled.div` padding-left: 17px; `; -const SecondaryWrapper = styled.div` - display: flex; - height: 100%; - border-top: 1px solid #d0d7dd; - margin-top: 15px; -`; - -const RequestParamsWrapper = styled.div` - flex: 4; - border-right: 1px solid #d0d7dd; - height: 100%; - overflow-y: auto; - padding-top: 6px; - padding-left: 17px; - padding-right: 10px; -`; - const ActionButtons = styled.div` flex: 1; `; @@ -88,13 +68,15 @@ const ActionButton = styled(BaseButton)` } `; -const HeadersSection = styled.div` - margin-bottom: 32px; -`; - const DatasourceWrapper = styled.div` width: 100%; - max-width: 320px; +`; + +const SecondaryWrapper = styled.div` + display: flex; + height: 100%; + border-top: 1px solid #d0d7dd; + margin-top: 15px; `; const TabbedViewContainer = styled.div` @@ -113,6 +95,19 @@ const StyledOpenDocsIcon = styled(Icon)` height: 18px; } `; +const RequestParamsWrapper = styled.div` + flex: 4; + border-right: 1px solid #d0d7dd; + height: 100%; + overflow-y: auto; + padding-top: 6px; + padding-left: 17px; + padding-right: 10px; +`; + +const HeadersSection = styled.div` + margin-bottom: 32px; +`; interface APIFormProps { pluginId: string; @@ -126,18 +121,15 @@ interface APIFormProps { isDeleting: boolean; paginationType: PaginationType; appName: string; - actionConfiguration?: any; httpMethodFromForm: string; actionConfigurationBody: object | string; actionConfigurationHeaders?: any; - contentType: { - key: string; - value: string; - }; + apiId: string; location: { pathname: string; }; dispatch: any; + datasourceFieldText: string; } type Props = APIFormProps & InjectedFormProps; @@ -153,15 +145,13 @@ const ApiEditorForm: React.FC = (props: Props) => { isDeleting, isRunning, isSaving, - actionConfiguration, actionConfigurationHeaders, actionConfigurationBody, - httpMethodFromForm, location, dispatch, + apiId, + httpMethodFromForm, } = props; - const allowPostBody = - httpMethodFromForm && httpMethodFromForm !== HTTP_METHODS[0]; useEffect(() => { dispatch({ type: ReduxActionTypes.SET_LAST_USED_EDITOR_PAGE, @@ -170,6 +160,8 @@ const ApiEditorForm: React.FC = (props: Props) => { }, }); }); + const allowPostBody = + httpMethodFromForm && httpMethodFromForm !== HTTP_METHODS[0]; return ( @@ -219,20 +211,13 @@ const ApiEditorForm: React.FC = (props: Props) => { /> - value.trim()} - singleLine - setMaxHeight - /> @@ -259,9 +244,7 @@ const ApiEditorForm: React.FC = (props: Props) => { @@ -305,24 +288,17 @@ const selector = formValueSelector(API_EDITOR_FORM_NAME); export default connect(state => { const httpMethodFromForm = selector(state, "actionConfiguration.httpMethod"); - const actionConfiguration = selector(state, "actionConfiguration"); const actionConfigurationBody = selector(state, "actionConfiguration.body"); const actionConfigurationHeaders = selector( state, "actionConfiguration.headers", ); - let contentType; - if (actionConfigurationHeaders) { - contentType = actionConfigurationHeaders.find( - (header: any) => header.key.toLowerCase() === CONTENT_TYPE, - ); - } + const apiId = selector(state, "id"); return { + apiId, httpMethodFromForm, - actionConfiguration, actionConfigurationBody, - contentType, actionConfigurationHeaders, }; })( diff --git a/app/client/src/pages/Editor/APIEditor/index.tsx b/app/client/src/pages/Editor/APIEditor/index.tsx index 37274420d2..8a83ec099a 100644 --- a/app/client/src/pages/Editor/APIEditor/index.tsx +++ b/app/client/src/pages/Editor/APIEditor/index.tsx @@ -17,7 +17,6 @@ import { ActionData, ActionDataState, } from "reducers/entityReducers/actionsReducer"; -import { ApiPaneReduxState } from "reducers/uiReducers/apiPaneReducer"; import { REST_PLUGIN_PACKAGE_NAME } from "constants/ApiEditorConstants"; import _ from "lodash"; import { getCurrentApplication } from "selectors/applicationSelectors"; @@ -28,6 +27,7 @@ import { Plugin } from "api/PluginApi"; import styled from "styled-components"; import FeatureFlag from "utils/featureFlags"; import { FeatureFlagsEnum } from "configs/types"; +import { PaginationType } from "./Pagination"; const EmptyStateContainer = styled.div` display: flex; @@ -39,15 +39,19 @@ const EmptyStateContainer = styled.div` interface ReduxStateProps { actions: ActionDataState; - apiPane: ApiPaneReduxState; - formData: RestAction; + isRunning: Record; + isSaving: Record; + isDeleting: Record; + allowSave: boolean; + apiName: string; currentApplication: UserApplication; currentPageName: string | undefined; pages: any; plugins: Plugin[]; pluginId: any; apiAction: RestAction | ActionData | RapidApiAction | undefined; - data: RestAction | ActionData | RapidApiAction | undefined; + paginationType: PaginationType; + datasourceFieldText: string; } interface ReduxActionProps { submitForm: (name: string) => void; @@ -67,35 +71,42 @@ type Props = ReduxActionProps & class ApiEditor extends React.Component { handleSubmit = (values: RestAction) => { - const { formData } = this.props; - this.props.updateAction(formData); + this.props.updateAction(values); }; handleSaveClick = () => { - const pageName = getPageName(this.props.pages, this.props.formData.pageId); + const pageName = getPageName( + this.props.pages, + this.props.match.params.pageId, + ); AnalyticsUtil.logEvent("SAVE_API_CLICK", { - apiName: this.props.formData.name, + apiName: this.props.apiName, apiID: this.props.match.params.apiId, pageName: pageName, }); this.props.submitForm(API_EDITOR_FORM_NAME); }; + handleDeleteClick = () => { - const pageName = getPageName(this.props.pages, this.props.formData.pageId); + const pageName = getPageName( + this.props.pages, + this.props.match.params.pageId, + ); AnalyticsUtil.logEvent("DELETE_API_CLICK", { - apiName: this.props.formData.name, + apiName: this.props.apiName, apiID: this.props.match.params.apiId, pageName: pageName, }); - this.props.deleteAction( - this.props.match.params.apiId, - this.props.formData.name, - ); + this.props.deleteAction(this.props.match.params.apiId, this.props.apiName); }; + handleRunClick = (paginationField?: PaginationField) => { - const pageName = getPageName(this.props.pages, this.props.formData.pageId); + const pageName = getPageName( + this.props.pages, + this.props.match.params.pageId, + ); AnalyticsUtil.logEvent("RUN_API_CLICK", { - apiName: this.props.formData.name, + apiName: this.props.apiName, apiID: this.props.match.params.apiId, pageName: pageName, }); @@ -130,13 +141,16 @@ class ApiEditor extends React.Component { render() { const { - apiPane, match: { params: { apiId }, }, plugins, pluginId, - data, + isSaving, + isRunning, + isDeleting, + allowSave, + paginationType, } = this.props; let formUiComponent: string | undefined; @@ -148,8 +162,6 @@ class ApiEditor extends React.Component { } } - const { isSaving, isRunning, isDeleting, drafts } = apiPane; - const paginationType = _.get(data, "actionConfiguration.paginationType"); const apiHomeScreen = ( { {formUiComponent === "ApiEditorForm" && ( { onSaveClick={this.handleSaveClick} onDeleteClick={this.handleDeleteClick} onRunClick={this.handleRunClick} + datasourceFieldText={this.props.datasourceFieldText} appName={ this.props.currentApplication ? this.props.currentApplication.name @@ -197,7 +210,7 @@ class ApiEditor extends React.Component { {formUiComponent === "RapidApiEditorForm" && ( { const formData = getFormValues(API_EDITOR_FORM_NAME)(state) as RestAction; const apiAction = getActionById(state, props); - const { drafts } = state.ui.apiPane; + const { drafts, isSaving, isDeleting, isRunning } = state.ui.apiPane; let data: RestAction | ActionData | RapidApiAction | undefined; + let allowSave; if (apiAction && apiAction.id in drafts) { data = drafts[apiAction.id]; + allowSave = true; } else { data = apiAction; + allowSave = false; } + const datasourceFieldText = + state.ui.apiPane.datasourceFieldText[formData?.id ?? ""] || ""; return { + datasourceFieldText, actions: state.entities.actions, - apiPane: state.ui.apiPane, currentApplication: getCurrentApplication(state), currentPageName: getCurrentPageName(state), pages: state.entities.pageList.pages, - formData, - data, + apiName: formData?.name || "", plugins: state.entities.plugins.list, pluginId: _.get(data, "pluginId"), + paginationType: _.get(data, "actionConfiguration.paginationType"), apiAction, + isSaving, + isRunning, + isDeleting, + allowSave, }; }; diff --git a/app/client/src/pages/Editor/DataSourceEditor/DBForm.tsx b/app/client/src/pages/Editor/DataSourceEditor/DBForm.tsx index 86a626fbc5..8f2f3e9911 100644 --- a/app/client/src/pages/Editor/DataSourceEditor/DBForm.tsx +++ b/app/client/src/pages/Editor/DataSourceEditor/DBForm.tsx @@ -41,6 +41,7 @@ interface DatasourceDBEditorProps { isTesting: boolean; loadingFormConfigs: boolean; formConfig: []; + isNewDatasource: boolean; } interface DatasourceDBEditorState { @@ -327,7 +328,7 @@ class DatasourceDBEditor extends React.Component< {!_.isNil(sections) diff --git a/app/client/src/pages/Editor/DataSourceEditor/index.tsx b/app/client/src/pages/Editor/DataSourceEditor/index.tsx index fb2aa2c7ab..2a9d868b6f 100644 --- a/app/client/src/pages/Editor/DataSourceEditor/index.tsx +++ b/app/client/src/pages/Editor/DataSourceEditor/index.tsx @@ -26,6 +26,7 @@ interface ReduxStateProps { formConfig: []; loadingFormConfigs: boolean; isDeleting: boolean; + newDatasource: string; } type Props = ReduxStateProps & @@ -58,6 +59,7 @@ class DataSourceEditor extends React.Component { formConfig, isDeleting, deleteDatasource, + newDatasource, } = this.props; return ( @@ -69,6 +71,7 @@ class DataSourceEditor extends React.Component { isSaving={isSaving} isTesting={isTesting} isDeleting={isDeleting} + isNewDatasource={newDatasource === datasourceId} onSubmit={this.handleSubmit} onSave={this.handleSave} onTest={this.props.testDatasource} @@ -112,6 +115,7 @@ const mapStateToProps = (state: AppState): ReduxStateProps => { isTesting: datasources.isTesting, formConfig: formConfigs[datasourcePane.selectedPlugin] || [], loadingFormConfigs, + newDatasource: datasourcePane.newDatasource, }; }; diff --git a/app/client/src/reducers/uiReducers/apiPaneReducer.ts b/app/client/src/reducers/uiReducers/apiPaneReducer.ts index f5aeafba56..bbd45aa78d 100644 --- a/app/client/src/reducers/uiReducers/apiPaneReducer.ts +++ b/app/client/src/reducers/uiReducers/apiPaneReducer.ts @@ -18,6 +18,7 @@ const initialState: ApiPaneReduxState = { lastUsedEditorPage: "", lastSelectedPage: "", extraformData: {}, + datasourceFieldText: {}, }; export interface ApiPaneReduxState { @@ -29,6 +30,7 @@ export interface ApiPaneReduxState { isDeleting: Record; currentCategory: string; lastUsedEditorPage: string; + datasourceFieldText: Record; lastSelectedPage: string; extraformData: Record; } @@ -201,6 +203,19 @@ const apiPaneReducer = createReducer(initialState, { }, }; }, + [ReduxActionTypes.SET_DATASOURCE_FIELD_TEXT]: ( + state: ApiPaneReduxState, + action: ReduxAction<{ apiId: string; value: string }>, + ) => { + const { apiId } = action.payload; + return { + ...state, + datasourceFieldText: { + ...state.datasourceFieldText, + [apiId]: action.payload.value, + }, + }; + }, }); export default apiPaneReducer; diff --git a/app/client/src/reducers/uiReducers/datasourcePaneReducer.ts b/app/client/src/reducers/uiReducers/datasourcePaneReducer.ts index 5a84c73649..871fa5e56b 100644 --- a/app/client/src/reducers/uiReducers/datasourcePaneReducer.ts +++ b/app/client/src/reducers/uiReducers/datasourcePaneReducer.ts @@ -8,12 +8,21 @@ const initialState: DatasourcePaneReduxState = { selectedPlugin: "", datasourceRefs: {}, drafts: {}, + actionRouteInfo: {}, + newDatasource: "", }; export interface DatasourcePaneReduxState { selectedPlugin: string; datasourceRefs: {}; drafts: Record; + actionRouteInfo: Partial<{ + apiId: string; + datasourceId: string; + pageId: string; + applicationId: string; + }>; + newDatasource: string; } const datasourcePaneReducer = createReducer(initialState, { @@ -64,6 +73,43 @@ const datasourcePaneReducer = createReducer(initialState, { ...state, drafts: _.omit(state.drafts, action.payload.id), }), + [ReduxActionTypes.STORE_AS_DATASOURCE_UPDATE]: ( + state: DatasourcePaneReduxState, + action: ReduxAction<{ + apiId: string; + datasourceId: string; + pageId: string; + applicationId: string; + }>, + ) => { + return { + ...state, + actionRouteInfo: action.payload, + }; + }, + [ReduxActionTypes.STORE_AS_DATASOURCE_COMPLETE]: ( + state: DatasourcePaneReduxState, + ) => ({ + ...state, + actionRouteInfo: {}, + }), + [ReduxActionTypes.CREATE_DATASOURCE_SUCCESS]: ( + state: DatasourcePaneReduxState, + action: ReduxAction<{ id: string }>, + ) => { + return { + ...state, + newDatasource: action.payload.id, + }; + }, + [ReduxActionTypes.UPDATE_DATASOURCE_SUCCESS]: ( + state: DatasourcePaneReduxState, + ) => { + return { + ...state, + newDatasource: "", + }; + }, }); export default datasourcePaneReducer; diff --git a/app/client/src/sagas/ApiPaneSagas.ts b/app/client/src/sagas/ApiPaneSagas.ts index f5603f5a0b..7817502006 100644 --- a/app/client/src/sagas/ApiPaneSagas.ts +++ b/app/client/src/sagas/ApiPaneSagas.ts @@ -34,7 +34,7 @@ import { initialize, autofill, change } from "redux-form"; import { getAction } from "./ActionSagas"; import { AppState } from "reducers"; import { Property, RestAction } from "api/ActionAPI"; -import { changeApi } from "actions/apiPaneActions"; +import { changeApi, setDatasourceFieldText } from "actions/apiPaneActions"; import { API_PATH_START_WITH_SLASH_ERROR, FIELD_REQUIRED_ERROR, @@ -147,6 +147,28 @@ function* syncApiParamsSaga( `${currentPath}${paramsString}`, ), ); + + if ( + actionPayload.type === ReduxFormActionTypes.VALUE_CHANGE || + actionPayload.type === ReduxFormActionTypes.ARRAY_REMOVE + ) { + if (values.datasource && values.datasource.id) { + yield put( + setDatasourceFieldText(values.id, `${currentPath}${paramsString}`), + ); + } else if ( + values.datasource && + values.datasource.datasourceConfiguration + ) { + yield put( + setDatasourceFieldText( + values.id, + values.datasource.datasourceConfiguration.url + + `${currentPath}${paramsString}`, + ), + ); + } + } } } @@ -191,13 +213,6 @@ function* changeApiSaga(actionPayload: ReduxAction<{ id: string }>) { data = draft; } - if (data.actionConfiguration.path) { - if (data.actionConfiguration.path.charAt(0) === "/") - data.actionConfiguration.path = data.actionConfiguration.path.substring( - 1, - ); - } - if ( data.actionConfiguration.httpMethod !== "GET" && !data.providerId && diff --git a/app/client/src/sagas/CurlImportSagas.ts b/app/client/src/sagas/CurlImportSagas.ts index 67c8b1e4b2..d4ae3f48eb 100644 --- a/app/client/src/sagas/CurlImportSagas.ts +++ b/app/client/src/sagas/CurlImportSagas.ts @@ -1,11 +1,9 @@ -import { takeLatest, put, all, select } from "redux-saga/effects"; -import { initialize } from "redux-form"; +import { takeLatest, put, all, select, take } from "redux-saga/effects"; import { ReduxActionTypes, ReduxActionErrorTypes, ReduxAction, } from "constants/ReduxActionConstants"; -import { API_EDITOR_FORM_NAME } from "constants/forms"; import { validateResponse } from "sagas/ErrorSagas"; import CurlImportApi, { CurlImportRequest } from "api/ImportApi"; import { ApiResponse } from "api/ApiResponses"; @@ -13,14 +11,10 @@ import AnalyticsUtil from "utils/AnalyticsUtil"; import { AppToaster } from "components/editorComponents/ToastComponent"; import { ToastType } from "react-toastify"; import { CURL_IMPORT_SUCCESS } from "constants/messages"; -import { API_EDITOR_ID_URL } from "constants/routes"; -import history from "utils/history"; -import { - getCurrentApplicationId, - getCurrentPageId, -} from "selectors/editorSelectors"; +import { getCurrentApplicationId } from "selectors/editorSelectors"; import { fetchActions } from "actions/actionActions"; import { CURL } from "constants/ApiConstants"; +import { changeApi } from "actions/apiPaneActions"; export function* curlImportSaga(action: ReduxAction) { const { type, pageId, name } = action.payload; @@ -35,12 +29,16 @@ export function* curlImportSaga(action: ReduxAction) { const response: ApiResponse = yield CurlImportApi.curlImport(request); const isValidResponse = yield validateResponse(response); const applicationId = yield select(getCurrentApplicationId); - const currentPageId = yield select(getCurrentPageId); if (isValidResponse) { AnalyticsUtil.logEvent("IMPORT_API", { importSource: CURL, }); + + yield put(fetchActions(applicationId)); + const data = { ...response.data }; + yield take(ReduxActionTypes.FETCH_ACTIONS_SUCCESS); + AppToaster.show({ message: CURL_IMPORT_SUCCESS, type: ToastType.SUCCESS, @@ -49,12 +47,8 @@ export function* curlImportSaga(action: ReduxAction) { type: ReduxActionTypes.SUBMIT_CURL_FORM_SUCCESS, payload: response.data, }); - yield put(fetchActions(applicationId)); - const data = { ...response.data }; - yield put(initialize(API_EDITOR_FORM_NAME, data)); - history.push( - API_EDITOR_ID_URL(applicationId, currentPageId, response.data.id), - ); + + yield put(changeApi(data.id)); } } catch (error) { yield put({ diff --git a/app/client/src/sagas/DatasourcesSagas.ts b/app/client/src/sagas/DatasourcesSagas.ts index 4579e3931e..95cf92539e 100644 --- a/app/client/src/sagas/DatasourcesSagas.ts +++ b/app/client/src/sagas/DatasourcesSagas.ts @@ -1,4 +1,4 @@ -import { all, put, takeEvery, select, call } from "redux-saga/effects"; +import { all, put, takeEvery, select, call, take } from "redux-saga/effects"; import { change, initialize, getFormValues } from "redux-form"; import _ from "lodash"; import { @@ -18,7 +18,11 @@ import { getDatasource, getDatasourceDraft, } from "selectors/entitiesSelector"; -import { selectPlugin } from "actions/datasourceActions"; +import { + selectPlugin, + createDatasource, + changeDatasource, +} from "actions/datasourceActions"; import { fetchPluginForm } from "actions/pluginActions"; import { GenericApiResponse } from "api/ApiResponses"; import DatasourcesApi, { @@ -26,6 +30,7 @@ import DatasourcesApi, { Datasource, } from "api/DatasourcesApi"; import PluginApi, { DatasourceForm } from "api/PluginApi"; + import { DATA_SOURCES_EDITOR_ID_URL, DATA_SOURCES_EDITOR_URL, @@ -37,6 +42,7 @@ import AnalyticsUtil from "utils/AnalyticsUtil"; import { AppToaster } from "components/editorComponents/ToastComponent"; import { ToastType } from "react-toastify"; import { getFormData } from "selectors/formSelectors"; +import { changeApi, setDatasourceFieldText } from "actions/apiPaneActions"; function* fetchDatasourcesSaga() { try { @@ -73,9 +79,7 @@ function* createDatasourceSaga( type: ReduxActionTypes.CREATE_DATASOURCE_SUCCESS, payload: response.data, }); - yield put( - change(API_EDITOR_FORM_NAME, "datasource.id", response.data.id), - ); + yield put(change(API_EDITOR_FORM_NAME, "datasource", response.data)); } } catch (error) { yield put({ @@ -344,6 +348,53 @@ function* formValueChangeSaga( yield all([call(updateDraftsSaga)]); } +function* storeAsDatasourceSaga() { + const { values } = yield select(getFormData, API_EDITOR_FORM_NAME); + const applicationId = yield select(getCurrentApplicationId); + const pageId = yield select(getCurrentPageId); + const datasource = _.get(values, "datasource"); + + history.push(DATA_SOURCES_EDITOR_URL(applicationId, pageId)); + + yield put(createDatasource(datasource)); + const createDatasourceSuccessAction = yield take( + ReduxActionTypes.CREATE_DATASOURCE_SUCCESS, + ); + const createdDatasource = createDatasourceSuccessAction.payload; + + yield put({ + type: ReduxActionTypes.STORE_AS_DATASOURCE_UPDATE, + payload: { + pageId, + applicationId, + apiId: values.id, + datasourceId: createdDatasource.id, + }, + }); + + yield put(changeDatasource(createdDatasource)); +} + +function* updateDatasourceSuccessSaga(action: ReduxAction) { + const state = yield select(); + const actionRouteInfo = _.get(state, "ui.datasourcePane.actionRouteInfo"); + const updatedDatasource = action.payload; + + if ( + actionRouteInfo && + updatedDatasource.id === actionRouteInfo.datasourceId + ) { + const { apiId } = actionRouteInfo; + + yield put(setDatasourceFieldText(apiId, "")); + yield put(changeApi(apiId)); + } + + yield put({ + type: ReduxActionTypes.STORE_AS_DATASOURCE_COMPLETE, + }); +} + export function* watchDatasourcesSagas() { yield all([ takeEvery(ReduxActionTypes.FETCH_DATASOURCES_INIT, fetchDatasourcesSaga), @@ -356,6 +407,11 @@ export function* watchDatasourcesSagas() { takeEvery(ReduxActionTypes.TEST_DATASOURCE_INIT, testDatasourceSaga), takeEvery(ReduxActionTypes.DELETE_DATASOURCE_INIT, deleteDatasourceSaga), takeEvery(ReduxActionTypes.CHANGE_DATASOURCE, changeDatasourceSaga), + takeEvery(ReduxActionTypes.STORE_AS_DATASOURCE_INIT, storeAsDatasourceSaga), + takeEvery( + ReduxActionTypes.UPDATE_DATASOURCE_SUCCESS, + updateDatasourceSuccessSaga, + ), // Intercepting the redux-form change actionType takeEvery(ReduxFormActionTypes.VALUE_CHANGE, formValueChangeSaga), ]); diff --git a/app/client/src/selectors/entitiesSelector.ts b/app/client/src/selectors/entitiesSelector.ts index 6ac1d16d87..b128320a62 100644 --- a/app/client/src/selectors/entitiesSelector.ts +++ b/app/client/src/selectors/entitiesSelector.ts @@ -154,11 +154,9 @@ export const getQueryActions = (state: AppState): ActionDataState => { const getCurrentPageId = (state: AppState) => state.entities.pageList.currentPageId; -export const getDatasourcePlugins = (state: AppState) => { - return state.entities.plugins.list.filter( - plugin => plugin?.allowUserDatasources ?? true, - ); -}; +export const getDatasourcePlugins = createSelector(getPlugins, plugins => { + return plugins.filter(plugin => plugin?.allowUserDatasources ?? true); +}); export const getActionsForCurrentPage = createSelector( getCurrentPageId, @@ -169,13 +167,12 @@ export const getActionsForCurrentPage = createSelector( }, ); -export const getActionResponses = ( - state: AppState, -): Record => { +export const getActionResponses = createSelector(getActions, actions => { const responses: Record = {}; - state.entities.actions.forEach(a => { + + actions.forEach(a => { responses[a.config.id] = a.data; }); return responses; -}; +});