From 85f24eec77a3253e0e4dcee95dcd1e68a22b60c2 Mon Sep 17 00:00:00 2001 From: Ricardo Silverio Date: Mon, 8 Jan 2024 08:51:55 -0300 Subject: [PATCH] [Feature] Prompt user to save requests before exiting app (#1317) * Starting quit flow and focusing in draft * Finishing app if there is no draft to save * Automatically opening request after creation through event queue * Fix remove events from queue using pathname to find item * Removing updateNextAction * Listening via predicate * Confirm close dialog toggle moved to store * Draft operations as tab actions * Complete quit flow * Fixing close app/window hooks * Breaking the chain when dismissing dialog * Displaying request name in ConfirmRequestClose modal * Added disableEscapeKey and disableCloseOnOutsideClick props to Modal (passed in ConfirmRequestClose) * Removing logs * Refactor * listenerMiddleware module * ipc events listeners names * Update next action * Helpful comments * Eventually handle events to close request even if is no draft * Request name in bold --- .../bruno-app/src/components/Modal/index.js | 15 ++- .../RequestTab/ConfirmRequestClose/index.js | 6 +- .../RequestTabs/RequestTab/index.js | 72 +++++----- packages/bruno-app/src/providers/App/index.js | 10 +- .../providers/App/useCollectionNextAction.js | 35 ----- .../src/providers/App/useIpcEvents.js | 28 ++-- .../src/providers/ReduxStore/index.js | 4 +- .../middlewares/listenerMiddleware.js | 123 ++++++++++++++++++ .../src/providers/ReduxStore/slices/app.js | 78 ++++++++++- .../ReduxStore/slices/collections/actions.js | 98 ++++++-------- .../ReduxStore/slices/collections/index.js | 52 +++----- .../src/providers/ReduxStore/slices/tabs.js | 56 +++++++- .../bruno-app/src/utils/events-queue/index.js | 9 ++ packages/bruno-electron/src/index.js | 6 +- packages/bruno-electron/src/ipc/collection.js | 10 +- 15 files changed, 404 insertions(+), 198 deletions(-) delete mode 100644 packages/bruno-app/src/providers/App/useCollectionNextAction.js create mode 100644 packages/bruno-app/src/providers/ReduxStore/middlewares/listenerMiddleware.js create mode 100644 packages/bruno-app/src/utils/events-queue/index.js diff --git a/packages/bruno-app/src/components/Modal/index.js b/packages/bruno-app/src/components/Modal/index.js index ec715a03d..413118812 100644 --- a/packages/bruno-app/src/components/Modal/index.js +++ b/packages/bruno-app/src/components/Modal/index.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import StyledWrapper from './StyledWrapper'; const ModalHeader = ({ title, handleCancel }) => ( @@ -62,6 +62,8 @@ const Modal = ({ confirmDisabled, hideCancel, hideFooter, + disableCloseOnOutsideClick, + disableEscapeKey, closeModalFadeTimeout = 500 }) => { const [isClosing, setIsClosing] = useState(false); @@ -78,6 +80,7 @@ const Modal = ({ }; useEffect(() => { + if (disableEscapeKey) return; document.addEventListener('keydown', escFunction, false); return () => { @@ -111,9 +114,13 @@ const Modal = ({ {/* Clicking on backdrop closes the modal */}
{ - closeModal({ type: 'backdrop' }); - }} + onClick={ + disableCloseOnOutsideClick + ? null + : () => { + closeModal({ type: 'backdrop' }); + } + } /> ); diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/ConfirmRequestClose/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/ConfirmRequestClose/index.js index 392c188e6..5a6a08c3a 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/ConfirmRequestClose/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/ConfirmRequestClose/index.js @@ -1,7 +1,7 @@ import Modal from 'components/Modal'; import React from 'react'; -const ConfirmRequestClose = ({ onCancel, onCloseWithoutSave, onSaveAndClose }) => { +const ConfirmRequestClose = ({ item, onCancel, onCloseWithoutSave, onSaveAndClose }) => { const _handleCancel = ({ type }) => { if (type === 'button') { return onCloseWithoutSave(); @@ -22,7 +22,9 @@ const ConfirmRequestClose = ({ onCancel, onCloseWithoutSave, onSaveAndClose }) = disableCloseOnOutsideClick={true} closeModalFadeTimeout={150} > -
You have unsaved changes in your request.
+
+ You have unsaved changes in request {item.name}. +
); }; diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index 8fc27e90c..9c608d8d7 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -1,22 +1,25 @@ -import React, { useState } from 'react'; import get from 'lodash/get'; -import { closeTabs } from 'providers/ReduxStore/slices/tabs'; -import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; -import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections'; -import { useDispatch } from 'react-redux'; -import { findItemInCollection } from 'utils/collections'; -import StyledWrapper from './StyledWrapper'; -import RequestTabNotFound from './RequestTabNotFound'; -import ConfirmRequestClose from './ConfirmRequestClose'; -import SpecialTab from './SpecialTab'; +import { + cancelCloseDraft, + closeAndSaveDraft, + closeTabs, + closeWithoutSavingDraft, + setShowConfirmClose +} from 'providers/ReduxStore/slices/tabs'; import { useTheme } from 'providers/Theme'; +import React from 'react'; +import { useDispatch } from 'react-redux'; import darkTheme from 'themes/dark'; import lightTheme from 'themes/light'; +import { findItemInCollection } from 'utils/collections'; +import ConfirmRequestClose from './ConfirmRequestClose'; +import RequestTabNotFound from './RequestTabNotFound'; +import SpecialTab from './SpecialTab'; +import StyledWrapper from './StyledWrapper'; const RequestTab = ({ tab, collection }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); - const [showConfirmClose, setShowConfirmClose] = useState(false); const handleCloseClick = (event) => { event.stopPropagation(); @@ -28,6 +31,15 @@ const RequestTab = ({ tab, collection }) => { ); }; + const showConfirmClose = () => { + dispatch( + setShowConfirmClose({ + tabUid: tab.uid, + showConfirmClose: true + }) + ); + }; + const getMethodColor = (method = '') => { const theme = storedTheme === 'dark' ? darkTheme : lightTheme; @@ -90,37 +102,12 @@ const RequestTab = ({ tab, collection }) => { return ( - {showConfirmClose && ( + {tab.showConfirmClose && ( setShowConfirmClose(false)} - onCloseWithoutSave={() => { - dispatch( - deleteRequestDraft({ - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - dispatch( - closeTabs({ - tabUids: [tab.uid] - }) - ); - setShowConfirmClose(false); - }} - onSaveAndClose={() => { - dispatch(saveRequest(item.uid, collection.uid)) - .then(() => { - dispatch( - closeTabs({ - tabUids: [tab.uid] - }) - ); - setShowConfirmClose(false); - }) - .catch((err) => { - console.log('err', err); - }); - }} + item={item} + onCancel={() => dispatch(cancelCloseDraft(item.uid))} + onCloseWithoutSave={() => dispatch(closeWithoutSavingDraft(item.uid, collection.uid))} + onSaveAndClose={() => dispatch(closeAndSaveDraft(item.uid, collection.uid))} /> )}
@@ -135,8 +122,7 @@ const RequestTab = ({ tab, collection }) => { className="flex px-2 close-icon-container" onClick={(e) => { if (!item.draft) return handleCloseClick(e); - - setShowConfirmClose(true); + showConfirmClose(); }} > {!item.draft ? ( diff --git a/packages/bruno-app/src/providers/App/index.js b/packages/bruno-app/src/providers/App/index.js index 2fbd17e75..1022b5eec 100644 --- a/packages/bruno-app/src/providers/App/index.js +++ b/packages/bruno-app/src/providers/App/index.js @@ -1,17 +1,15 @@ -import React, { useEffect } from 'react'; -import useTelemetry from './useTelemetry'; -import useIpcEvents from './useIpcEvents'; -import useCollectionNextAction from './useCollectionNextAction'; -import { useDispatch } from 'react-redux'; import { refreshScreenWidth } from 'providers/ReduxStore/slices/app'; +import React, { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; import StyledWrapper from './StyledWrapper'; +import useIpcEvents from './useIpcEvents'; +import useTelemetry from './useTelemetry'; export const AppContext = React.createContext(); export const AppProvider = (props) => { useTelemetry(); useIpcEvents(); - useCollectionNextAction(); const dispatch = useDispatch(); diff --git a/packages/bruno-app/src/providers/App/useCollectionNextAction.js b/packages/bruno-app/src/providers/App/useCollectionNextAction.js deleted file mode 100644 index 57e14c2fc..000000000 --- a/packages/bruno-app/src/providers/App/useCollectionNextAction.js +++ /dev/null @@ -1,35 +0,0 @@ -import React, { useEffect } from 'react'; -import get from 'lodash/get'; -import each from 'lodash/each'; -import { addTab } from 'providers/ReduxStore/slices/tabs'; -import { getDefaultRequestPaneTab, findItemInCollectionByPathname } from 'utils/collections/index'; -import { hideHomePage } from 'providers/ReduxStore/slices/app'; -import { updateNextAction } from 'providers/ReduxStore/slices/collections/index'; -import { useSelector, useDispatch } from 'react-redux'; - -const useCollectionNextAction = () => { - const collections = useSelector((state) => state.collections.collections); - const dispatch = useDispatch(); - - useEffect(() => { - each(collections, (collection) => { - if (collection.nextAction && collection.nextAction.type === 'OPEN_REQUEST') { - const item = findItemInCollectionByPathname(collection, get(collection, 'nextAction.payload.pathname')); - - if (item) { - dispatch(updateNextAction({ collectionUid: collection.uid, nextAction: null })); - dispatch( - addTab({ - uid: item.uid, - collectionUid: collection.uid, - requestPaneTab: getDefaultRequestPaneTab(item.type) - }) - ); - dispatch(hideHomePage()); - } - } - }); - }, [collections, each, dispatch, updateNextAction, hideHomePage, addTab]); -}; - -export default useCollectionNextAction; diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js index 3f251f2fe..58e902e56 100644 --- a/packages/bruno-app/src/providers/App/useIpcEvents.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -1,22 +1,22 @@ -import { useEffect } from 'react'; -import { useDispatch } from 'react-redux'; +import { showPreferences, startQuitFlow, updateCookies, updatePreferences } from 'providers/ReduxStore/slices/app'; import { + brunoConfigUpdateEvent, collectionAddDirectoryEvent, collectionAddFileEvent, collectionChangeFileEvent, - collectionUnlinkFileEvent, + collectionRenamedEvent, collectionUnlinkDirectoryEvent, collectionUnlinkEnvFileEvent, - scriptEnvironmentUpdateEvent, + collectionUnlinkFileEvent, processEnvUpdateEvent, - collectionRenamedEvent, - runRequestEvent, runFolderEvent, - brunoConfigUpdateEvent + runRequestEvent, + scriptEnvironmentUpdateEvent } from 'providers/ReduxStore/slices/collections'; -import { showPreferences, updatePreferences, updateCookies } from 'providers/ReduxStore/slices/app'; +import { collectionAddEnvFileEvent, openCollectionEvent } from 'providers/ReduxStore/slices/collections/actions'; +import { useEffect } from 'react'; import toast from 'react-hot-toast'; -import { openCollectionEvent, collectionAddEnvFileEvent } from 'providers/ReduxStore/slices/collections/actions'; +import { useDispatch } from 'react-redux'; import { isElectron } from 'utils/common/platform'; const useIpcEvents = () => { @@ -80,6 +80,7 @@ const useIpcEvents = () => { }; ipcRenderer.invoke('renderer:ready'); + const removeCollectionTreeUpdateListener = ipcRenderer.on('main:collection-tree-updated', _collectionTreeUpdated); const removeOpenCollectionListener = ipcRenderer.on('main:collection-opened', (pathname, uid, brunoConfig) => { @@ -127,7 +128,7 @@ const useIpcEvents = () => { dispatch(brunoConfigUpdateEvent(val)) ); - const showPreferencesListener = ipcRenderer.on('main:open-preferences', () => { + const removeShowPreferencesListener = ipcRenderer.on('main:open-preferences', () => { dispatch(showPreferences(true)); }); @@ -139,6 +140,10 @@ const useIpcEvents = () => { dispatch(updateCookies(val)); }); + const removeStartQuitFlowListener = ipcRenderer.on('main:start-quit-flow', () => { + dispatch(startQuitFlow()); + }); + return () => { removeCollectionTreeUpdateListener(); removeOpenCollectionListener(); @@ -151,9 +156,10 @@ const useIpcEvents = () => { removeProcessEnvUpdatesListener(); removeConsoleLogListener(); removeConfigUpdatesListener(); - showPreferencesListener(); + removeShowPreferencesListener(); removePreferencesUpdatesListener(); removeCookieUpdateListener(); + removeStartQuitFlowListener(); }; }, [isElectron]); }; diff --git a/packages/bruno-app/src/providers/ReduxStore/index.js b/packages/bruno-app/src/providers/ReduxStore/index.js index d86b18fc4..5ac8056f9 100644 --- a/packages/bruno-app/src/providers/ReduxStore/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/index.js @@ -1,4 +1,5 @@ import { configureStore } from '@reduxjs/toolkit'; +import listenerMiddleware from './middlewares/listenerMiddleware'; import appReducer from './slices/app'; import collectionsReducer from './slices/collections'; import tabsReducer from './slices/tabs'; @@ -8,7 +9,8 @@ export const store = configureStore({ app: appReducer, collections: collectionsReducer, tabs: tabsReducer - } + }, + middleware: (getDefaultMiddleware) => getDefaultMiddleware().prepend(listenerMiddleware.middleware) }); export default store; diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/listenerMiddleware.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/listenerMiddleware.js new file mode 100644 index 000000000..27f2e894b --- /dev/null +++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/listenerMiddleware.js @@ -0,0 +1,123 @@ +import { createListenerMiddleware } from '@reduxjs/toolkit'; +import { completeQuitFlow, removeEventsFromQueue } from 'providers/ReduxStore/slices/app'; +import { addTab, closeTabs, focusTab, setShowConfirmClose } from 'providers/ReduxStore/slices/tabs'; +import { + findCollectionByUid, + findItemInCollection, + findItemInCollectionByPathname, + getDefaultRequestPaneTab +} from 'utils/collections/index'; +import { eventMatchesItem, eventTypes } from 'utils/events-queue/index'; +import { itemIsOpenedInTabs } from 'utils/tabs/index'; + +const listenerMiddleware = createListenerMiddleware(); + +listenerMiddleware.startListening({ + predicate: (action) => ['app/insertEventsIntoQueue', 'app/removeEventsFromQueue'].includes(action.type), + effect: async (action, listenerApi) => { + const state = listenerApi.getState(); + const { tabs } = state.tabs; + + // after events are added or removed from queue, it will handle the first (if there is any left) + const [firstEvent] = state.app.eventsQueue; + if (!firstEvent) return; + + if (firstEvent.eventType === eventTypes.CLOSE_APP) { + // this events closes the window + return listenerApi.dispatch(completeQuitFlow()); + } + + const { itemUid, itemPathname, collectionUid, eventType } = firstEvent; + let eventItem = null; + if (firstEvent.eventType === eventTypes.OPEN_REQUEST) { + // this event adds or opens a request + const collection = findCollectionByUid(state.collections.collections, collectionUid); + eventItem = findItemInCollectionByPathname(collection, itemPathname); + if (!eventItem) { + // waiting until item is added into collection (only happens after IO completes) before handling event + // this happens when first opening a request just after creating it + await listenerApi.condition((action, currentState, originalState) => { + const { collections } = currentState.collections; + const collection = findCollectionByUid(collections, collectionUid); + const item = findItemInCollectionByPathname(collection, itemPathname); + if (item) eventItem = item; + return !!item; + }); + } + } else { + const { collections } = state.collections; + const collection = findCollectionByUid(collections, collectionUid); + const item = findItemInCollection(collection, itemUid); + if (item) eventItem = item; + } + if (eventItem) { + switch (eventType) { + case eventTypes.OPEN_REQUEST: // this event adds or opens a request + return listenerApi.dispatch( + itemIsOpenedInTabs(eventItem, tabs) + ? focusTab({ + uid: eventItem.uid + }) + : addTab({ + uid: eventItem.uid, + collectionUid, + requestPaneTab: getDefaultRequestPaneTab(eventItem) + }) + ); + case eventTypes.CLOSE_REQUEST: // this event closes a request or prompts the user to save it if has pending changes + return listenerApi.dispatch( + eventItem.draft + ? setShowConfirmClose({ + tabUid: eventItem.uid, + showConfirmClose: true + }) + : closeTabs({ + tabUids: [eventItem.uid] + }) + ); + } + } + } +}); + +listenerMiddleware.startListening({ + predicate: (action) => ['tabs/addTab', 'tabs/focusTab'].includes(action.type), + effect: (action, listenerApi) => { + let { uid, collectionUid } = action.payload; + const state = listenerApi.getState(); + const { eventsQueue } = state.app; + const { collections } = state.collections; + const { tabs } = state.tabs; + + // after tab is opened, remove corresponding event from start of queue (if any) + const [firstEvent] = eventsQueue; + if (firstEvent && firstEvent.eventType == eventTypes.OPEN_REQUEST) { + collectionUid = collectionUid ?? tabs.find((t) => t.uid === uid).collectionUid; + const collection = findCollectionByUid(collections, collectionUid); + const item = findItemInCollection(collection, uid); + const eventToRemove = eventMatchesItem(firstEvent, item) ? firstEvent : null; + if (eventToRemove) { + listenerApi.dispatch(removeEventsFromQueue([eventToRemove])); + } + } + } +}); + +listenerMiddleware.startListening({ + actionCreator: closeTabs, + effect: (action, listenerApi) => { + const state = listenerApi.getState(); + const { tabUids } = action.payload; + const { eventsQueue } = state.app; + + // after tab is closed, remove corresponding event from start of queue (if any) + const [firstEvent] = eventsQueue; + if (!firstEvent || firstEvent.eventType !== eventTypes.CLOSE_REQUEST) return; + const eventToRemove = tabUids.some((uid) => uid === firstEvent.itemUid) ? firstEvent : null; + if (eventToRemove) { + listenerApi.dispatch(removeEventsFromQueue([eventToRemove])); + } + } +}); + +export default listenerMiddleware; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index 622a4a7bd..054ebf737 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -1,5 +1,11 @@ import { createSlice } from '@reduxjs/toolkit'; +import filter from 'lodash/filter'; +import groupBy from 'lodash/groupBy'; import toast from 'react-hot-toast'; +import { isItemARequest } from 'utils/collections'; +import { findCollectionByUid, flattenItems } from 'utils/collections/index'; +import { uuid } from 'utils/common'; +import { eventTypes } from 'utils/events-queue/index'; const initialState = { isDragging: false, @@ -21,7 +27,8 @@ const initialState = { codeFont: 'default' } }, - cookies: [] + cookies: [], + eventsQueue: [] }; export const appSlice = createSlice({ @@ -54,6 +61,19 @@ export const appSlice = createSlice({ }, updateCookies: (state, action) => { state.cookies = action.payload; + }, + insertEventsIntoQueue: (state, action) => { + state.eventsQueue = state.eventsQueue.concat(action.payload); + }, + removeEventsFromQueue: (state, action) => { + const eventsToRemove = action.payload; + state.eventsQueue = filter( + state.eventsQueue, + (event) => !eventsToRemove.some((e) => e.eventUid === event.eventUid) + ); + }, + removeAllEventsFromQueue: (state) => { + state.eventsQueue = []; } } }); @@ -67,7 +87,10 @@ export const { hideHomePage, showPreferences, updatePreferences, - updateCookies + updateCookies, + insertEventsIntoQueue, + removeEventsFromQueue, + removeAllEventsFromQueue } = appSlice.actions; export const savePreferences = (preferences) => (dispatch, getState) => { @@ -95,4 +118,55 @@ export const deleteCookiesForDomain = (domain) => (dispatch, getState) => { }); }; +export const startQuitFlow = () => (dispatch, getState) => { + const state = getState(); + + // Before closing the app, checks for unsaved requests (drafts) + const currentDrafts = []; + const { collections } = state.collections; + const { tabs } = state.tabs; + + const tabsByCollection = groupBy(tabs, (t) => t.collectionUid); + Object.keys(tabsByCollection).forEach((collectionUid) => { + const collectionItems = flattenItems(findCollectionByUid(collections, collectionUid).items); + let openedTabs = tabsByCollection[collectionUid]; + for (const item of collectionItems) { + if (isItemARequest(item) && item.draft) { + openedTabs = filter(openedTabs, (t) => t.uid !== item.uid); + currentDrafts.push({ ...item, collectionUid }); + } + if (!openedTabs.length) return; + } + }); + + // If there are no drafts, closes the window + if (currentDrafts.length === 0) { + return dispatch(completeQuitFlow()); + } + + // Sequence of events tracked by listener middleware + // For every draft, it will focus the request and immediately prompt if the user wants to save it + // At the end of the sequence, closes the window + const events = currentDrafts + .reduce((acc, draft) => { + const { uid, pathname, collectionUid } = draft; + const defaultProperties = { itemUid: uid, collectionUid, itemPathname: pathname }; + acc.push( + ...[ + { eventUid: uuid(), eventType: eventTypes.OPEN_REQUEST, ...defaultProperties }, + { eventUid: uuid(), eventType: eventTypes.CLOSE_REQUEST, ...defaultProperties } + ] + ); + return acc; + }, []) + .concat([{ eventUid: uuid(), eventType: eventTypes.CLOSE_APP }]); + + dispatch(insertEventsIntoQueue(events)); +}; + +export const completeQuitFlow = () => (dispatch, getState) => { + const { ipcRenderer } = window; + return ipcRenderer.invoke('main:complete-quit-flow'); +}; + export default appSlice.reducer; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 9c799c234..e393e40f4 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -1,51 +1,45 @@ -import path from 'path'; -import toast from 'react-hot-toast'; -import trim from 'lodash/trim'; +import { collectionSchema, environmentSchema, itemSchema } from '@usebruno/schema'; +import cloneDeep from 'lodash/cloneDeep'; +import filter from 'lodash/filter'; import find from 'lodash/find'; import get from 'lodash/get'; -import filter from 'lodash/filter'; -import { uuid } from 'utils/common'; -import cloneDeep from 'lodash/cloneDeep'; +import trim from 'lodash/trim'; +import path from 'path'; +import { insertEventsIntoQueue } from 'providers/ReduxStore/slices/app'; +import toast from 'react-hot-toast'; import { - findItemInCollection, - moveCollectionItem, - getItemsToResequence, - moveCollectionItemToRootOfCollection, findCollectionByUid, - transformRequestToSaveToFilesystem, - findParentItemInCollection, findEnvironmentInCollection, - isItemARequest, + findItemInCollection, + findParentItemInCollection, + getItemsToResequence, isItemAFolder, - refreshUidsInItem + isItemARequest, + moveCollectionItem, + moveCollectionItemToRootOfCollection, + refreshUidsInItem, + transformRequestToSaveToFilesystem } from 'utils/collections'; -import { collectionSchema, itemSchema, environmentSchema, environmentsSchema } from '@usebruno/schema'; -import { waitForNextTick } from 'utils/common'; -import { getDirectoryName, isWindowsOS, PATH_SEPARATOR } from 'utils/common/platform'; -import { sendNetworkRequest, cancelNetworkRequest } from 'utils/network'; +import { uuid, waitForNextTick } from 'utils/common'; +import { PATH_SEPARATOR, getDirectoryName } from 'utils/common/platform'; +import { cancelNetworkRequest, sendNetworkRequest } from 'utils/network'; import { - updateLastAction, - updateNextAction, - resetRunResults, - requestCancelled, - responseReceived, - newItem as _newItem, - cloneItem as _cloneItem, - deleteItem as _deleteItem, - saveRequest as _saveRequest, - selectEnvironment as _selectEnvironment, + collectionAddEnvFileEvent as _collectionAddEnvFileEvent, createCollection as _createCollection, - renameCollection as _renameCollection, removeCollection as _removeCollection, + selectEnvironment as _selectEnvironment, sortCollections as _sortCollections, - collectionAddEnvFileEvent as _collectionAddEnvFileEvent + requestCancelled, + resetRunResults, + responseReceived, + updateLastAction } from './index'; +import { each } from 'lodash'; import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs'; import { resolveRequestFilename } from 'utils/common/platform'; import { parseQueryParams, splitOnFirst } from 'utils/url/index'; -import { each } from 'lodash'; export const renameCollection = (newName, collectionUid) => (dispatch, getState) => { const state = getState(); @@ -595,7 +589,6 @@ export const newHttpRequest = (params) => (dispatch, getState) => { urlParam.enabled = true; }); - const collectionCopy = cloneDeep(collection); const item = { uid: uuid(), type: requestType, @@ -632,18 +625,16 @@ export const newHttpRequest = (params) => (dispatch, getState) => { const { ipcRenderer } = window; ipcRenderer.invoke('renderer:new-request', fullName, item).then(resolve).catch(reject); - // the useCollectionNextAction() will track this and open the new request in a new tab - // once the request is created + // listener middleware will track this and open the new request in a new tab once request is created dispatch( - updateNextAction({ - nextAction: { - type: 'OPEN_REQUEST', - payload: { - pathname: fullName - } - }, - collectionUid - }) + insertEventsIntoQueue([ + { + eventUid: uuid(), + eventType: 'OPEN_REQUEST', + collectionUid, + itemPathname: fullName + } + ]) ); } else { return reject(new Error('Duplicate request names are not allowed under the same folder')); @@ -662,19 +653,16 @@ export const newHttpRequest = (params) => (dispatch, getState) => { const { ipcRenderer } = window; ipcRenderer.invoke('renderer:new-request', fullName, item).then(resolve).catch(reject); - - // the useCollectionNextAction() will track this and open the new request in a new tab - // once the request is created + // listener middleware will track this and open the new request in a new tab once request is created dispatch( - updateNextAction({ - nextAction: { - type: 'OPEN_REQUEST', - payload: { - pathname: fullName - } - }, - collectionUid - }) + insertEventsIntoQueue([ + { + eventUid: uuid(), + eventType: 'OPEN_REQUEST', + collectionUid, + itemPathname: fullName + } + ]) ); } else { return reject(new Error('Duplicate request names are not allowed under the same folder')); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index ae2df4a0b..09bf07061 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -1,30 +1,29 @@ -import { uuid } from 'utils/common'; -import find from 'lodash/find'; -import map from 'lodash/map'; -import forOwn from 'lodash/forOwn'; -import concat from 'lodash/concat'; -import filter from 'lodash/filter'; -import each from 'lodash/each'; -import cloneDeep from 'lodash/cloneDeep'; -import get from 'lodash/get'; -import set from 'lodash/set'; import { createSlice } from '@reduxjs/toolkit'; -import { splitOnFirst } from 'utils/url'; +import cloneDeep from 'lodash/cloneDeep'; +import concat from 'lodash/concat'; +import each from 'lodash/each'; +import filter from 'lodash/filter'; +import find from 'lodash/find'; +import forOwn from 'lodash/forOwn'; +import get from 'lodash/get'; +import map from 'lodash/map'; +import set from 'lodash/set'; import { - findCollectionByUid, - findCollectionByPathname, - findItemInCollection, - findEnvironmentInCollection, - findItemInCollectionByPathname, addDepth, + areItemsTheSameExceptSeqUpdate, collapseCollection, deleteItemInCollection, deleteItemInCollectionByPathname, - isItemARequest, - areItemsTheSameExceptSeqUpdate + findCollectionByPathname, + findCollectionByUid, + findEnvironmentInCollection, + findItemInCollection, + findItemInCollectionByPathname, + isItemARequest } from 'utils/collections'; -import { parseQueryParams, stringifyQueryParams } from 'utils/url'; -import { getSubdirectoriesFromRoot, getDirectoryName, PATH_SEPARATOR } from 'utils/common/platform'; +import { uuid } from 'utils/common'; +import { PATH_SEPARATOR, getDirectoryName, getSubdirectoriesFromRoot } from 'utils/common/platform'; +import { parseQueryParams, splitOnFirst, stringifyQueryParams } from 'utils/url'; const initialState = { collections: [], @@ -50,10 +49,6 @@ export const collectionsSlice = createSlice({ collection.importedAt = new Date().getTime(); collection.lastAction = null; - // an improvement over the above approach. - // this defines an action that need to be performed next and is executed vy the useCollectionNextAction() - collection.nextAction = null; - collapseCollection(collection); addDepth(collection.items); if (!collectionUids.includes(collection.uid)) { @@ -100,14 +95,6 @@ export const collectionsSlice = createSlice({ collection.lastAction = lastAction; } }, - updateNextAction: (state, action) => { - const { collectionUid, nextAction } = action.payload; - const collection = findCollectionByUid(state.collections, collectionUid); - - if (collection) { - collection.nextAction = nextAction; - } - }, updateSettingsSelectedTab: (state, action) => { const { collectionUid, tab } = action.payload; @@ -1394,7 +1381,6 @@ export const { removeCollection, sortCollections, updateLastAction, - updateNextAction, updateSettingsSelectedTab, collectionUnlinkEnvFileEvent, saveEnvironment, diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js index 3cf070d68..ffc4254a5 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js @@ -1,7 +1,11 @@ -import find from 'lodash/find'; -import filter from 'lodash/filter'; -import last from 'lodash/last'; import { createSlice } from '@reduxjs/toolkit'; +import filter from 'lodash/filter'; +import find from 'lodash/find'; +import last from 'lodash/last'; +import { removeAllEventsFromQueue } from 'providers/ReduxStore/slices/app'; +import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections'; +import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { eventTypes } from 'utils/events-queue/index'; // todo: errors should be tracked in each slice and displayed as toasts @@ -101,6 +105,11 @@ export const tabsSlice = createSlice({ const collectionUid = action.payload.collectionUid; state.tabs = filter(state.tabs, (t) => t.collectionUid !== collectionUid); state.activeTabUid = null; + }, + setShowConfirmClose: (state, action) => { + const { tabUid, showConfirmClose } = action.payload; + const tab = find(state.tabs, (t) => t.uid === tabUid); + if (tab) tab.showConfirmClose = showConfirmClose; } } }); @@ -112,7 +121,46 @@ export const { updateRequestPaneTab, updateResponsePaneTab, closeTabs, - closeAllCollectionTabs + closeAllCollectionTabs, + setShowConfirmClose } = tabsSlice.actions; +export const closeAndSaveDraft = (itemUid, collectionUid) => (dispatch) => { + dispatch(saveRequest(itemUid, collectionUid)).then(() => { + dispatch( + closeTabs({ + tabUids: [itemUid] + }) + ); + dispatch(setShowConfirmClose({ tabUid: itemUid, showConfirmClose: false })); + }); +}; + +export const closeWithoutSavingDraft = (itemUid, collectionUid) => (dispatch) => { + dispatch( + deleteRequestDraft({ + itemUid: itemUid, + collectionUid: collectionUid + }) + ); + dispatch( + closeTabs({ + tabUids: [itemUid] + }) + ); + dispatch(setShowConfirmClose({ tabUid: itemUid, showConfirmClose: false })); +}; + +export const cancelCloseDraft = (itemUid) => (dispatch, getState) => { + const state = getState(); + dispatch(setShowConfirmClose({ tabUid: itemUid, showConfirmClose: false })); + + // check if there was an event to close this tab and aborts the sequence + const { eventsQueue } = state.app; + const [firstEvent] = eventsQueue; + if (firstEvent && firstEvent.eventType === eventTypes.CLOSE_REQUEST && firstEvent.itemUid === itemUid) { + dispatch(removeAllEventsFromQueue()); + } +}; + export default tabsSlice.reducer; diff --git a/packages/bruno-app/src/utils/events-queue/index.js b/packages/bruno-app/src/utils/events-queue/index.js new file mode 100644 index 000000000..694b8bc15 --- /dev/null +++ b/packages/bruno-app/src/utils/events-queue/index.js @@ -0,0 +1,9 @@ +export const eventTypes = { + OPEN_REQUEST: 'OPEN_REQUEST', + CLOSE_REQUEST: 'CLOSE_REQUEST', + CLOSE_APP: 'CLOSE_APP' +}; + +export const eventMatchesItem = (event, item) => { + return event.itemUid === item.uid || event.itemPathname === item.pathname; +}; diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index c9095b3a4..d82b93a42 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -1,7 +1,7 @@ const path = require('path'); const isDev = require('electron-is-dev'); const { format } = require('url'); -const { BrowserWindow, app, Menu } = require('electron'); +const { BrowserWindow, app, Menu, ipcMain } = require('electron'); const { setContentSecurityPolicy } = require('electron-util'); const menuTemplate = require('./app/menu-template'); @@ -97,6 +97,10 @@ app.on('ready', async () => { mainWindow.on('maximize', () => saveMaximized(true)); mainWindow.on('unmaximize', () => saveMaximized(false)); + mainWindow.on('close', (e) => { + e.preventDefault(); + ipcMain.emit('main:start-quit-flow'); + }); mainWindow.webContents.on('will-redirect', (event, url) => { event.preventDefault(); diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 76bb661d8..9cb9829ef 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -1,7 +1,7 @@ const _ = require('lodash'); const fs = require('fs'); const path = require('path'); -const { ipcMain, shell, dialog } = require('electron'); +const { ipcMain, shell, dialog, app } = require('electron'); const { envJsonToBru, bruToJson, jsonToBru, jsonToCollectionBru } = require('../bru'); const { @@ -585,6 +585,14 @@ const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) = watcher.addWatcher(win, pathname, uid); lastOpenedCollections.add(pathname); }); + + ipcMain.on('main:start-quit-flow', () => { + mainWindow.webContents.send('main:start-quit-flow'); + }); + + ipcMain.handle('main:complete-quit-flow', () => { + mainWindow.destroy(); + }); }; const registerCollectionsIpc = (mainWindow, watcher, lastOpenedCollections) => {