Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/components/app/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import DownloadSettings from '../download/DownloadSettings.jsx'
import LayersPanel from '../layers/LayersPanel.jsx'
import MapPosition from '../map/MapPosition.jsx'
import AppMenu from './AppMenu.jsx'
import ConfirmLeaveModal from './ConfirmLeaveModal.jsx'
import DetailsPanel from './DetailsPanel.jsx'
import ModalContainer from './ModalContainer.jsx'
import './App.css'
Expand All @@ -28,7 +29,7 @@ const App = () => {
)
}, [])

useLoadMap()
const { locationToConfirm, confirmLeave, cancelLeave } = useLoadMap()
useLoadDataStore()
useLayersLoader()

Expand Down Expand Up @@ -69,6 +70,12 @@ const App = () => {
)}
</div>
<ModalContainer />
{locationToConfirm && (
<ConfirmLeaveModal
onCancel={cancelLeave}
onConfirm={confirmLeave}
/>
)}
</>
)
}
Expand Down
47 changes: 47 additions & 0 deletions src/components/app/ConfirmLeaveModal.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import i18n from '@dhis2/d2-i18n'
import {
Modal,
ModalTitle,
ModalContent,
ModalActions,
Button,
ButtonStrip,
} from '@dhis2/ui'
import PropTypes from 'prop-types'
import React from 'react'

const ConfirmLeaveModal = ({ onCancel, onConfirm }) => (
<Modal small dataTest="confirm-leave-modal">
<ModalTitle>{i18n.t('Discard unsaved changes?')}</ModalTitle>
<ModalContent>
{i18n.t(
'Are you sure you want to leave this map? Any unsaved changes will be lost.'
)}
</ModalContent>
<ModalActions>
<ButtonStrip end>
<Button
secondary
onClick={onCancel}
dataTest="confirm-leave-modal-option-cancel"
>
{i18n.t('No, cancel')}
</Button>
<Button
primary
onClick={onConfirm}
dataTest="confirm-leave-modal-option-confirm"
>
{i18n.t('Yes, leave')}
</Button>
</ButtonStrip>
</ModalActions>
</Modal>
)

ConfirmLeaveModal.propTypes = {
onCancel: PropTypes.func.isRequired,
onConfirm: PropTypes.func.isRequired,
}

export default ConfirmLeaveModal
2 changes: 1 addition & 1 deletion src/components/app/FileMenu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ const FileMenu = ({ onFileMenuAction }) => {
saveAsAlert.show({ msg: getSavedMessage(getMapName(name)) })

if (res.response.uid) {
history.push(`/${res.response.uid}`)
history.push(`/${res.response.uid}`, { isSaving: true })
}
}
}
Expand Down
25 changes: 25 additions & 0 deletions src/components/app/__tests__/ConfirmLeaveModal.spec.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { render, fireEvent, screen } from '@testing-library/react'
import React from 'react'
import ConfirmLeaveModal from '../ConfirmLeaveModal.jsx'

describe('ConfirmLeaveModal', () => {
it('calls onCancel when the cancel button is clicked', () => {
const onCancel = jest.fn()
render(<ConfirmLeaveModal onCancel={onCancel} onConfirm={jest.fn()} />)

fireEvent.click(screen.getByTestId('confirm-leave-modal-option-cancel'))

expect(onCancel).toHaveBeenCalledTimes(1)
})

it('calls onConfirm when the leave button is clicked', () => {
const onConfirm = jest.fn()
render(<ConfirmLeaveModal onCancel={jest.fn()} onConfirm={onConfirm} />)

fireEvent.click(
screen.getByTestId('confirm-leave-modal-option-confirm')
)

expect(onConfirm).toHaveBeenCalledTimes(1)
})
})
181 changes: 181 additions & 0 deletions src/components/app/__tests__/useLoadMap.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { renderHook, act, waitFor } from '@testing-library/react'
import history from '../../../util/history.js'
import { useLoadMap } from '../useLoadMap.js'

const mockDispatch = jest.fn()
let mockMapState
let mockSavedMapState

jest.mock('query-string', () => ({
parse: jest.fn(() => ({})),
}))

jest.mock('react-redux', () => ({
useDispatch: () => mockDispatch,
useSelector: (selector) =>
selector({ map: mockMapState, savedMap: mockSavedMapState }),
}))

const mockEngine = {
query: jest.fn().mockResolvedValue({}),
mutate: jest.fn().mockResolvedValue({}),
}

jest.mock('@dhis2/app-runtime', () => ({
useDataEngine: () => mockEngine,
}))

jest.mock('@dhis2/app-service-alerts', () => ({
useAlert: () => ({ show: jest.fn() }),
}))

const mockCachedData = {
systemSettings: { keyDefaultBaseMap: 'osm' },
basemaps: [{ id: 'osm' }],
}

jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({
useCachedData: () => mockCachedData,
}))

jest.mock('../../../util/requests.js', () => ({
fetchMap: jest.fn((args) =>
Promise.resolve({
id: args.id,
basemap: { id: 'osm' },
mapViews: [],
})
),
}))

jest.mock('../../../util/basemaps.js', () => ({
getBasemapOrFallback: ({ id }) => ({ id }),
}))

const dirtyMap = { id: 'map1', mapViews: [{ id: 'layer1', opacity: 0.5 }] }
const savedMap = { id: 'map1', mapViews: [{ id: 'layer1', opacity: 1 }] }

describe('useLoadMap - unsaved changes guard', () => {
beforeEach(() => {
mockDispatch.mockClear()
mockMapState = savedMap
mockSavedMapState = savedMap
})

const mountOnMap = async (mapId) => {
history.replace(`/${mapId}`)
const hook = renderHook(() => useLoadMap())
// Let the initial mount-time loadMap() resolve so previousParamsRef
// picks up this map id before we simulate further navigation.
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
return hook
}

it('shows a confirm dialog when dirty and switching to a different map', async () => {
mockMapState = dirtyMap
const { result } = await mountOnMap('map1')

act(() => {
history.push('/map2')
})

expect(result.current.locationToConfirm).not.toBeNull()
})

it('does not gate navigation when the map is not dirty', async () => {
mockMapState = savedMap
const { result } = await mountOnMap('map1')
mockDispatch.mockClear()

act(() => {
history.push('/map2')
})

expect(result.current.locationToConfirm).toBeNull()
await waitFor(() => expect(mockDispatch).toHaveBeenCalled())
})

it('confirmLeave navigates to the pending location and clears it', async () => {
mockMapState = dirtyMap
const { result } = await mountOnMap('map1')

act(() => {
history.push('/map2')
})
expect(result.current.locationToConfirm).not.toBeNull()

mockDispatch.mockClear()
await act(async () => {
result.current.confirmLeave()
await Promise.resolve()
await Promise.resolve()
})

expect(result.current.locationToConfirm).toBeNull()
expect(mockDispatch).toHaveBeenCalled()
})

it('cancelLeave clears the pending location without loading it', async () => {
mockMapState = dirtyMap
const { result } = await mountOnMap('map1')

act(() => {
history.push('/map2')
})
expect(result.current.locationToConfirm).not.toBeNull()

mockDispatch.mockClear()
act(() => {
result.current.cancelLeave()
})

expect(result.current.locationToConfirm).toBeNull()
expect(mockDispatch).not.toHaveBeenCalled()
})

it('does not gate a Save As redirect even when dirty', async () => {
mockMapState = dirtyMap
const { result } = await mountOnMap('map1')
mockDispatch.mockClear()

act(() => {
history.push('/map2', { isSaving: true })
})

expect(result.current.locationToConfirm).toBeNull()
await waitFor(() => expect(mockDispatch).toHaveBeenCalled())
})

it('does not gate re-opening the same map while dirty', async () => {
mockMapState = dirtyMap
const { result } = await mountOnMap('map1')
mockDispatch.mockClear()

act(() => {
history.replace('/map1')
})

expect(result.current.locationToConfirm).toBeNull()
await waitFor(() => expect(mockDispatch).toHaveBeenCalled())
})

it('clears a pending confirm when the browser Back button reverts the navigation itself', async () => {
mockMapState = dirtyMap
const { result } = await mountOnMap('map1')

act(() => {
history.push('/map2')
})
expect(result.current.locationToConfirm).not.toBeNull()

act(() => {
history.back()
})
await waitFor(() => expect(history.location.pathname).toBe('/map1'))

expect(result.current.locationToConfirm).toBeNull()
})
})
27 changes: 22 additions & 5 deletions src/components/app/useLoadMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ALERT_CRITICAL,
ALERT_MESSAGE_DYNAMIC,
} from '../../constants/alerts.js'
import { useUnsavedChangesGuard } from '../../hooks/useUnsavedChangesGuard.js'
import { CURRENT_AO_KEY } from '../../util/analyticalObject.js'
import { dataStatisticsMutation } from '../../util/apiDataStatistics.js'
import { getBasemapOrFallback } from '../../util/basemaps.js'
Expand Down Expand Up @@ -89,6 +90,14 @@ export const useLoadMap = () => {
[basemaps, defaultBasemap, dispatch, engine]
)

const {
locationToConfirm,
setLocationToConfirm,
isDirtyNow,
confirmLeave,
cancelLeave,
} = useUnsavedChangesGuard(loadMap)

useEffect(() => {
loadMap(history.location)
}, [loadMap])
Expand Down Expand Up @@ -122,11 +131,17 @@ export const useLoadMap = () => {
)

const params = getHashUrlParams(location)

if (
action === 'REPLACE' ||
const isSwitchingMap =
previousParamsRef.current.mapId !== params.mapId
) {

if (isSwitchingMap && !location.state?.isSaving && isDirtyNow()) {
setLocationToConfirm(location)
return
}

setLocationToConfirm(null)

if (action === 'REPLACE' || isSwitchingMap) {
loadMap(location)
return
}
Expand All @@ -144,5 +159,7 @@ export const useLoadMap = () => {
})

return () => unlisten?.()
}, [loadMap, dispatch])
}, [loadMap, dispatch, isDirtyNow, setLocationToConfirm])

return { locationToConfirm, confirmLeave, cancelLeave }
}
12 changes: 11 additions & 1 deletion src/components/map/MapName.jsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import i18n from '@dhis2/d2-i18n'
import React from 'react'
import { useSelector } from 'react-redux'
import { useMapDirty } from '../../hooks/useMapDirty.js'
import styles from './styles/MapName.module.css'

const MapName = () => {
const name = useSelector((state) => state.map.displayName)
const downloadMode = useSelector((state) => state.ui.downloadMode)
const dirty = useMapDirty()

return !downloadMode && name ? (
<div className={styles.mapName} data-test="map-name">
<div className={`${styles.name} dhis2-maps-title`}>{name}</div>
<div className={`${styles.name} dhis2-maps-title`}>
{name}
{dirty && (
<span className={styles.edited} data-test="map-name-edited">
{` - ${i18n.t('Edited')}`}
</span>
)}
</div>
</div>
) : null
}
Expand Down
Loading
Loading