feature/autoSave (#582) (#6211)

This commit is contained in:
Sid
2025-12-02 14:32:34 +05:30
committed by GitHub
10 changed files with 470 additions and 9 deletions

View File

@@ -37,6 +37,25 @@ const General = ({ close }) => {
.test('isValidTimeout', 'Request Timeout must be equal or greater than 0', (value) => {
return value === undefined || Number(value) >= 0;
}),
autoSave: Yup.object({
enabled: Yup.boolean(),
interval: Yup.mixed()
.transform((value, originalValue) => {
return originalValue === '' ? undefined : value;
})
.test('isNumber', 'Save Delay must be a number', (value) => {
return value === undefined || !isNaN(value);
})
.test('isValidInterval', 'Save Delay must be at least 100ms', (value) => {
return value === undefined || Number(value) >= 100;
})
}).test('intervalRequired', 'Save Delay is required when Auto Save is enabled', (value) => {
// If autosave is enabled, interval must be provided
if (value.enabled && (value.interval === undefined || value.interval === '')) {
return false;
}
return true;
}),
defaultCollectionLocation: Yup.string().max(1024)
});
@@ -53,6 +72,10 @@ const General = ({ close }) => {
timeout: preferences.request.timeout,
storeCookies: get(preferences, 'request.storeCookies', true),
sendCookies: get(preferences, 'request.sendCookies', true),
autoSave: {
enabled: get(preferences, 'autoSave.enabled', false),
interval: get(preferences, 'autoSave.interval', 1000)
},
defaultCollectionLocation: get(preferences, 'general.defaultCollectionLocation', '')
},
validationSchema: preferencesSchema,
@@ -83,12 +106,16 @@ const General = ({ close }) => {
storeCookies: newPreferences.storeCookies,
sendCookies: newPreferences.sendCookies
},
autoSave: {
enabled: newPreferences.autoSave.enabled,
interval: newPreferences.autoSave.interval
},
general: {
defaultCollectionLocation: newPreferences.defaultCollectionLocation
}
}))
.then(() => {
toast.success('Preferences saved successfully')
toast.success('Preferences saved successfully');
close();
})
.catch((err) => console.log(err) && toast.error('Failed to update preferences'));
@@ -250,6 +277,43 @@ const General = ({ close }) => {
{formik.touched.timeout && formik.errors.timeout ? (
<div className="text-red-500">{formik.errors.timeout}</div>
) : null}
<div className="flex items-center mt-6">
<input
id="autoSaveEnabled"
type="checkbox"
name="autoSave.enabled"
checked={formik.values.autoSave.enabled}
onChange={formik.handleChange}
className="mousetrap mr-0"
/>
<label className="block ml-2 select-none" htmlFor="autoSaveEnabled">
Enable Auto Save
</label>
</div>
<div className={`flex flex-col mt-2 ${!formik.values.autoSave.enabled ? 'opacity-50' : ''}`}>
<label className="block select-none" htmlFor="autoSaveInterval">
Save Delay (in ms)
</label>
<input
type="text"
name="autoSave.interval"
id="autoSaveInterval"
className="block textbox mt-2 w-24"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.autoSave.interval}
disabled={!formik.values.autoSave.enabled}
/>
</div>
{formik.touched.autoSave && formik.errors.autoSave && typeof formik.errors.autoSave === 'string' && (
<div className="text-red-500">{formik.errors.autoSave}</div>
)}
{formik.touched.autoSave?.interval && formik.errors.autoSave?.interval && (
<div className="text-red-500">{formik.errors.autoSave.interval}</div>
)}
<div className="flex flex-col mt-6">
<label className="block select-none default-collection-location-label" htmlFor="defaultCollectionLocation">
Default Collection Location
@@ -257,6 +321,7 @@ const General = ({ close }) => {
<input
type="text"
name="defaultCollectionLocation"
id="defaultCollectionLocation"
className="block textbox mt-2 w-full cursor-pointer default-collection-location-input"
autoComplete="off"
autoCorrect="off"

View File

@@ -33,7 +33,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
setMethodSelectorWidth(el.offsetWidth);
}, [method]);
const onSave = (finalValue) => {
const onSave = () => {
dispatch(saveRequest(item.uid, collection.uid));
};

View File

@@ -9,12 +9,13 @@ import globalEnvironmentsReducer from './slices/global-environments';
import logsReducer from './slices/logs';
import performanceReducer from './slices/performance';
import { draftDetectMiddleware } from './middlewares/draft/middleware';
import { autosaveMiddleware } from './middlewares/autosave/middleware';
const isDevEnv = () => {
return import.meta.env.MODE === 'development';
};
let middleware = [tasksMiddleware.middleware, draftDetectMiddleware];
let middleware = [tasksMiddleware.middleware, draftDetectMiddleware, autosaveMiddleware];
if (isDevEnv()) {
middleware = [...middleware, debugMiddleware.middleware];
}

View File

@@ -0,0 +1,169 @@
import { saveRequest, saveCollectionSettings, saveFolderRoot } from '../../slices/collections/actions';
import { flattenItems, isItemARequest, isItemAFolder } from 'utils/collections';
const actionsToIntercept = [
// Request-level actions
'collections/requestUrlChanged',
'collections/updateAuth',
'collections/addQueryParam',
'collections/moveQueryParam',
'collections/updateQueryParam',
'collections/deleteQueryParam',
'collections/updatePathParam',
'collections/addRequestHeader',
'collections/updateRequestHeader',
'collections/deleteRequestHeader',
'collections/moveRequestHeader',
'collections/addFormUrlEncodedParam',
'collections/updateFormUrlEncodedParam',
'collections/deleteFormUrlEncodedParam',
'collections/moveFormUrlEncodedParam',
'collections/addMultipartFormParam',
'collections/updateMultipartFormParam',
'collections/deleteMultipartFormParam',
'collections/moveMultipartFormParam',
'collections/updateRequestAuthMode',
'collections/updateRequestBodyMode',
'collections/updateRequestBody',
'collections/updateRequestGraphqlQuery',
'collections/updateRequestGraphqlVariables',
'collections/updateRequestScript',
'collections/updateResponseScript',
'collections/updateRequestTests',
'collections/updateRequestMethod',
'collections/addAssertion',
'collections/updateAssertion',
'collections/deleteAssertion',
'collections/moveAssertion',
'collections/addVar',
'collections/updateVar',
'collections/deleteVar',
'collections/moveVar',
'collections/updateRequestDocs',
'collections/runRequestEvent',
// Folder-level actions
'collections/addFolderHeader',
'collections/updateFolderHeader',
'collections/deleteFolderHeader',
'collections/addFolderVar',
'collections/updateFolderVar',
'collections/deleteFolderVar',
'collections/updateFolderRequestScript',
'collections/updateFolderResponseScript',
'collections/updateFolderTests',
'collections/updateFolderAuth',
'collections/updateFolderAuthMode',
'collections/updateFolderDocs',
// Collection-level actions
'collections/addCollectionHeader',
'collections/updateCollectionHeader',
'collections/deleteCollectionHeader',
'collections/addCollectionVar',
'collections/updateCollectionVar',
'collections/deleteCollectionVar',
'collections/updateCollectionAuth',
'collections/updateCollectionAuthMode',
'collections/updateCollectionRequestScript',
'collections/updateCollectionResponseScript',
'collections/updateCollectionTests',
'collections/updateCollectionDocs',
'collections/updateCollectionClientCertificates',
'collections/updateCollectionProtobuf',
'collections/updateCollectionProxy'
];
// Simple object to track pending save timers
const pendingTimers = {};
// Helper to schedule autosave for an item
const scheduleAutoSave = (key, save, interval) => {
// Clear any existing timer for this entity
clearTimeout(pendingTimers[key]);
// Schedule a new save
pendingTimers[key] = setTimeout(() => {
save();
delete pendingTimers[key];
}, interval);
};
// Helper to find and schedule saves for all existing drafts
const saveExistingDrafts = (dispatch, getState, interval) => {
const collections = getState().collections.collections;
collections.forEach((collection) => {
// Check collection-level draft
if (collection.draft) {
const key = `collection-${collection.uid}`;
scheduleAutoSave(key, () => dispatch(saveCollectionSettings(collection.uid, null, true)), interval);
}
// Check all items (requests and folders) for drafts
const allItems = flattenItems(collection.items);
allItems.forEach((item) => {
if (item.draft) {
if (isItemARequest(item)) {
const key = `request-${item.uid}`;
scheduleAutoSave(key, () => dispatch(saveRequest(item.uid, collection.uid, true)), interval);
} else if (isItemAFolder(item)) {
const key = `folder-${item.uid}`;
scheduleAutoSave(key, () => dispatch(saveFolderRoot(collection.uid, item.uid, true)), interval);
}
}
});
});
};
export const autosaveMiddleware = ({ dispatch, getState }) => (next) => (action) => {
// Let the action update the state first
const result = next(action);
// Check if autosave is enabled
const { autoSave } = getState().app.preferences;
if (!autoSave?.enabled) return result;
// When autosave is enabled (or settings change), save any existing drafts
if (action.type === 'app/updatePreferences' && action.payload?.autoSave?.enabled) {
saveExistingDrafts(dispatch, getState, autoSave.interval);
return result;
}
if (action.type === 'app/updatePreferences' && action.payload?.autoSave?.enabled === false) {
Object.keys(pendingTimers).forEach((key) => {
clearTimeout(pendingTimers[key]);
delete pendingTimers[key];
});
return result;
}
// Only handle actions that create dirty state
if (!actionsToIntercept.includes(action.type)) return result;
const { itemUid, folderUid, collectionUid } = action.payload;
const interval = autoSave.interval;
// Determine what to save based on what IDs are present
let key, save;
if (itemUid) {
// Request change
key = `request-${itemUid}`;
save = () => dispatch(saveRequest(itemUid, collectionUid, true));
} else if (folderUid) {
// Folder change
key = `folder-${folderUid}`;
save = () => dispatch(saveFolderRoot(collectionUid, folderUid, true));
} else if (collectionUid) {
// Collection change
key = `collection-${collectionUid}`;
save = () => dispatch(saveCollectionSettings(collectionUid, null, true));
}
if (key && save) {
scheduleAutoSave(key, save, interval);
}
return result;
};

View File

@@ -28,6 +28,10 @@ const initialState = {
},
general: {
defaultCollectionLocation: ''
},
autoSave: {
enabled: false,
interval: 1000
}
},
generateCode: {

View File

@@ -94,7 +94,7 @@ export const renameCollection = (newName, collectionUid) => (dispatch, getState)
});
};
export const saveRequest = (itemUid, collectionUid, saveSilently) => (dispatch, getState) => {
export const saveRequest = (itemUid, collectionUid, silent = false) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -116,7 +116,7 @@ export const saveRequest = (itemUid, collectionUid, saveSilently) => (dispatch,
.validate(itemToSave)
.then(() => ipcRenderer.invoke('renderer:save-request', item.pathname, itemToSave, collection.format))
.then(() => {
if (!saveSilently) {
if (!silent) {
toast.success('Request saved successfully');
}
dispatch(
@@ -196,7 +196,7 @@ export const saveCollectionRoot = (collectionUid) => (dispatch, getState) => {
});
};
export const saveFolderRoot = (collectionUid, folderUid) => (dispatch, getState) => {
export const saveFolderRoot = (collectionUid, folderUid, silent = false) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
const folder = findItemInCollection(collection, folderUid);
@@ -225,7 +225,9 @@ export const saveFolderRoot = (collectionUid, folderUid) => (dispatch, getState)
ipcRenderer
.invoke('renderer:save-folder-root', folderData)
.then(() => {
toast.success('Folder Settings saved successfully');
if (!silent) {
toast.success('Folder Settings saved successfully');
}
// If there was a draft, save it to root and clear the draft
if (folder.draft) {
dispatch(saveFolderDraft({ collectionUid, folderUid }));
@@ -2128,7 +2130,7 @@ export const browseFiles = (filters, properties) => (_dispatch, _getState) => {
});
};
export const saveCollectionSettings = (collectionUid, brunoConfig = null) => (dispatch, getState) => {
export const saveCollectionSettings = (collectionUid, brunoConfig = null, silent = false) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -2156,7 +2158,9 @@ export const saveCollectionSettings = (collectionUid, brunoConfig = null) => (di
Promise.all(savePromises)
.then(() => {
toast.success('Collection Settings saved successfully');
if (!silent) {
toast.success('Collection Settings saved successfully');
}
dispatch(saveCollectionDraft({ collectionUid }));
})
.then(resolve)

View File

@@ -545,6 +545,12 @@ const darkTheme = {
text: '#B8B8B8'
},
preferences: {
sidebar: {
border: '#444444'
}
},
examples: {
buttonBg: '#d9a3421A',
buttonColor: '#d9a342',

View File

@@ -555,6 +555,12 @@ const lightTheme = {
text: '#343434'
},
preferences: {
sidebar: {
border: '#EFEFEF'
}
},
examples: {
buttonBg: '#D977061A',
buttonColor: '#D97706',

View File

@@ -47,6 +47,10 @@ const defaultPreferences = {
},
general: {
defaultCollectionLocation: ''
},
autoSave: {
enabled: false,
interval: 1000
}
};
@@ -90,6 +94,10 @@ const preferencesSchema = Yup.object().shape({
}),
general: Yup.object({
defaultCollectionLocation: Yup.string().max(1024).nullable()
}),
autoSave: Yup.object({
enabled: Yup.boolean(),
interval: Yup.number().min(100)
})
});

View File

@@ -0,0 +1,198 @@
import { test, expect } from '../../../playwright';
import { createCollection, closeAllCollections, createRequest } from '../../utils/page';
test.describe('Autosave', () => {
test.afterEach(async ({ page }) => {
await closeAllCollections(page);
});
test('should automatically save request changes when autosave is enabled', async ({ page, createTmpDir }) => {
const collectionName = 'autosave-test';
await test.step('Create collection and request', async () => {
await createCollection(page, collectionName, await createTmpDir('autosave-collection'), {
openWithSandboxMode: 'safe'
});
await expect(page.locator('#sidebar-collection-name').filter({ hasText: collectionName })).toBeVisible();
await createRequest(page, 'Test Request', collectionName);
await page.locator('.collection-item-name').filter({ hasText: 'Test Request' }).click();
// Set initial URL and save
const urlEditor = page.locator('#request-url .CodeMirror');
await urlEditor.click();
await page.keyboard.type('https://api.example.com');
await page.keyboard.press('Control+s');
// Verify no draft indicator
const requestTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'Test Request' }) });
await expect(requestTab.locator('.has-changes-icon')).not.toBeVisible();
});
await test.step('Enable autosave in preferences', async () => {
// Open preferences
await page.locator('.status-bar button[data-trigger="preferences"]').click();
const preferencesModal = page.locator('.bruno-modal-card').filter({ hasText: 'Preferences' });
await expect(preferencesModal).toBeVisible();
// Enable autosave checkbox
const autoSaveCheckbox = preferencesModal.locator('#autoSaveEnabled');
await autoSaveCheckbox.check();
// Save preferences
await preferencesModal.locator('button[type="submit"]').click();
// Wait for preferences to close
await expect(preferencesModal).not.toBeVisible();
});
await test.step('Make changes and verify autosave', async () => {
// Make a change to the URL
const urlEditor = page.locator('#request-url .CodeMirror');
await urlEditor.click();
await page.keyboard.press('End');
await page.keyboard.type('/users');
// Verify draft indicator appears
const requestTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'Test Request' }) });
await expect(requestTab.locator('.has-changes-icon')).toBeVisible();
// Wait for autosave to trigger (interval + some buffer)
await page.waitForTimeout(1000);
// Verify draft indicator disappears after autosave
await expect(requestTab.locator('.has-changes-icon')).not.toBeVisible();
});
await test.step('Verify changes persisted', async () => {
// Close and reopen the request tab to verify persistence
const requestTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'Test Request' }) });
await requestTab.locator('.close-icon').click();
// Reopen request
await page.locator('.collection-item-name').filter({ hasText: 'Test Request' }).click();
// Verify URL contains our changes
const urlEditor = page.locator('#request-url .CodeMirror');
const urlContent = await urlEditor.locator('.CodeMirror-line').first().textContent();
expect(urlContent).toContain('api.example.com/users');
});
await test.step('Disable autosave in preferences', async () => {
// Open preferences from status bar
await page.locator('.status-bar button[data-trigger="preferences"]').click();
// Wait for preferences modal
const preferencesModal = page.locator('.bruno-modal-card').filter({ hasText: 'Preferences' });
await expect(preferencesModal).toBeVisible();
// Disable autosave checkbox
const autoSaveCheckbox = preferencesModal.locator('#autoSaveEnabled');
await autoSaveCheckbox.uncheck();
// Save preferences
await preferencesModal.locator('button[type="submit"]').click();
// Wait for preferences to close
await expect(preferencesModal).not.toBeVisible();
});
await test.step('Make changes and verify no autosave when disabled', async () => {
// Make a change to the URL
const urlEditor = page.locator('#request-url .CodeMirror');
await urlEditor.click();
await page.keyboard.press('End');
await page.keyboard.type('/posts');
// Verify draft indicator appears
const requestTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'Test Request' }) });
await expect(requestTab.locator('.has-changes-icon')).toBeVisible();
// Wait a bit (longer than autosave interval would be)
await page.waitForTimeout(1500);
// Draft indicator should still be visible (autosave is disabled)
await expect(requestTab.locator('.has-changes-icon')).toBeVisible();
// Save the request
await page.keyboard.press('Control+s');
await expect(requestTab.locator('.has-changes-icon')).not.toBeVisible();
});
});
test('should autosave existing drafts when autosave is enabled', async ({ page, createTmpDir }) => {
const collectionName = 'autosave-existing-drafts-test';
await test.step('Create collection and request with initial URL', async () => {
await createCollection(page, collectionName, await createTmpDir('autosave-existing-drafts-collection'), {
openWithSandboxMode: 'safe'
});
await expect(page.locator('#sidebar-collection-name').filter({ hasText: collectionName })).toBeVisible();
await createRequest(page, 'Draft Request', collectionName);
await page.locator('.collection-item-name').filter({ hasText: 'Draft Request' }).click();
// Set initial URL and save
const urlEditor = page.locator('#request-url .CodeMirror');
await urlEditor.click();
await page.keyboard.type('https://api.example.com');
await page.keyboard.press('Control+s');
// Verify no draft indicator
const requestTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'Draft Request' }) });
await expect(requestTab.locator('.has-changes-icon')).not.toBeVisible();
});
await test.step('Make changes to create a draft (without saving)', async () => {
// Make a change to the URL
const urlEditor = page.locator('#request-url .CodeMirror');
await urlEditor.click();
await page.keyboard.press('End');
await page.keyboard.type('/existing-draft');
// Verify draft indicator appears
const requestTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'Draft Request' }) });
await expect(requestTab.locator('.has-changes-icon')).toBeVisible();
});
await test.step('Enable autosave and verify existing draft is saved', async () => {
// Open preferences
await page.locator('.status-bar button[data-trigger="preferences"]').click();
const preferencesModal = page.locator('.bruno-modal-card').filter({ hasText: 'Preferences' });
await expect(preferencesModal).toBeVisible();
// Enable autosave checkbox
const autoSaveCheckbox = preferencesModal.locator('#autoSaveEnabled');
await autoSaveCheckbox.check();
// Save preferences
await preferencesModal.locator('button[type="submit"]').click();
// Wait for preferences to close
await expect(preferencesModal).not.toBeVisible();
// Wait for autosave to trigger for existing draft
await page.waitForTimeout(1000);
// Verify draft indicator disappears (existing draft was auto-saved)
const requestTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'Draft Request' }) });
await expect(requestTab.locator('.has-changes-icon')).not.toBeVisible();
});
await test.step('Verify changes persisted', async () => {
// Close and reopen the request tab to verify persistence
const requestTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'Draft Request' }) });
await requestTab.locator('.close-icon').click();
// Reopen request
await page.locator('.collection-item-name').filter({ hasText: 'Draft Request' }).click();
// Verify URL contains our changes
const urlEditor = page.locator('#request-url .CodeMirror');
const urlContent = await urlEditor.locator('.CodeMirror-line').first().textContent();
expect(urlContent).toContain('api.example.com/existing-draft');
});
});
});