mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
fix(studio): preserve forward history during unchanged filter sync
This commit is contained in:
1 parent
7c59a5bf72
commit
e11cce3ad2
3 files changed
+211
-2
No files matched your search
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
createBrowserHistory,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
useParams,
|
||||
type AnyRouter,
|
||||
} from '@tanstack/react-router'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useMemo } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Link from '@/compat/next/link'
|
||||
import { useSyncFiltersToUrl } from '@/components/grid/hooks/useFilterLifeCycle'
|
||||
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
|
||||
import { parseSearch, stringifySearch } from '@/lib/router-search-params'
|
||||
import {
|
||||
createTableEditorTableState,
|
||||
TableEditorTableStateContext,
|
||||
} from '@/state/table-editor-table'
|
||||
|
||||
vi.mock('next/router', () => import('@/compat/next/router'))
|
||||
|
||||
function FilterSync() {
|
||||
useSyncFiltersToUrl()
|
||||
return null
|
||||
}
|
||||
|
||||
function TablePage() {
|
||||
const { id } = useParams<AnyRouter, undefined, false>({ strict: false })
|
||||
const state = useMemo(
|
||||
() =>
|
||||
createTableEditorTableState({
|
||||
projectRef: 'default',
|
||||
table: {
|
||||
id: Number(id),
|
||||
schema: 'public',
|
||||
name: `table_${id}`,
|
||||
entity_type: ENTITY_TYPE.FOREIGN_TABLE,
|
||||
comment: null,
|
||||
columns: [],
|
||||
foreign_server_name: 'fixture',
|
||||
foreign_data_wrapper_name: 'fixture',
|
||||
foreign_data_wrapper_handler: 'fixture',
|
||||
},
|
||||
onAddColumn: vi.fn(),
|
||||
onExpandJSONEditor: vi.fn(),
|
||||
onExpandTextEditor: vi.fn(),
|
||||
}),
|
||||
[id]
|
||||
)
|
||||
|
||||
return (
|
||||
<TableEditorTableStateContext.Provider value={state}>
|
||||
<FilterSync key={id} />
|
||||
<output data-testid="table">{id}</output>
|
||||
<Link href="/project/default/editor/17607?schema=public&extra=kept#selection">Table B</Link>
|
||||
<Link href="/project/default/editor/17489?schema=public&extra=kept#selection">Table A</Link>
|
||||
<button
|
||||
tabIndex={0}
|
||||
onClick={() => state.setFilters([{ column: 'id', operator: '=', value: '1' }])}
|
||||
>
|
||||
Filter rows
|
||||
</button>
|
||||
<button
|
||||
tabIndex={0}
|
||||
onClick={() => state.setFilters([{ column: 'id', operator: '=', value: '2' }])}
|
||||
>
|
||||
Change filter
|
||||
</button>
|
||||
</TableEditorTableStateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Allow the production filter synchronizer's 500ms debounce to finish before traversing.
|
||||
const settleFilters = () => act(async () => new Promise((resolve) => setTimeout(resolve, 650)))
|
||||
|
||||
for (const basepath of ['/', '/dashboard']) {
|
||||
describe(`table filter history at ${basepath}`, () => {
|
||||
it.each([false, true])(
|
||||
'preserves repeated Back/Forward with retained application state: %s',
|
||||
async (hasApplicationState) => {
|
||||
const prefix = basepath === '/' ? '' : basepath
|
||||
const tableA = `${prefix}/project/default/editor/17489?schema=public&extra=kept#selection`
|
||||
const tableB = `${prefix}/project/default/editor/17607?schema=public&extra=kept#selection`
|
||||
window.history.replaceState(null, '', tableA)
|
||||
const history = createBrowserHistory()
|
||||
const root = createRootRoute({ component: Outlet })
|
||||
const table = createRoute({
|
||||
getParentRoute: () => root,
|
||||
path: '/project/$ref/editor/$id',
|
||||
component: TablePage,
|
||||
})
|
||||
const router = createRouter({
|
||||
routeTree: root.addChildren([table]),
|
||||
history,
|
||||
basepath,
|
||||
parseSearch,
|
||||
stringifySearch,
|
||||
isServer: false,
|
||||
})
|
||||
await router.load()
|
||||
const view = render(<RouterProvider router={router} />)
|
||||
const push = vi.spyOn(window.history, 'pushState')
|
||||
|
||||
try {
|
||||
await settleFilters()
|
||||
if (hasApplicationState) {
|
||||
const retainedState = { ...history.location.state, legacyState: 'retained' }
|
||||
await act(async () => history.replace(tableA, retainedState))
|
||||
}
|
||||
fireEvent.click(screen.getByText('Table B'))
|
||||
await waitFor(() => expect(screen.getByTestId('table')).toHaveTextContent('17607'))
|
||||
await settleFilters()
|
||||
|
||||
for (let cycle = 0; cycle < 2; cycle++) {
|
||||
push.mockClear()
|
||||
act(() => window.history.back())
|
||||
await waitFor(() => expect(screen.getByTestId('table')).toHaveTextContent('17489'))
|
||||
await settleFilters()
|
||||
expect(window.location.pathname + window.location.search + window.location.hash).toBe(
|
||||
tableA
|
||||
)
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
if (hasApplicationState) expect(window.history.state.legacyState).toBe('retained')
|
||||
|
||||
act(() => window.history.forward())
|
||||
await waitFor(() => expect(screen.getByTestId('table')).toHaveTextContent('17607'))
|
||||
await settleFilters()
|
||||
expect(window.location.pathname + window.location.search + window.location.hash).toBe(
|
||||
tableB
|
||||
)
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByText('Filter rows'))
|
||||
await waitFor(() => expect(window.location.search).toContain('filter=id%3Aeq%3A1'))
|
||||
expect(window.location.search).toContain('schema=public')
|
||||
expect(window.location.search).toContain('extra=kept')
|
||||
expect(window.location.hash).toBe('#selection')
|
||||
|
||||
// Leaving before the next debounce must cancel the old table's pending URL update.
|
||||
fireEvent.click(screen.getByText('Change filter'))
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)))
|
||||
fireEvent.click(screen.getByText('Table A'))
|
||||
await waitFor(() => expect(screen.getByTestId('table')).toHaveTextContent('17489'))
|
||||
await settleFilters()
|
||||
expect(window.location.pathname + window.location.search + window.location.hash).toBe(
|
||||
tableA
|
||||
)
|
||||
} finally {
|
||||
view.unmount()
|
||||
push.mockRestore()
|
||||
history.destroy()
|
||||
}
|
||||
},
|
||||
10000
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import mockRouter from 'next-router-mock'
|
||||
import { useRouter } from 'next/router'
|
||||
import { withNuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -61,4 +62,43 @@ describe('useTableEditorFilters', () => {
|
||||
|
||||
expect(result.current.sorts).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps unchanged filters and sorts out of Next navigation history', async () => {
|
||||
const router = mockRouter
|
||||
await act(async () => {
|
||||
await router.push('/test?schema=public&filter=id:eq:1&sort=id:asc#selection')
|
||||
})
|
||||
const { result } = renderHook(() => useTableEditorFiltersSort())
|
||||
const onNavigation = vi.fn()
|
||||
router.events.on('routeChangeStart', onNavigation)
|
||||
try {
|
||||
act(() => result.current.setParams((prev) => ({ ...prev })))
|
||||
act(() => result.current.setParams(() => ({ filter: ['id:eq:1'] })))
|
||||
act(() => result.current.setParams(() => ({ sort: ['id:asc'] })))
|
||||
expect(onNavigation).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => result.current.setParams(() => ({ filter: ['id:eq:2'] })))
|
||||
expect(onNavigation).toHaveBeenCalledTimes(1)
|
||||
expect(router.asPath).toBe('/test?schema=public&filter=id%3Aeq%3A2&sort=id%3Aasc#selection')
|
||||
|
||||
await act(async () =>
|
||||
result.current.setParams(() => ({ sort: ['id:desc', 'created_at:asc'] }))
|
||||
)
|
||||
expect(onNavigation).toHaveBeenCalledTimes(2)
|
||||
expect(result.current.filters).toEqual(['id:eq:2'])
|
||||
expect(result.current.sorts).toEqual(['id:desc', 'created_at:asc'])
|
||||
|
||||
await act(async () =>
|
||||
result.current.setParams(() => ({ sort: ['created_at:asc', 'id:desc'] }))
|
||||
)
|
||||
expect(onNavigation).toHaveBeenCalledTimes(3)
|
||||
expect(result.current.sorts).toEqual(['created_at:asc', 'id:desc'])
|
||||
|
||||
await act(async () => result.current.setParams(() => ({ filter: [], sort: [] })))
|
||||
expect(onNavigation).toHaveBeenCalledTimes(4)
|
||||
expect(router.asPath).toBe('/test?schema=public#selection')
|
||||
} finally {
|
||||
router.events.off('routeChangeStart', onNavigation)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isEqual } from 'lodash'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
@@ -5,7 +6,7 @@ export const useTableEditorFiltersSort = () => {
|
||||
const router = useRouter()
|
||||
|
||||
const urlParams = useMemo(() => {
|
||||
return new URLSearchParams(router.asPath.split('?')[1])
|
||||
return new URLSearchParams(router.asPath.split('?')[1]?.split('#')[0])
|
||||
}, [router.asPath])
|
||||
|
||||
const filters = useMemo(() => {
|
||||
@@ -28,9 +29,16 @@ export const useTableEditorFiltersSort = () => {
|
||||
|
||||
const hasFilter = newParams.filter !== undefined
|
||||
const hasSort = newParams.sort !== undefined
|
||||
const areFiltersUnchanged = !hasFilter || isEqual(newParams.filter, filters)
|
||||
const areSortsUnchanged = !hasSort || isEqual(newParams.sort, sorts)
|
||||
|
||||
// A no-op URL sync must not create an entry that discards browser Forward history.
|
||||
if (areFiltersUnchanged && areSortsUnchanged) return
|
||||
|
||||
const hashStart = router.asPath.indexOf('#')
|
||||
router.push(
|
||||
{
|
||||
hash: hashStart === -1 ? undefined : router.asPath.slice(hashStart),
|
||||
query: {
|
||||
...router.query,
|
||||
...(hasFilter ? { filter: newParams.filter } : {}),
|
||||
|
||||
Reference in new issue
Block a user