fix: API editor query parameters not auto-updating URL (#40133)

# API Editor Query Parameters URL Auto-Update Fix

## Issue
When adding or editing query parameters in the API editor, the URL
wasn't automatically being updated to reflect these changes. This
creates a disconnect between the parameters in the Params tab and the
actual URL being used, as reported in issue #40045.

## Fix
Implemented proper synchronization between query parameters and the URL
in the API editor using a two-pronged approach:

1. **Component-level solution** with React hooks:
- Added a `useSyncParamsToPath` hook that handles bidirectional
synchronization
   - Ensures sync happens on component mount and when values change
   - Prevents update loops by checking if changes are needed

2. **Redux saga enhancement**:
- Enhanced `syncApiParamsSaga` to ensure the action model is updated
when params change
   - Provides a failsafe at the data layer in addition to the UI layer

These changes ensure:
- When query parameters are modified in the Params tab, the URL now
updates automatically
- When the URL is modified with query parameters, they are correctly
extracted to the Params tab
- Synchronization happens proactively rather than just reactively

## Changes
- Added new React hook for bidirectional synchronization
- Updated `syncApiParamsSaga` to handle API action data updates
- Addressed potential edge cases like empty parameters and update loops

## Test Strategy
No new test cases were added for the following reasons:
- This fix primarily addresses UI synchronization between two components
- The fix is easily verified through manual testing
- The functionality is already covered by existing integration tests for
the API editor
- The changes are focused on visual state synchronization rather than
core business logic
- The bug is easily reproduced and fixed by the UI components working
together correctly

## Related Issue
Fixes #40045

## Automation

/ok-to-test tags="@tag.Datasource"

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/14332565088>
> Commit: 6de39d58842493a4eb514278a7c6e8dc458dc635
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=14332565088&attempt=1"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.Datasource`
> Spec:
> <hr>Tue, 08 Apr 2025 12:29:38 UTC
<!-- end of auto-generated comment: Cypress test results  -->


## Communication
Should the DevRel and Marketing teams inform users about this change?
- [ ] Yes
- [x] No

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Improved synchronization between URL parameters and form inputs,
ensuring real-time, consistent updates during user interactions.
- Enhanced the API configuration interface by automatically reflecting
changes to action parameters for a smoother experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Aman Agarwal <aman@appsmith.com>
This commit is contained in:
vivek-appsmith 2025-04-11 16:43:26 +05:30 committed by GitHub
parent 6b6faccde2
commit abc9c71c3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 197 additions and 1 deletions

View File

@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect, useRef, useCallback } from "react";
import type { ControlType } from "constants/PropertyControlConstants";
import FormControl from "pages/Editor/FormControl";
import { Grid, Tabs, TabPanel, TabsList, Tab, Flex } from "@appsmith/ads";
@ -7,6 +7,14 @@ import { HTTP_METHOD } from "PluginActionEditor/constants/CommonApiConstants";
import { API_EDITOR_TAB_TITLES } from "ee/constants/messages";
import { createMessage } from "ee/constants/messages";
import styled from "styled-components";
import { useDispatch, useSelector } from "react-redux";
import { getFormData } from "selectors/formSelectors";
import { parseUrlForQueryParams, queryParamsRegEx } from "utils/ApiPaneUtils";
import { autofill } from "redux-form";
import { setActionProperty } from "actions/pluginActionActions";
import type { Property } from "api/ActionAPI";
import get from "lodash/get";
import isEqual from "lodash/isEqual";
enum CUSTOM_ACTION_TABS {
HEADERS = "HEADERS",
@ -37,7 +45,186 @@ const TabbedWrapper = styled(Tabs)`
}
`;
// Helper function to check if two arrays of params are functionally equivalent
const areParamsEquivalent = (
params1: Property[],
params2: Property[],
): boolean => {
if (params1.length !== params2.length) return false;
// Create a map of key-value pairs for easier comparison
const paramsMap1 = params1.reduce(
(map, param) => {
if (param.key) map[param.key] = param.value;
return map;
},
{} as Record<string, unknown>,
);
const paramsMap2 = params2.reduce(
(map, param) => {
if (param.key) map[param.key] = param.value;
return map;
},
{} as Record<string, unknown>,
);
return isEqual(paramsMap1, paramsMap2);
};
// Hook to sync query parameters with URL path in both directions
const useSyncParamsToPath = (formName: string, configProperty: string) => {
const dispatch = useDispatch();
const formValues = useSelector((state) => getFormData(state, formName));
// Refs to track the last values to prevent infinite loops
const lastPathRef = useRef("");
const lastParamsRef = useRef<Property[]>([]);
// Extract the sync logic into a separate function so we can call it imperatively
const syncParamsEffect = useCallback(() => {
if (!formValues || !formValues.values) return;
const values = formValues.values;
const actionId = values.id;
if (!actionId) return;
// Correctly access nested properties using lodash's get
const path = get(values, `${configProperty}.path`, "");
const queryParameters = get(values, `${configProperty}.params`, []);
// Early return if nothing has changed
if (
path === lastPathRef.current &&
isEqual(queryParameters, lastParamsRef.current)
) {
return;
}
// Check if params have changed but path hasn't - indicating params tab update
const paramsChanged = !isEqual(queryParameters, lastParamsRef.current);
const pathChanged = path !== lastPathRef.current;
// Only one sync direction per effect execution to prevent loops
// Path changed - update params from path if needed
if (pathChanged) {
// Update refs to reflect current path value before parsing
lastPathRef.current = path;
// Check if we need to extract parameters from the path
const parsedParams = parseUrlForQueryParams(path);
// We want to update params in two cases:
// 1. URL has params and they differ from current params
// 2. URL has no params but we have params in the form (need to clear them)
const urlHasParams = path.includes("?");
const shouldClearParams =
!urlHasParams && queryParameters.some((p: Property) => p.key);
const shouldUpdateParams =
(parsedParams.length > 0 &&
!areParamsEquivalent(parsedParams, queryParameters)) ||
shouldClearParams;
if (shouldUpdateParams) {
// If URL has no params but we have params in the form, clear them
const updatedParams = shouldClearParams ? [] : parsedParams;
// Immediately update both the form and the action model
dispatch(autofill(formName, `${configProperty}.params`, updatedParams));
dispatch(
setActionProperty({
actionId: actionId,
propertyName: `${configProperty}.params`,
value: updatedParams,
}),
);
// Update ref to reflect the change we just made
lastParamsRef.current = updatedParams;
} else {
// Just update the ref without changing anything
lastParamsRef.current = [...queryParameters];
}
return; // Exit to prevent double updates
}
// Params changed - update path from params if needed
if (paramsChanged) {
// Update refs to reflect current params before rebuilding path
lastParamsRef.current = [...queryParameters];
// Extract base path without query parameters
const matchGroups = path.match(queryParamsRegEx) || [];
const currentPath = matchGroups[1] || "";
// Only build params string if we have any valid params
const validParams = queryParameters.filter((p: Property) => p.key);
// If we have valid params, build a new path with those params
if (validParams.length > 0) {
const paramsString = validParams
.map(
(p: Property, i: number) =>
`${i === 0 ? "?" : "&"}${p.key}=${p.value}`,
)
.join("");
// Create new path
const newPath = `${currentPath}${paramsString}`;
// Only update if path is actually different
if (path !== newPath) {
dispatch(autofill(formName, `${configProperty}.path`, newPath));
dispatch(
setActionProperty({
actionId: actionId,
propertyName: `${configProperty}.path`,
value: newPath,
}),
);
// Update ref to reflect the change we just made
lastPathRef.current = newPath;
}
} else {
// If no valid params, remove query part from path if it exists
if (path.includes("?")) {
const newPath = currentPath;
dispatch(autofill(formName, `${configProperty}.path`, newPath));
dispatch(
setActionProperty({
actionId: actionId,
propertyName: `${configProperty}.path`,
value: newPath,
}),
);
// Update ref to reflect the change we just made
lastPathRef.current = newPath;
} else {
// Just update the ref without changing anything
lastPathRef.current = path;
}
}
}
}, [formValues, dispatch, formName, configProperty]);
// Run effect on formValues change
useEffect(() => {
syncParamsEffect();
}, [syncParamsEffect, formValues]);
};
const TabbedControls = (props: ControlProps) => {
// Use the hook to sync params with path
useSyncParamsToPath(props.formName, props.configProperty);
return (
<TabbedWrapper defaultValue={CUSTOM_ACTION_TABS.HEADERS}>
<TabsList>

View File

@ -124,6 +124,15 @@ function* syncApiParamsSaga(
`${currentPath}${paramsString}`,
),
);
// Also update the action property to ensure the path is updated in the action
yield put(
setActionProperty({
actionId: actionId,
propertyName: "actionConfiguration.path",
value: `${currentPath}${paramsString}`,
}),
);
}
}