mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Block return navigation on dirty detail edits
This commit is contained in:
@@ -26,6 +26,8 @@ import { EditDataType } from '@/shared/types'
|
||||
import { usePageContext } from '../Page'
|
||||
import { ContactForm } from './common/ContactForm'
|
||||
import { useSyncTabSearch } from '@/hooks/useSyncTabSearch'
|
||||
import { useUnsavedChangesPrompt } from '@/hooks/useUnsavedChangesPrompt'
|
||||
import { UnsavedChangesProvider } from '../UnsavedChangesProvider'
|
||||
|
||||
export type TabType = {
|
||||
title: string
|
||||
@@ -239,46 +241,55 @@ export const DetailView = <T extends object>({
|
||||
</>
|
||||
)
|
||||
|
||||
const UnsavedChangesTracker = () => {
|
||||
const { isDirty, mode } = useDetailContext<T>()
|
||||
useUnsavedChangesPrompt(!mode.read && isDirty)
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack rowGap={2}>
|
||||
<DetailContextProvider contextState={initialState}>
|
||||
{!isUserPage && (
|
||||
<Box>
|
||||
<DetailBrowser<T> />
|
||||
<UnsavedChangesProvider>
|
||||
<Stack rowGap={2}>
|
||||
<DetailContextProvider contextState={initialState}>
|
||||
<UnsavedChangesTracker />
|
||||
{!isUserPage && (
|
||||
<Box>
|
||||
<DetailBrowser<T> />
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
sx={{ display: 'flex', justifyContent: 'space-between', alignContent: 'space-between', marginTop: 'auto' }}
|
||||
>
|
||||
<Box sx={{ display: 'flex' }} gap={3}>
|
||||
{!isUserPage && <ReturnButton />}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex' }} gap={1}>
|
||||
{!isPersonPage && !isNew && <ContactForm<T> buttonText="Contact" />}
|
||||
<EditToggleButton<T>
|
||||
canEdit={!!editRights.edit && !mode.staging}
|
||||
isInitiallyNew={initialState.mode.new}
|
||||
setFieldsWithErrors={setFieldsWithErrors}
|
||||
/>
|
||||
{editRights.delete && !isNew && !isUserPage && (
|
||||
<Button
|
||||
id="delete-button"
|
||||
onClick={onDelete}
|
||||
variant="contained"
|
||||
sx={{ width: '8em', color: 'white' }}
|
||||
color="error"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
{!mode.read && Object.keys(fieldsWithErrors).length > 0 && <ErrorBox />}
|
||||
{(!mode.read || initialState.mode.new) && onWrite && (
|
||||
<WriteButton onWrite={onWrite} taxonomy={taxonomy} hasStagingMode={hasStagingMode} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
sx={{ display: 'flex', justifyContent: 'space-between', alignContent: 'space-between', marginTop: 'auto' }}
|
||||
>
|
||||
<Box sx={{ display: 'flex' }} gap={3}>
|
||||
{!isUserPage && <ReturnButton />}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex' }} gap={1}>
|
||||
{!isPersonPage && !isNew && <ContactForm<T> buttonText="Contact" />}
|
||||
<EditToggleButton<T>
|
||||
canEdit={!!editRights.edit && !mode.staging}
|
||||
isInitiallyNew={initialState.mode.new}
|
||||
setFieldsWithErrors={setFieldsWithErrors}
|
||||
/>
|
||||
{editRights.delete && !isNew && !isUserPage && (
|
||||
<Button
|
||||
id="delete-button"
|
||||
onClick={onDelete}
|
||||
variant="contained"
|
||||
sx={{ width: '8em', color: 'white' }}
|
||||
color="error"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
{!mode.read && Object.keys(fieldsWithErrors).length > 0 && <ErrorBox />}
|
||||
{(!mode.read || initialState.mode.new) && onWrite && (
|
||||
<WriteButton onWrite={onWrite} taxonomy={taxonomy} hasStagingMode={hasStagingMode} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{mode.staging ? stagingView : tabView}
|
||||
</DetailContextProvider>
|
||||
</Stack>
|
||||
{mode.staging ? stagingView : tabView}
|
||||
</DetailContextProvider>
|
||||
</Stack>
|
||||
</UnsavedChangesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it, jest } from '@jest/globals'
|
||||
import '@testing-library/jest-dom'
|
||||
import { RouterProvider, createMemoryRouter } from 'react-router-dom'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import { DetailView, type TabType } from '@/components/DetailView/DetailView'
|
||||
import { PageContext, type PageContextType } from '@/components/Page'
|
||||
import { useDetailContext } from '@/components/DetailView/Context/DetailContext'
|
||||
import type { TimeUnitDetailsType } from '@/shared/types'
|
||||
|
||||
const timeUnitData = {
|
||||
tu_name: 'old_tu',
|
||||
tu_display_name: 'Old TU',
|
||||
rank: 'Age',
|
||||
low_bnd: 2,
|
||||
up_bnd: 1,
|
||||
sequence: 'seq-1',
|
||||
tu_comment: '',
|
||||
now_tu_sequence: { sequence: 'seq-1', seq_name: 'Sequence 1' },
|
||||
now_tau: [],
|
||||
up_bound: { bid: 1, b_name: 'Upper', age: 20, b_comment: '' },
|
||||
low_bound: { bid: 2, b_name: 'Lower', age: 10, b_comment: '' },
|
||||
} as unknown as TimeUnitDetailsType
|
||||
|
||||
const pageContextValue: PageContextType<unknown> = {
|
||||
idList: ['old_tu'],
|
||||
setIdList: jest.fn(),
|
||||
idFieldName: 'tu_name',
|
||||
viewName: 'time-unit',
|
||||
previousTableUrls: [],
|
||||
setPreviousTableUrls: jest.fn(),
|
||||
createTitle: () => timeUnitData.tu_display_name,
|
||||
createSubtitle: () => '',
|
||||
editRights: { edit: true },
|
||||
sqlLimit: 20,
|
||||
sqlOffset: 0,
|
||||
sqlColumnFilters: [],
|
||||
sqlOrderBy: [],
|
||||
setSqlLimit: jest.fn(),
|
||||
setSqlOffset: jest.fn(),
|
||||
setSqlColumnFilters: jest.fn(),
|
||||
setSqlOrderBy: jest.fn(),
|
||||
}
|
||||
|
||||
const TimeUnitTabContent = () => {
|
||||
const { editData, setEditData, mode } = useDetailContext<TimeUnitDetailsType>()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="up-bound-age">{editData.up_bound?.age}</div>
|
||||
{!mode.read && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setEditData({
|
||||
...editData,
|
||||
up_bound: { ...(editData.up_bound ?? {}), age: 99 } as TimeUnitDetailsType['up_bound'],
|
||||
})
|
||||
}
|
||||
>
|
||||
Change upper age
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tabs: TabType[] = [
|
||||
{
|
||||
title: 'Time Unit',
|
||||
content: <TimeUnitTabContent />,
|
||||
},
|
||||
]
|
||||
|
||||
const renderWithRouter = () => {
|
||||
const onWrite = jest.fn(async () => {})
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{
|
||||
path: '/time-unit/:id',
|
||||
element: (
|
||||
<PageContext.Provider value={pageContextValue}>
|
||||
<DetailView<TimeUnitDetailsType>
|
||||
tabs={tabs}
|
||||
data={timeUnitData}
|
||||
validator={() => ({ name: 'noop', error: null })}
|
||||
onWrite={onWrite}
|
||||
/>
|
||||
</PageContext.Provider>
|
||||
),
|
||||
},
|
||||
{ path: '/time-unit', element: <div>Table view</div> },
|
||||
],
|
||||
{ initialEntries: ['/time-unit/old_tu'] }
|
||||
)
|
||||
|
||||
render(<RouterProvider router={router} />)
|
||||
return router
|
||||
}
|
||||
|
||||
describe('DetailView unsaved changes prompt', () => {
|
||||
it('shows a prompt when navigating away with dirty edits via Return to table', async () => {
|
||||
const user = userEvent.setup()
|
||||
const router = renderWithRouter()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /edit/i }))
|
||||
await user.click(screen.getByRole('button', { name: /change upper age/i }))
|
||||
await user.click(screen.getByRole('button', { name: /return to table/i }))
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
await user.click(screen.getByRole('button', { name: /stay on page/i }))
|
||||
|
||||
expect(router.state.location.pathname).toBe('/time-unit/old_tu')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /return to table/i }))
|
||||
await user.click(await screen.findByRole('button', { name: /leave page/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe('/time-unit')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user