Fix: ApiEndpointInput component used for rendering OpenAPI spec where values are stored in cache for respective operation. (#13732)

* apiendpoint component used for rendering openapi spec value are persisted

* Fix/stripe old component (#13737)

* fix: integrated old component

* removed log

---------

Co-authored-by: Devanshu Gupta <devanshuguptaknp@gmail.com>
This commit is contained in:
Ganesh Kumar 2025-08-08 22:28:57 +05:30 committed by GitHub
parent f05eb802a9
commit b8b0d57504
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 414 additions and 15 deletions

View file

@ -59,6 +59,7 @@ const ApiEndpointInput = (props) => {
const [loadingSpec, setLoadingSpec] = useState(true);
const [options, setOptions] = useState(props.options);
const [specJson, setSpecJson] = useState(null);
const [operationParams, setOperationParams] = useState({});
// Check if specUrl is an object (multiple specs) or string (single spec)
const isMultiSpec = typeof props.specUrl === 'object' && !Array.isArray(props.specUrl);
@ -78,21 +79,25 @@ const ApiEndpointInput = (props) => {
setSpecJson(data);
if (isMultiSpec) {
// Clear all parameters when switching specs
const queryParams = {
// MODIFIED: Retain existing values instead of clearing them
const currentParams = options?.params || {
path: {},
query: {},
request: {},
};
let newOperation = null;
let newPath = null;
// Keep existing values if the operation/path still exists in the new spec
let newOperation = options?.operation;
let newPath = options?.path;
let newSelectedOperation = null;
if (options?.path && options?.operation && data?.paths?.[options.path]?.[options.operation]) {
newOperation = options.operation;
newPath = options.path;
newSelectedOperation = data.paths[options.path][options.operation];
// Validate if the current operation/path exists in the new spec
if (newPath && newOperation && data?.paths?.[newPath]?.[newOperation]) {
newSelectedOperation = data.paths[newPath][newOperation];
} else {
// Only clear if the operation/path doesn't exist in the new spec
newOperation = null;
newPath = null;
}
const newOptions = {
@ -100,7 +105,8 @@ const ApiEndpointInput = (props) => {
operation: newOperation,
path: newPath,
selectedOperation: newSelectedOperation,
params: queryParams,
params: currentParams, // Retain existing params
specType: specUrlOrType,
};
setOptions(newOptions);
@ -112,12 +118,24 @@ const ApiEndpointInput = (props) => {
});
};
const getOperationKey = (operation, path) => {
return `${operation}_${path}`;
};
const changeOperation = (value) => {
const operation = value.split('/', 2)[0];
const path = value.substring(value.indexOf('/'));
// Clear all params when changing operation
const queryParams = {
if (options.operation && options.path) {
const currentOperationKey = getOperationKey(options.operation, options.path);
setOperationParams((prevState) => ({
...prevState,
[currentOperationKey]: options.params,
}));
}
const newOperationKey = getOperationKey(operation, path);
const savedParams = operationParams[newOperationKey] || {
path: {},
query: {},
request: {},
@ -128,7 +146,7 @@ const ApiEndpointInput = (props) => {
path,
operation,
selectedOperation: specJson.paths[path][operation],
params: queryParams,
params: savedParams,
};
setOptions(newOptions);
@ -167,7 +185,13 @@ const ApiEndpointInput = (props) => {
const removeParam = (paramType, paramName) => {
const newOptions = JSON.parse(JSON.stringify(options));
delete newOptions['params'][paramType][paramName];
const newParams = { ...newOptions.params };
const newParamType = { ...newParams[paramType] };
delete newParamType[paramName];
newParams[paramType] = newParamType;
newOptions.params = newParams;
setOptions(newOptions);
props.optionsChanged(newOptions);
};

View file

@ -0,0 +1,361 @@
import React, { useEffect, useState } from 'react';
import { openapiService } from '@/_services';
import Select from '@/_ui/Select';
import { queryManagerSelectComponentStyle } from '@/_ui/Select/styles';
import DOMPurify from 'dompurify';
import { ToolTip } from '@/_components';
import CodeHinter from '@/AppBuilder/CodeEditor';
import { withTranslation } from 'react-i18next';
import { isEmpty } from 'lodash';
import PropTypes from 'prop-types';
import SolidIcons from '@/_ui/Icon/SolidIcons';
const operationColorMapping = {
get: 'azure',
post: 'green',
delete: 'red',
put: 'yellow',
};
const ApiEndpointInput = (props) => {
const [loadingSpec, setLoadingSpec] = useState(true);
const [options, setOptions] = useState(props.options);
const [specJson, setSpecJson] = useState(null);
const fetchOpenApiSpec = () => {
setLoadingSpec(true);
openapiService
.fetchSpecFromUrl(props.specUrl)
.then((response) => response.text())
.then((text) => {
const data = JSON.parse(text);
setSpecJson(data);
setLoadingSpec(false);
});
};
const changeOperation = (value) => {
const operation = value.split('/', 2)[0];
const path = value.substring(value.indexOf('/'));
const newOptions = { ...options, path, operation, selectedOperation: specJson.paths[path][operation] };
setOptions(newOptions);
props.optionsChanged(newOptions);
};
const changeParam = (paramType, paramName, value) => {
if (value === '') {
removeParam(paramType, paramName);
} else {
const newOptions = {
...options,
params: {
...options.params,
[paramType]: {
...options.params[paramType],
[paramName]: value,
},
},
};
setOptions(newOptions);
props.optionsChanged(newOptions);
}
};
const removeParam = (paramType, paramName) => {
const newOptions = JSON.parse(JSON.stringify(options));
delete newOptions['params'][paramType][paramName];
setOptions(newOptions);
props.optionsChanged(newOptions);
};
const renderOperationOption = (data) => {
const path = data.value.substring(data.value.indexOf('/'));
const operation = data.operation;
if (path && operation) {
return (
<div className="row">
<div className="col-auto" style={{ width: '60px' }}>
<span className={`badge bg-${operationColorMapping[operation]}`}>{operation}</span>
</div>
<div className="col">
<span>{path}</span>
</div>
</div>
);
} else {
return 'Select an operation';
}
};
const categorizeOperations = (operation, path, acc, category) => {
const option = {
value: `${operation}${path}`,
label: `${path}`,
name: path,
operation: operation,
};
const existingCategory = acc.find((obj) => obj.label === category);
if (existingCategory) {
existingCategory.options.push(option);
} else {
acc.push({
label: category,
options: [option],
});
}
};
const computeOperationSelectionOptions = () => {
const paths = specJson?.paths;
if (isEmpty(paths)) return [];
const pathGroups = Object.keys(paths).reduce((acc, path) => {
const operations = Object.keys(paths[path]);
const category = path.split('/')[2];
operations.forEach((operation) => categorizeOperations(operation, path, acc, category));
return acc;
}, []);
return pathGroups;
};
useEffect(() => {
const queryParams = {
path: props.options?.params?.path ?? {},
query: props.options?.params?.query ?? {},
request: props.options?.params?.request ?? {},
};
setLoadingSpec(true);
setOptions({ ...props.options, params: queryParams });
fetchOpenApiSpec();
}, []);
return (
<div>
{loadingSpec && (
<div className="p-3">
<div className="spinner-border spinner-border-sm text-azure mx-2" role="status"></div>
{props.t('stripe', 'Please wait while we load the OpenAPI specification.')}
</div>
)}
{options && !loadingSpec && (
<div>
<div className="d-flex g-2">
<div className="col-12 form-label">
<label className="form-label">{props.t('globals.operation', 'Operation')}</label>
</div>
<div className="col stripe-operation-options flex-grow-1" style={{ width: '90px', marginTop: 0 }}>
<Select
options={computeOperationSelectionOptions()}
value={{
operation: options?.operation,
value: `${options?.operation}${options?.path}`,
}}
onChange={(value) => changeOperation(value)}
width={'100%'}
useMenuPortal={true}
customOption={renderOperationOption}
styles={queryManagerSelectComponentStyle(props.darkMode, '100%')}
useCustomStyles={true}
/>
{options?.selectedOperation && (
<small
style={{ margintTop: '10px' }}
className="my-2"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(options?.selectedOperation?.description) }}
/>
)}
</div>
</div>
{options?.selectedOperation && (
<div className={`row stripe-fields-row ${props.darkMode && 'theme-dark'}`}>
<RenderParameterFields
parameters={options?.selectedOperation?.parameters}
type="path"
label={props.t('globals.path', 'PATH')}
options={options}
changeParam={changeParam}
removeParam={removeParam}
darkMode={props.darkMode}
/>
<RenderParameterFields
parameters={options?.selectedOperation?.parameters}
type="query"
label={props.t('globals.query'.toUpperCase(), 'Query')}
options={options}
changeParam={changeParam}
removeParam={removeParam}
darkMode={props.darkMode}
/>
<RenderParameterFields
parameters={
options?.selectedOperation?.requestBody?.content[
Object.keys(options?.selectedOperation?.requestBody?.content)[0]
]?.schema?.properties ?? {}
}
type="request"
label={props.t('globals.requestBody', 'REQUEST BODY')}
options={options}
changeParam={changeParam}
removeParam={removeParam}
darkMode={props.darkMode}
/>
</div>
)}
</div>
)}
</div>
);
};
export default withTranslation()(ApiEndpointInput);
ApiEndpointInput.propTypes = {
options: PropTypes.object,
specUrl: PropTypes.string,
optionsChanged: PropTypes.func,
darkMode: PropTypes.bool,
t: PropTypes.func,
};
const RenderParameterFields = ({ parameters, type, label, options, changeParam, removeParam, darkMode }) => {
let filteredParams;
if (type === 'request') {
filteredParams = Object.keys(parameters);
} else {
filteredParams = parameters?.filter((param) => param.in === type);
}
const paramLabelWithDescription = (param) => {
return (
<ToolTip message={type === 'request' ? DOMPurify.sanitize(parameters[param].description) : param.description}>
<div className="cursor-help">
<input
type="text"
value={type === 'request' ? param : param.name}
className="form-control form-control-underline"
placeholder="key"
disabled
/>
</div>
</ToolTip>
);
};
const paramLabelWithoutDescription = (param) => {
return (
<input
type="text"
value={type === 'request' ? param : param.name}
className="form-control"
placeholder="key"
disabled
/>
);
};
const paramType = (param) => {
return (
<div className="p-2 text-muted">
{type === 'query' &&
param?.schema?.anyOf &&
param?.schema?.anyOf.map((type, i) =>
i < param.schema?.anyOf.length - 1
? type.type.substring(0, 3).toUpperCase() + '|'
: type.type.substring(0, 3).toUpperCase()
)}
{(type === 'path' || (type === 'query' && !param?.schema?.anyOf)) &&
param?.schema?.type?.substring(0, 3).toUpperCase()}
{type === 'request' && parameters[param].type?.substring(0, 3).toUpperCase()}
</div>
);
};
const paramDetails = (param) => {
return (
<div className="col-auto d-flex field field-width-179 align-items-center">
{(type === 'request' && parameters[param].description) || param?.description
? paramLabelWithDescription(param)
: paramLabelWithoutDescription(param)}
{param.required && <span className="text-danger fw-bold">*</span>}
{paramType(param)}
</div>
);
};
const inputField = (param) => {
return (
<CodeHinter
initialValue={(type === 'request' ? options?.params[type][param] : options?.params[type][param.name]) ?? ''}
mode="text"
placeholder={'Value'}
theme={darkMode ? 'monokai' : 'duotone-light'}
lineNumbers={false}
onChange={(value) => {
if (type === 'request') {
changeParam(type, param, value);
} else {
changeParam(type, param.name, value);
}
}}
height={'32px'}
/>
);
};
const clearButton = (param) => {
const handleClear = () => {
if (type === 'request') {
removeParam(type, param);
} else {
removeParam(type, param.name);
}
};
return (
<span
className="code-hinter-clear-btn"
role="button"
onClick={handleClear}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleClear();
}
}}
tabIndex="0"
>
<SolidIcons name="removerectangle" width="20" fill="#ACB2B9" />
</span>
);
};
return (
filteredParams?.length > 0 && (
<div className={`${type === 'request' ? 'request-body' : type}-fields d-flex`}>
<h5 className="text-heading form-label">{label}</h5>
<div className="flex-grow-1 input-group-parent-container">
{filteredParams.map((param) => (
<div className="input-group-wrapper" key={type === 'request' ? param : param.name}>
<div className="input-group">
{paramDetails(param)}
<div className="col field overflow-hidden code-hinter-borderless">{inputField(param)}</div>
{((type === 'request' && options['params'][type][param]) || options['params'][type][param.name]) &&
clearButton(param)}
</div>
</div>
))}
</div>
</div>
)
);
};
RenderParameterFields.propTypes = {
parameters: PropTypes.any,
type: PropTypes.string,
label: PropTypes.string,
options: PropTypes.object,
changeParam: PropTypes.func,
removeParam: PropTypes.func,
darkMode: PropTypes.bool,
};

View file

@ -14,6 +14,7 @@ import GoogleSheets from '@/_components/Googlesheets';
import Slack from '@/_components/Slack';
import Zendesk from '@/_components/Zendesk';
import ApiEndpointInput from '@/_components/ApiEndpointInput';
import ApiEndpointInputOld from './ApiEndpointInputOld';
import { ConditionFilter, CondtionSort, MultiColumn } from '@/_components/MultiConditions';
import ToolJetDbOperations from '@/AppBuilder/QueryManager/QueryEditors/TooljetDatabase/ToolJetDbOperations';
import { orgEnvironmentVariableService, orgEnvironmentConstantService } from '../_services';
@ -210,6 +211,8 @@ const DynamicForm = ({
return CondtionSort;
case 'react-component-api-endpoint':
return ApiEndpointInput;
case 'react-component-api-endpoint-old':
return ApiEndpointInputOld;
case 'react-component-sharepoint':
return Sharepoint;
case 'react-component-oauth':
@ -502,6 +505,13 @@ const DynamicForm = ({
options,
darkMode,
};
case 'react-component-api-endpoint-old':
return {
specUrl: spec_url,
optionsChanged,
options,
darkMode,
};
default:
return {};
}
@ -580,7 +590,11 @@ const DynamicForm = ({
{Object.keys(obj).map((key) => {
const { label, type, encrypted, className, key: propertyKey, shouldRenderTheProperty = '' } = obj[key];
const Element = getElement(type);
const isSpecificComponent = ['tooljetdb-operations', 'react-component-api-endpoint'].includes(type);
const isSpecificComponent = [
'tooljetdb-operations',
'react-component-api-endpoint',
'react-component-api-endpoint-old',
].includes(type);
// shouldRenderTheProperty - key is used for Dynamic connection parameters
const enabled = shouldRenderTheProperty
? selectedDataSource?.options?.[shouldRenderTheProperty]?.value ?? false

View file

@ -8,7 +8,7 @@
"operation": {
"label": "",
"key": "stripe_operation",
"type": "react-component-api-endpoint",
"type": "react-component-api-endpoint-old",
"description": "Single select dropdown for operation",
"spec_url": "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json"
}