mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-09 06:55:03 +00:00
fix: example-request tab collision (#7989)
* fix: prevent response-example tabs from hijacking request sidebar selection * fix: add selectors for examples with index * test: better locator * fix: duplicate name collision * fix: refactor sidebar example handling functions for better clarity and reusability * chore: cr comments
This commit is contained in:
@@ -344,7 +344,7 @@ export const findCollectionEnvironmentFromSnapshot = (collection, snapshotData =
|
||||
};
|
||||
|
||||
const getAccessor = (tab) => {
|
||||
if (tab.type === 'response-example') return 'pathname::exampleName';
|
||||
if (tab.type === 'response-example') return 'pathname::exampleIndex';
|
||||
if (SINGLETON_TAB_TYPES.has(tab.type)) return 'type';
|
||||
return 'pathname';
|
||||
};
|
||||
@@ -379,6 +379,23 @@ export const serializeTab = (tab, collection) => {
|
||||
const item = findItemInCollection(collection, tab.itemUid);
|
||||
serialized.pathname = item?.pathname || tab.pathname;
|
||||
serialized.exampleName = tab.exampleName;
|
||||
const exampleIndex = item?.examples?.findIndex((example) => example.uid === tab.uid);
|
||||
if (typeof exampleIndex === 'number' && exampleIndex >= 0) {
|
||||
serialized.exampleIndex = exampleIndex;
|
||||
}
|
||||
serialized.exampleUid = tab.uid;
|
||||
if (tab.name) {
|
||||
serialized.name = tab.name;
|
||||
}
|
||||
} else if (accessor === 'pathname::exampleIndex') {
|
||||
const item = findItemInCollection(collection, tab.itemUid);
|
||||
serialized.pathname = item?.pathname || tab.pathname;
|
||||
const exampleIndex = item?.examples?.findIndex((example) => example.uid === tab.uid);
|
||||
if (typeof exampleIndex === 'number' && exampleIndex >= 0) {
|
||||
serialized.exampleIndex = exampleIndex;
|
||||
}
|
||||
serialized.exampleName = tab.exampleName;
|
||||
serialized.exampleUid = tab.uid;
|
||||
if (tab.name) {
|
||||
serialized.name = tab.name;
|
||||
}
|
||||
@@ -420,6 +437,22 @@ export const serializeActiveTab = (tab, collection) => {
|
||||
return { accessor, value: `${pathname}::${tab.exampleName}` };
|
||||
}
|
||||
|
||||
if (accessor === 'pathname::exampleIndex') {
|
||||
const item = findItemInCollection(collection, tab.itemUid);
|
||||
const pathname = item?.pathname || tab.pathname;
|
||||
const exampleIndex = item?.examples?.findIndex((example) => example.uid === tab.uid);
|
||||
|
||||
if (typeof exampleIndex === 'number' && exampleIndex >= 0) {
|
||||
return { accessor, value: `${pathname}::${exampleIndex}` };
|
||||
}
|
||||
|
||||
if (tab.exampleName) {
|
||||
return { accessor: 'pathname::exampleName', value: `${pathname}::${tab.exampleName}` };
|
||||
}
|
||||
|
||||
return { accessor, value: `${pathname}::-1` };
|
||||
}
|
||||
|
||||
return { accessor: 'type', value: tab.type };
|
||||
};
|
||||
|
||||
@@ -434,7 +467,7 @@ export const isActiveTab = (tab, activeTab, collection) => {
|
||||
|
||||
if (accessor === 'pathname') {
|
||||
const item = findItemInCollection(collection, tab.uid);
|
||||
return item?.pathname === value || tab.pathname === value;
|
||||
return tab.type !== 'response-example' && (item?.pathname === value || tab.pathname === value);
|
||||
}
|
||||
|
||||
if (accessor === 'pathname::exampleName') {
|
||||
@@ -443,11 +476,62 @@ export const isActiveTab = (tab, activeTab, collection) => {
|
||||
return `${pathname}::${tab.exampleName}` === value;
|
||||
}
|
||||
|
||||
if (accessor === 'pathname::exampleIndex') {
|
||||
const item = findItemInCollection(collection, tab.itemUid);
|
||||
const pathname = item?.pathname || tab.pathname;
|
||||
const exampleIndex = item?.examples?.findIndex((example) => example.uid === tab.uid);
|
||||
|
||||
return `${pathname}::${exampleIndex}` === value;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const resolveResponseExampleTabState = ({ item, pathname, exampleName, exampleIndex, exampleUid }) => {
|
||||
const hasExamples = Array.isArray(item?.examples);
|
||||
const hasProvidedExampleIndex = typeof exampleIndex === 'number' && exampleIndex >= 0;
|
||||
const hasValidExampleIndex = hasExamples && hasProvidedExampleIndex && exampleIndex < item.examples.length;
|
||||
|
||||
let resolvedExample = null;
|
||||
if (hasExamples) {
|
||||
if (hasValidExampleIndex) {
|
||||
resolvedExample = item.examples[exampleIndex] || null;
|
||||
} else {
|
||||
if (typeof exampleUid === 'string' && exampleUid.length > 0) {
|
||||
resolvedExample = item.examples.find((ex) => ex.uid === exampleUid) || null;
|
||||
}
|
||||
|
||||
if (!resolvedExample && exampleName) {
|
||||
resolvedExample = item.examples.find((ex) => ex.name === exampleName) || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedExampleIndex = hasExamples && resolvedExample?.uid
|
||||
? item.examples.findIndex((ex) => ex.uid === resolvedExample.uid)
|
||||
: -1;
|
||||
|
||||
const fallbackExampleIdentity = hasProvidedExampleIndex
|
||||
? `${pathname}::${exampleIndex}`
|
||||
: `${pathname}::${exampleName}`;
|
||||
|
||||
let normalizedExampleIndex = null;
|
||||
if (resolvedExampleIndex >= 0) {
|
||||
normalizedExampleIndex = resolvedExampleIndex;
|
||||
} else if (hasProvidedExampleIndex) {
|
||||
normalizedExampleIndex = exampleIndex;
|
||||
}
|
||||
|
||||
return {
|
||||
uid: resolvedExample?.uid || fallbackExampleIdentity,
|
||||
itemUid: item?.uid || pathname,
|
||||
exampleName: resolvedExample?.name || exampleName,
|
||||
exampleIndex: normalizedExampleIndex
|
||||
};
|
||||
};
|
||||
|
||||
export const deserializeTab = (snapshotTab, collection) => {
|
||||
const { accessor, pathname, exampleName, type } = snapshotTab;
|
||||
const { accessor, pathname, exampleName, exampleIndex, exampleUid, type } = snapshotTab;
|
||||
const restoredRequestPaneTab = typeof snapshotTab.request?.tab === 'string' ? snapshotTab.request.tab : null;
|
||||
|
||||
const tab = {
|
||||
@@ -483,12 +567,13 @@ export const deserializeTab = (snapshotTab, collection) => {
|
||||
if (type === 'folder-settings') {
|
||||
tab.folderUid = item?.uid || pathname;
|
||||
}
|
||||
} else if (accessor === 'pathname::exampleName' && pathname && exampleName) {
|
||||
} else if ((accessor === 'pathname::exampleName' || accessor === 'pathname::exampleIndex') && pathname) {
|
||||
const item = findItemInCollectionByPathname(collection, pathname);
|
||||
const example = item?.examples?.find((ex) => ex.name === exampleName);
|
||||
tab.uid = example?.uid || `${pathname}::${exampleName}`;
|
||||
tab.itemUid = item?.uid || pathname;
|
||||
tab.exampleName = exampleName;
|
||||
const resolvedTabState = resolveResponseExampleTabState({ item, pathname, exampleName, exampleIndex, exampleUid });
|
||||
tab.uid = resolvedTabState.uid;
|
||||
tab.itemUid = resolvedTabState.itemUid;
|
||||
tab.exampleName = resolvedTabState.exampleName;
|
||||
tab.exampleIndex = resolvedTabState.exampleIndex;
|
||||
} else if (needsTypeBasedFallback) {
|
||||
const collectionUidFromSnapshot = typeof snapshotTab.collection === 'string' && snapshotTab.collection.length > 0
|
||||
? snapshotTab.collection
|
||||
@@ -573,9 +658,20 @@ export const getActiveTabFromSnapshot = async (collectionPathname, collection, s
|
||||
if (accessor === 'type') {
|
||||
snapshotTab = tabsSnapshot.tabs.find((t) => t.type === value);
|
||||
} else if (accessor === 'pathname') {
|
||||
snapshotTab = tabsSnapshot.tabs.find((t) => t.pathname === value);
|
||||
snapshotTab = tabsSnapshot.tabs.find((t) => t.pathname === value && t.type !== 'response-example');
|
||||
} else if (accessor === 'pathname::exampleName') {
|
||||
snapshotTab = tabsSnapshot.tabs.find((t) => `${t.pathname}::${t.exampleName}` === value);
|
||||
} else if (accessor === 'pathname::exampleIndex') {
|
||||
snapshotTab = tabsSnapshot.tabs.find((t) => `${t.pathname}::${t.exampleIndex}` === value);
|
||||
|
||||
if (!snapshotTab) {
|
||||
const [pathname, rawIndex] = value.split('::');
|
||||
const exampleIndex = Number(rawIndex);
|
||||
if (pathname && Number.isInteger(exampleIndex) && exampleIndex >= 0) {
|
||||
const candidateTabs = tabsSnapshot.tabs.filter((t) => t.type === 'response-example' && t.pathname === pathname);
|
||||
snapshotTab = candidateTabs[exampleIndex] || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!snapshotTab) return null;
|
||||
|
||||
@@ -11,7 +11,13 @@ jest.mock('nanoid', () => {
|
||||
};
|
||||
});
|
||||
|
||||
const { deserializeTab, hydrateSnapshotLookups, hydrateCollectionTabs } = require('./index');
|
||||
const {
|
||||
deserializeTab,
|
||||
hydrateSnapshotLookups,
|
||||
hydrateCollectionTabs,
|
||||
isActiveTab,
|
||||
getActiveTabFromSnapshot
|
||||
} = require('./index');
|
||||
|
||||
describe('hydrateSnapshotLookups', () => {
|
||||
it('builds lookup maps from array-based snapshot schema', () => {
|
||||
@@ -280,6 +286,100 @@ describe('deserializeTab', () => {
|
||||
expect(tab.uid).toBe('collection-uid-preferences');
|
||||
});
|
||||
|
||||
it('restores response example by index when duplicate names exist', () => {
|
||||
const collectionWithDuplicateExamples = {
|
||||
uid: 'collection-uid',
|
||||
pathname: '/collections/a',
|
||||
items: [
|
||||
{
|
||||
uid: 'request-1',
|
||||
pathname: '/collections/a/request-1.bru',
|
||||
examples: [
|
||||
{ uid: 'example-1', name: 'dup' },
|
||||
{ uid: 'example-2', name: 'dup' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const snapshotTab = {
|
||||
type: 'response-example',
|
||||
accessor: 'pathname::exampleIndex',
|
||||
pathname: '/collections/a/request-1.bru',
|
||||
exampleName: 'dup',
|
||||
exampleIndex: 1,
|
||||
permanent: true
|
||||
};
|
||||
|
||||
const tab = deserializeTab(snapshotTab, collectionWithDuplicateExamples);
|
||||
expect(tab.uid).toBe('example-2');
|
||||
expect(tab.exampleName).toBe('dup');
|
||||
expect(tab.exampleIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to first matching name when example index is missing or invalid', () => {
|
||||
const collectionWithDuplicateExamples = {
|
||||
uid: 'collection-uid',
|
||||
pathname: '/collections/a',
|
||||
items: [
|
||||
{
|
||||
uid: 'request-1',
|
||||
pathname: '/collections/a/request-1.bru',
|
||||
examples: [
|
||||
{ uid: 'example-1', name: 'dup' },
|
||||
{ uid: 'example-2', name: 'dup' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const snapshotTab = {
|
||||
type: 'response-example',
|
||||
accessor: 'pathname::exampleIndex',
|
||||
pathname: '/collections/a/request-1.bru',
|
||||
exampleName: 'dup',
|
||||
exampleIndex: 99,
|
||||
permanent: true
|
||||
};
|
||||
|
||||
const tab = deserializeTab(snapshotTab, collectionWithDuplicateExamples);
|
||||
expect(tab.uid).toBe('example-1');
|
||||
expect(tab.exampleName).toBe('dup');
|
||||
expect(tab.exampleIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps example uid and index consistent when uid fallback is used', () => {
|
||||
const collectionWithDuplicateExamples = {
|
||||
uid: 'collection-uid',
|
||||
pathname: '/collections/a',
|
||||
items: [
|
||||
{
|
||||
uid: 'request-1',
|
||||
pathname: '/collections/a/request-1.bru',
|
||||
examples: [
|
||||
{ uid: 'example-1', name: 'dup' },
|
||||
{ uid: 'example-2', name: 'dup' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const snapshotTab = {
|
||||
type: 'response-example',
|
||||
accessor: 'pathname::exampleIndex',
|
||||
pathname: '/collections/a/request-1.bru',
|
||||
exampleName: 'dup',
|
||||
exampleUid: 'example-1',
|
||||
exampleIndex: 99,
|
||||
permanent: true
|
||||
};
|
||||
|
||||
const tab = deserializeTab(snapshotTab, collectionWithDuplicateExamples);
|
||||
expect(tab.uid).toBe('example-1');
|
||||
expect(tab.exampleName).toBe('dup');
|
||||
expect(tab.exampleIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('defaults grpc request pane to body when snapshot request tab is missing', () => {
|
||||
const snapshotTab = {
|
||||
type: 'grpc-request',
|
||||
@@ -315,7 +415,6 @@ describe('deserializeTab', () => {
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const snapshotTab = {
|
||||
type: 'request',
|
||||
accessor: 'pathname',
|
||||
@@ -389,6 +488,96 @@ describe('deserializeTab', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('active tab matching', () => {
|
||||
it('does not mark response example tab as active for pathname accessor', () => {
|
||||
const collection = {
|
||||
uid: 'collection-uid',
|
||||
pathname: '/collections/a',
|
||||
items: [
|
||||
{
|
||||
uid: 'request-1',
|
||||
pathname: '/collections/a/request-1.bru',
|
||||
examples: [{ uid: 'example-1', name: 'Sample' }]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const tab = {
|
||||
uid: 'example-1',
|
||||
type: 'response-example',
|
||||
itemUid: 'request-1',
|
||||
pathname: '/collections/a/request-1.bru',
|
||||
exampleName: 'Sample'
|
||||
};
|
||||
|
||||
const activeTab = {
|
||||
accessor: 'pathname',
|
||||
value: '/collections/a/request-1.bru'
|
||||
};
|
||||
|
||||
expect(isActiveTab(tab, activeTab, collection)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActiveTabFromSnapshot', () => {
|
||||
beforeEach(() => {
|
||||
global.window = global.window || {};
|
||||
global.window.ipcRenderer = {
|
||||
invoke: jest.fn()
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.window.ipcRenderer;
|
||||
});
|
||||
|
||||
it('resolves response example using index accessor when duplicate names exist', async () => {
|
||||
const collection = {
|
||||
uid: 'collection-uid',
|
||||
pathname: '/collections/a',
|
||||
items: [
|
||||
{
|
||||
uid: 'request-1',
|
||||
pathname: '/collections/a/request-1.bru',
|
||||
examples: [
|
||||
{ uid: 'example-1', name: 'dup' },
|
||||
{ uid: 'example-2', name: 'dup' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
window.ipcRenderer.invoke.mockResolvedValue({
|
||||
activeTab: {
|
||||
accessor: 'pathname::exampleIndex',
|
||||
value: '/collections/a/request-1.bru::1'
|
||||
},
|
||||
tabs: [
|
||||
{
|
||||
type: 'response-example',
|
||||
accessor: 'pathname::exampleIndex',
|
||||
pathname: '/collections/a/request-1.bru',
|
||||
exampleName: 'dup',
|
||||
exampleIndex: 0,
|
||||
permanent: true
|
||||
},
|
||||
{
|
||||
type: 'response-example',
|
||||
accessor: 'pathname::exampleIndex',
|
||||
pathname: '/collections/a/request-1.bru',
|
||||
exampleName: 'dup',
|
||||
exampleIndex: 1,
|
||||
permanent: true
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const activeTab = await getActiveTabFromSnapshot('/collections/a', collection, null, null);
|
||||
expect(activeTab.uid).toBe('example-2');
|
||||
expect(activeTab.exampleIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hydrateCollectionTabs', () => {
|
||||
beforeEach(() => {
|
||||
global.window = {
|
||||
|
||||
Reference in New Issue
Block a user