mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-09 06:55:03 +00:00
feat(openapi-sync): preserve user-configured request values on sync (#8204)
Reconcile request structure against the spec on sync while preserving the
user's values (JSON body, params, headers, auth) and {{var}} references for
fields that still exist. A "Preserve values" toggle (default on) on the Spec
Updates review controls it; turning it off lets spec values overwrite. The diff
preview's EXPECTED column shows the post-merge result so unchanged values do not
render as changes.
- field-level merge for JSON body (by key path), form fields and params/headers
(by name, duplicate names paired positionally), preserving value + enabled
- {{var}} masking so interpolated bodies parse, merge and restore safely,
using a private-use sentinel that never collides with body text
- auth merged by mode: same mode keeps the user's values and adds spec-introduced
fields; a mode change takes the spec. compareRequestFields compares auth by
mode only, so preserved auth values no longer mark the collection out of sync
- preserveValues threaded through apply and diff-preview IPC handlers
- reset path left unchanged; scripts/tests/assertions preserved in sync and reset
- 67 unit tests covering the merge helpers and masking edge cases
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
IconChevronRight,
|
||||
@@ -15,7 +15,7 @@ import Help from 'components/Help';
|
||||
import EndpointVisualDiff from './EndpointVisualDiff';
|
||||
|
||||
// Expandable row - can be used with or without decision buttons
|
||||
const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectionPath, newSpec, showDecisions = true, decisionLabels, diffLeftLabel, diffRightLabel, swapDiffSides, collectionUid, actions }) => {
|
||||
const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectionPath, newSpec, showDecisions = true, decisionLabels, diffLeftLabel, diffRightLabel, swapDiffSides, collectionUid, actions, preserveValues = true }) => {
|
||||
const dispatch = useDispatch();
|
||||
const rowKey = endpoint.id || `${endpoint.method}-${endpoint.path}`;
|
||||
const isExpanded = useSelector((state) => {
|
||||
@@ -25,9 +25,15 @@ const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectio
|
||||
const [diffData, setDiffData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const loadDiffData = useCallback(async () => {
|
||||
if (diffData) return;
|
||||
// Monotonic id so a superseded in-flight fetch (e.g. the user flips the
|
||||
// Preserve toggle mid-request) can't overwrite the latest result.
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const loadDiffData = useCallback(async () => {
|
||||
// No internal diffData guard: both callers (the expand effect and handleToggle)
|
||||
// already gate on !diffData. Guarding here would capture a stale diffData from
|
||||
// the render that recreated this callback and silently skip the toggle re-fetch.
|
||||
const requestId = ++requestIdRef.current;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -36,20 +42,45 @@ const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectio
|
||||
const result = await ipcRenderer.invoke('renderer:get-endpoint-diff-data', {
|
||||
collectionPath,
|
||||
endpointId: endpoint.id,
|
||||
newSpec
|
||||
newSpec,
|
||||
preserveValues
|
||||
});
|
||||
|
||||
if (requestId !== requestIdRef.current) return; // superseded by a newer fetch
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
} else {
|
||||
setDiffData(result);
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setError(formatIpcError(err) || 'Failed to load diff data');
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) setIsLoading(false);
|
||||
}
|
||||
}, [collectionPath, endpoint.id, newSpec, preserveValues]);
|
||||
|
||||
// Re-fetch the preview when the preserve toggle changes — the EXPECTED column
|
||||
// depends on it. Expanded rows re-fetch in place (the old diff stays visible
|
||||
// and swaps when the new one arrives, so the row never blanks). Collapsed rows
|
||||
// just drop their cache so the next expand fetches fresh — invisible to the user.
|
||||
const didMountPreserve = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!didMountPreserve.current) {
|
||||
didMountPreserve.current = true;
|
||||
return;
|
||||
}
|
||||
if (isExpanded) {
|
||||
loadDiffData(); // bumps requestId, keeps old diff until the new one lands
|
||||
} else {
|
||||
requestIdRef.current++; // invalidate any in-flight fetch
|
||||
setDiffData(null);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [collectionPath, endpoint.id, newSpec]);
|
||||
// Intentionally only re-run when the toggle flips — not on isExpanded/loadDiffData
|
||||
// changes, which the dedicated load effect + handleToggle already cover.
|
||||
}, [preserveValues]);
|
||||
|
||||
// Load diff data when expanded (e.g. restored from Redux state)
|
||||
useEffect(() => {
|
||||
@@ -126,18 +157,21 @@ const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectio
|
||||
|
||||
{isExpanded && (
|
||||
<div className="review-row-diff">
|
||||
{isLoading && (
|
||||
{/* Spinner only on the initial load. A re-fetch (e.g. toggling Preserve)
|
||||
keeps the previous diff visible and swaps it in place, so the row
|
||||
never blanks/flickers. */}
|
||||
{isLoading && !diffData && !error && (
|
||||
<div className="diff-loading">
|
||||
<IconLoader2 size={16} className="spinning" />
|
||||
<span>Loading diff...</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
{error && !diffData && (
|
||||
<div className="diff-error">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
{diffData && !isLoading && !error && (
|
||||
{diffData && !error && (
|
||||
<EndpointVisualDiff
|
||||
oldData={diffData.oldData}
|
||||
newData={diffData.newData}
|
||||
|
||||
@@ -1770,6 +1770,7 @@ const StyledWrapper = styled.div`
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
user-select: none; /* these are controls, not selectable text (e.g. double-click on the info icon) */
|
||||
}
|
||||
|
||||
.bulk-btn {
|
||||
@@ -1804,6 +1805,77 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
/* the three Preserve elements read as one control: label + info + toggle */
|
||||
.preserve-values-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.preserve-values-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* the shared InfoCircle icon ships a hardcoded ml-2 (8px); override it
|
||||
so the info icon sits tight to the label. It is a hover-only tooltip
|
||||
affordance, not a button — use a help cursor and never show a
|
||||
click/focus box around it. */
|
||||
svg {
|
||||
margin-left: 4px;
|
||||
cursor: help;
|
||||
}
|
||||
svg:focus,
|
||||
svg:focus-visible,
|
||||
span:focus,
|
||||
span:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* compact themed track + knob toggle, sized to the button row height */
|
||||
.preserve-toggle {
|
||||
margin-right: 4px; /* space between the toggle and the label */
|
||||
width: 26px;
|
||||
height: 14px;
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
background: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.button2.color.primary.bg};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${(props) => props.theme.button2.color.primary.bg};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.preserve-toggle-knob {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: left 0.2s;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
&.active .preserve-toggle-knob {
|
||||
left: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sync-review-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -88,6 +88,7 @@ const SyncReviewPage = ({
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabUiState = useSelector((state) => state.openapiSync?.tabUiState?.[collectionUid] || {});
|
||||
const [preserveValues, setPreserveValues] = useState(true);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [showSpecDiffModal, setShowSpecDiffModal] = useState(false);
|
||||
const [isOpeningSpecDiff, setIsOpeningSpecDiff] = useState(false);
|
||||
@@ -210,7 +211,8 @@ const SyncReviewPage = ({
|
||||
newToCollection: filteredAddedEndpoints,
|
||||
specUpdates: filteredSpecChanges,
|
||||
resolvedConflicts: specUpdatedEndpoints.filter((ep) => ep.conflict && decisions[ep.id] === 'accept-incoming'),
|
||||
localChangesToReset: localUpdatedEndpoints.filter((ep) => decisions[ep.id] === 'accept-incoming')
|
||||
localChangesToReset: localUpdatedEndpoints.filter((ep) => decisions[ep.id] === 'accept-incoming'),
|
||||
preserveValues
|
||||
});
|
||||
};
|
||||
|
||||
@@ -238,6 +240,21 @@ const SyncReviewPage = ({
|
||||
</div>
|
||||
{(specDrift?.unifiedDiff || decidableEndpoints.length > 0) && (
|
||||
<div className="bulk-actions">
|
||||
<div className="preserve-values-control">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-pressed={preserveValues}
|
||||
className={`preserve-toggle ${preserveValues ? 'active' : ''}`}
|
||||
onClick={() => setPreserveValues((v) => !v)}
|
||||
>
|
||||
<span className="preserve-toggle-knob" />
|
||||
</button>
|
||||
<span className="preserve-values-label">Preserve values</span>
|
||||
<Help icon="info" size={12} placement="top" width={260}>
|
||||
When enabled, your edited values are preserved during sync. When disabled, all values are updated to match the OpenAPI spec.
|
||||
</Help>
|
||||
</div>
|
||||
{specDrift?.unifiedDiff && (
|
||||
<button
|
||||
className="bulk-btn"
|
||||
@@ -329,6 +346,7 @@ const SyncReviewPage = ({
|
||||
showDecisions={true}
|
||||
decisionLabels={{ keep: 'Keep Current', accept: 'Update' }}
|
||||
collectionUid={collectionUid}
|
||||
preserveValues={preserveValues}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -353,6 +371,7 @@ const SyncReviewPage = ({
|
||||
showDecisions={true}
|
||||
decisionLabels={{ keep: 'Skip', accept: 'Add' }}
|
||||
collectionUid={collectionUid}
|
||||
preserveValues={preserveValues}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -377,6 +396,7 @@ const SyncReviewPage = ({
|
||||
showDecisions={true}
|
||||
decisionLabels={{ keep: 'Keep', accept: 'Delete' }}
|
||||
collectionUid={collectionUid}
|
||||
preserveValues={preserveValues}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,7 @@ const useSyncFlow = ({
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
|
||||
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync', preserveValues = true) => {
|
||||
setShowConfirmModal(false);
|
||||
setIsSyncing(true);
|
||||
setError(null);
|
||||
@@ -71,7 +71,8 @@ const useSyncFlow = ({
|
||||
localOnlyToRemove,
|
||||
driftedToReset,
|
||||
mode,
|
||||
endpointDecisions: decisions
|
||||
endpointDecisions: decisions,
|
||||
preserveValues
|
||||
});
|
||||
|
||||
setPendingSyncMode(null);
|
||||
@@ -102,7 +103,7 @@ const useSyncFlow = ({
|
||||
const handleApplySync = (selections) => {
|
||||
const mode = pendingSyncMode || 'sync';
|
||||
setPendingSyncMode(null);
|
||||
performSync(selections, mode);
|
||||
performSync(selections, mode, selections?.preserveValues ?? true);
|
||||
};
|
||||
|
||||
const cancelConfirmModal = () => {
|
||||
|
||||
Reference in New Issue
Block a user