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

feat: add warning when navigating away with unsaved changes in environment settings #5049

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions frontend/common/providers/ProjectProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type CreateEnvType = (data: {
cloneFeatureStatesAsync?: boolean
metadata: Environment['metadata']
}) => void

export type ProjectProviderType = {
children: (props: {
createEnv: CreateEnvType
Expand Down
11 changes: 9 additions & 2 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,23 @@ export type Environment = {
is_creating: boolean
api_key: string
description?: string
banner_text?: string
banner_text?: string | null
banner_colour?: string
project: number
minimum_change_request_approvals?: number
minimum_change_request_approvals?: number | null
allow_client_traits: boolean
hide_sensitive_data: boolean
total_segment_overrides?: number
use_v2_feature_versioning: boolean
metadata: Metadata[] | []
use_identity_overrides_in_local_eval: boolean
use_identity_composite_key_for_hashing: boolean
hide_disabled_flags: boolean | null
use_mv_v2_evaluation: boolean
show_disabled_flags: boolean
enabledFeatureVersioning?: boolean
}

export type Project = {
id: number
uuid: string
Expand Down
7 changes: 7 additions & 0 deletions frontend/common/types/webhooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export type Webhook = {
id: string
url: string
secret: string
enabled: boolean
created_at: string
}
2 changes: 1 addition & 1 deletion frontend/web/components/IntegrationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const Integration: FC<IntegrationProps> = (props) => {
props.addIntegration(props.integration, props.id)
}
}

const openChildWin = () => {
const childWindow = window.open(
`${Project.githubAppURL}`,
Expand Down
4 changes: 2 additions & 2 deletions frontend/web/components/base/forms/Tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,14 @@ const Tabs = class extends React.Component {
}
this.props.onChange?.(i)
}}
className={`btn-tab ${isSelected ? ' tab-active' : ''}`}
className={`btn-tab ${this.props.noFocus ? 'btn-no-focus' : ''} ${isSelected ? ' tab-active' : ''}`}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a very specific implementation related to the hook. The selected tab would keep the focus on after interacting with the newly implemented modal

>
{child.props.tabLabel}
</Button>
)
})}
</div>
{this.props.theme === 'tab' && !hideNav && (
{this.props.theme === 'tab' && !hideNav && (
<ModalHR className='tab-nav-hr' />
)}
<div className='tabs-content'>
Expand Down
104 changes: 104 additions & 0 deletions frontend/web/components/hooks/useFormNotSavedModal.tsx
Copy link
Member

Choose a reason for hiding this comment

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

Awesome! I guess we can start using this in a lot of places

Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { useState, useEffect, useRef, useCallback } from "react"
import { RouterChildContext } from "react-router-dom"
import { Modal, ModalHeader, ModalBody } from "reactstrap"

/**
* useFormNotSavedModal
* @param {history: RouterChildContext['router']['history']} history - The history object
* @param {string} warningMessage - The message to show when user attempts to leave
* @returns {[React.FC, Function, boolean]}
*/

type UseFormNotSavedModalReturn = [React.FC, React.Dispatch<React.SetStateAction<boolean>>, boolean]

interface UseFormNotSavedModalOptions {
warningMessage?: string
onDiscard?: () => void
}

const useFormNotSavedModal = (
history: RouterChildContext['router']['history'],
options: UseFormNotSavedModalOptions = {}
): UseFormNotSavedModalReturn => {
const { onDiscard, warningMessage = "You have unsaved changes, are you sure you want to leave?" } = options

const [isDirty, setIsDirty] = useState(false)
const [isNavigating, setIsNavigating] = useState(false);
const [nextLocation, setNextLocation] = useState<Location | null>(null);

const unblockRef = useRef<(() => void) | null>(null);
useEffect(() => {
if (!isDirty) return;

const unblock = history.block((transition: Location) => {
setNextLocation(transition);
setIsNavigating(true);
return false;
});

unblockRef.current = unblock;
return () => {
if (unblockRef.current) {
unblockRef.current();
}
unblockRef.current = null;
};
}, [isDirty, history]);

const confirmNavigation = useCallback(() => {
// allow the route change to happen
if (unblockRef.current) {
unblockRef.current(); // unblocks
unblockRef.current = null;
}
// navigate
if (nextLocation) {
history.push(`${nextLocation.pathname}${nextLocation.search}`);
}
if (onDiscard) {
onDiscard()
}
setIsDirty(false)
setIsNavigating(false);
setNextLocation(null);
}, [nextLocation, history, onDiscard]);

const cancelNavigation = useCallback(() => {
history.push(`${history.location.pathname}${history.location.search}`);
setIsNavigating(false);
setNextLocation(null);
}, [history]);

// Listen for browser/tab close (the 'beforeunload' event)
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (!isDirty) return
event.preventDefault()
event.returnValue = warningMessage
}

window.addEventListener("beforeunload", handleBeforeUnload)
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload)
}
}, [isDirty, warningMessage])

const DirtyFormModal = () => (
<Modal isOpen={isDirty && isNavigating} toggle={cancelNavigation}>
<ModalHeader>
<h5>Unsaved Changes</h5>
</ModalHeader>
<ModalBody>
<p>{warningMessage}</p>
</ModalBody>
<div className="modal-footer">
<Button onClick={confirmNavigation} theme="secondary" className="mr-2">Yes, discard changes</Button>
<Button onClick={cancelNavigation} class="btn-primary">Cancel</Button>
</div>
</Modal>
)

return [DirtyFormModal, setIsDirty, isDirty]
}

export default useFormNotSavedModal
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const ConfirmRemoveEnvironment: FC<ConfirmRemoveEnvironmentType> = ({
cb()
}
}

return (
<ProjectProvider>
{() => (
Expand Down
Loading
Loading