Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Preventing Closing Tab/Browser And Leaving Page Without Confirmation Modal For Invoices #1419

Merged
merged 57 commits into from
May 27, 2024

Conversation

Civolilah
Copy link
Collaborator

@Civolilah Civolilah commented Dec 14, 2023

@beganovich @turbo124 The PR includes the implementation of preventing users from leaving the Creation and Edition pages for Invoices if changes have been made to the corresponding Invoice for which the page is open. It INVOLVES preventing users from leaving the page through the app buttons/links (navigating to another app page), preventing the closure of the tab or the entire browser, preventing navigation back in the history using the back browser button, preventing direct entry of URLs in the browser, and preventing page reloading. Screenshot:

Screenshot 2023-12-18 at 02 08 38

Let me know your thoughts.

@CLAassistant
Copy link

CLAassistant commented Dec 14, 2023

CLA assistant check
All committers have signed the CLA.

@Civolilah Civolilah marked this pull request as ready for review December 18, 2023 01:14
@Civolilah Civolilah marked this pull request as draft December 18, 2023 02:58
@Civolilah Civolilah marked this pull request as ready for review December 18, 2023 17:45
Copy link
Member

@beganovich beganovich left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While it works, it kinda feels really complex. Can you walk us through the implementation?

Also is it possible to provide test suite for such critical part of the app.

src/App.tsx Outdated
Comment on lines 39 to 57
interface PreventLeavingPage {
prevent: boolean;
actionKey?: 'switchCompany' | 'browserBack';
}

interface HistoryLocation {
lastLocation: string;
nonPreventedLocations: string[];
}

export const preventLeavingPageAtom = atom<PreventLeavingPage>({
prevent: false,
actionKey: undefined,
});

export const lastHistoryLocationAtom = atom<HistoryLocation>({
lastLocation: '',
nonPreventedLocations: [],
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

app.tsx is getting quite chunky, please move this into a separate module or usePreventNavigation, that works, too.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@beganovich Yeah, I separated it into the hook called useAddPreventNavigationEvents.

src/App.tsx Outdated

interface PreventLeavingPage {
prevent: boolean;
actionKey?: 'switchCompany' | 'browserBack';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What exactly is actionKey and what it is used for?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@beganovich We have two cases when the actionKey is needed. The actionKey is a property that defines what is currently triggered in our case—either the switch company functionality or triggering the back button on the browser.

So, why is it important?!

The functionality of switching companies is based on the browser reloading, which immediately means that if needed at that moment, the beforeunload event will be triggered, and the browser will show the default modal for preventing leaving the page. This modal provides 'Cancel' and 'Leave' buttons, but in the case of switching companies, 'Cancel' will work just as we need. However, the 'Leave' button will not know how to switch the company (it will not remember the 'switchCompany' function and execute it as needed). Therefore, we need to display our own modal and also prevent the default one from being displayed. Essentially, the 'switchCompany' actionKey is only used when it is necessary to prevent the display of the default browser's modal.

The second 'browserBack' action key is used when it is necessary to push a new state into the history. I conducted thorough research, and it is impossible to disable the back button of the browser unless we push the current page into the state. This way, when the user clicks the back button, they will stay on that page. So, this actionKey is used when we need to do that, avoiding unnecessary pushing of a new state into the history. Additionally, there is another case when it is also very important. If the user triggers the back button and then triggers the 'continue editing' button on the prevent navigation modal multiple times in a row (potentially), they will have, let's say, 10+ instances of the current page in the history object. Therefore, when the prevention of navigation is no longer needed, the user would have to click the back button 10+ times in the browser to go back to the previously non-prevented page. To avoid this poor UX, I store the last page once the user is not prevented from navigation, and then I navigate the user to that page instead of navigating to the exact same page where they are at that moment.

Please let me know if you need any additional explanation for this part of the logic.

Comment on lines 43 to 87
const handleDiscardChanges = () => {
const isBrowserBackAction = preventLeavingPage.actionKey === 'browserBack';

const { url, externalLink, fn } = blockedNavigationAction || {};

if (!externalLink) {
setLastHistoryLocation((current) => ({
...current,
lastLocation: '',
}));
setPreventLeavingPage({ prevent: false, actionKey: undefined });
}

setIsNavigationModalVisible(false);

const numberOfNonPreventedLocations = nonPreventedLocations.length;

let lastNonPreventedLocation =
nonPreventedLocations[numberOfNonPreventedLocations - 1];

lastNonPreventedLocation =
lastNonPreventedLocation !== location.pathname
? lastNonPreventedLocation
: nonPreventedLocations[numberOfNonPreventedLocations - 2];

if (isBrowserBackAction && lastNonPreventedLocation) {
navigate(lastNonPreventedLocation);
}

if (blockedNavigationAction) {
if (url) {
if (url === 'back') {
lastNonPreventedLocation && navigate(lastNonPreventedLocation);
} else {
if (externalLink) {
window.open(url, '_blank');
} else {
navigate(url);
}
}
}

fn?.();
}
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks fairly complex. Any chance we can provide some tests for this one? We definitely don't want navigation just randomly failing, that'd be bad.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@beganovich Sure, explaining the entire logic in this comment: #1419 (review)

@Civolilah Civolilah marked this pull request as draft December 20, 2023 15:05
@Civolilah Civolilah marked this pull request as draft May 14, 2024 17:46
@Civolilah
Copy link
Collaborator Author

on review, it appears only certain fields are intercepted.

the line items and any data within the text area's are not triggering the modal.

@turbo124 Yeah, I can recreate it, it only happens with text area input fields. I will fix it.

@beganovich
Copy link
Member

@Civolilah please mark this as QA pending and ready for review once it's ready.

@Civolilah Civolilah marked this pull request as ready for review May 26, 2024 19:16
@Civolilah
Copy link
Collaborator Author

@beganovich @turbo124 The PR now includes the fixes we discussed, along with fixes to the tests so we can run them locally at any time to check for any issues. The PR is ready for review. Let me know your thoughts.

@turbo124
Copy link
Member

tinymce input is not being triggered for this action. also need to ensure any inputs in text area in the line items are also captured.

@Civolilah
Copy link
Collaborator Author

tinymce input is not being triggered for this action. also need to ensure any inputs in text area in the line items are also captured.

@turbo124 Ah, great catch! I was able to recreate it. The issue was with the reference. Now, the issue should be fixed, and I cannot recreate it anymore. It should work for any input field, including the TinyMCE markdowns and any text area input fields as you mentioned. Let me know if you also see it fixed.

Comment on lines 72 to 73
? new InvoiceSumInclusive({ ...invoice }, currency).build()
: new InvoiceSum({ ...invoice }, currency).build();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this is still required? Isn't the spread operator part of previous implementation?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@beganovich Yes, you are right, this is unnecessary until we improve the implementation. So, I've removed the spreading. Along with this removal, I've fixed conflicts related to merging other PRs. Let me know your thoughts.

@beganovich beganovich merged commit d4d598e into invoiceninja:develop May 27, 2024
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants