fix(views): persist list/table sort across sidebar navigation (#2778)
This commit is contained in:
parent
63b7f32379
commit
82dae774f1
|
|
@ -0,0 +1,34 @@
|
|||
import {describe, it, expect} from 'vitest'
|
||||
import {buildStoredQuery} from './useTaskList'
|
||||
|
||||
describe('buildStoredQuery', () => {
|
||||
it('includes sort when set', () => {
|
||||
expect(buildStoredQuery({sort: 'due_date:asc', filter: undefined, s: undefined, page: 1}))
|
||||
.toEqual({sort: 'due_date:asc'})
|
||||
})
|
||||
|
||||
it('includes filter and search when set', () => {
|
||||
expect(buildStoredQuery({sort: undefined, filter: 'done = false', s: 'foo', page: 1}))
|
||||
.toEqual({filter: 'done = false', s: 'foo'})
|
||||
})
|
||||
|
||||
it('omits page when it equals the default of 1', () => {
|
||||
expect(buildStoredQuery({sort: 'id:desc', filter: undefined, s: undefined, page: 1}))
|
||||
.toEqual({sort: 'id:desc'})
|
||||
})
|
||||
|
||||
it('includes page when greater than 1', () => {
|
||||
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 3}))
|
||||
.toEqual({page: '3'})
|
||||
})
|
||||
|
||||
it('returns an empty object when nothing is set', () => {
|
||||
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 1}))
|
||||
.toEqual({})
|
||||
})
|
||||
|
||||
it('skips empty strings', () => {
|
||||
expect(buildStoredQuery({sort: '', filter: '', s: '', page: 1}))
|
||||
.toEqual({})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
|
||||
import {useRouter, isNavigationFailure} from 'vue-router'
|
||||
import type {LocationQueryRaw} from 'vue-router'
|
||||
import {useRouteQuery} from '@vueuse/router'
|
||||
|
||||
import TaskCollectionService, {
|
||||
|
|
@ -10,6 +12,7 @@ import type {ITask} from '@/modelTypes/ITask'
|
|||
import {error} from '@/message'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {useViewFiltersStore} from '@/stores/viewFilters'
|
||||
import type {IProjectView} from '@/modelTypes/IProjectView'
|
||||
|
||||
export type Order = 'asc' | 'desc' | 'none'
|
||||
|
|
@ -59,6 +62,22 @@ const SORT_BY_DEFAULT: SortBy = {
|
|||
id: 'desc',
|
||||
}
|
||||
|
||||
interface TaskListQueryState {
|
||||
sort: string | undefined
|
||||
filter: string | undefined
|
||||
s: string | undefined
|
||||
page: number
|
||||
}
|
||||
|
||||
export function buildStoredQuery(state: TaskListQueryState): LocationQueryRaw {
|
||||
const query: LocationQueryRaw = {}
|
||||
if (state.sort) query.sort = state.sort
|
||||
if (state.filter) query.filter = state.filter
|
||||
if (state.s) query.s = state.s
|
||||
if (state.page > 1) query.page = String(state.page)
|
||||
return query
|
||||
}
|
||||
|
||||
// This makes sure an id sort order is always sorted last.
|
||||
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
|
||||
// precedence over everything else, making any other sort columns pretty useless.
|
||||
|
|
@ -94,6 +113,9 @@ export function useTaskList(
|
|||
const projectId = computed(() => projectIdGetter())
|
||||
const projectViewId = computed(() => projectViewIdGetter())
|
||||
|
||||
const router = useRouter()
|
||||
const viewFiltersStore = useViewFiltersStore()
|
||||
|
||||
const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()})
|
||||
|
||||
const page = useRouteQuery('page', '1', { transform: Number })
|
||||
|
|
@ -119,6 +141,55 @@ export function useTaskList(
|
|||
},
|
||||
})
|
||||
|
||||
// Mirror the URL query bits this composable owns into the store so
|
||||
// in-project tab switches and sidebar re-visits can restore them.
|
||||
//
|
||||
// `ProjectList`/`ProjectTable` are reused across project switches (no
|
||||
// `:key` on them in ProjectView.vue), so setup runs only once. We track
|
||||
// the last viewId we synced — on every viewId transition, if the URL has
|
||||
// none of our params and the store has an entry, restore it via
|
||||
// `router.replace` and skip writing back the empty state we'd otherwise
|
||||
// clobber the saved entry with.
|
||||
let lastSyncedViewId: number | undefined
|
||||
watch(
|
||||
[projectViewId, sortQuery, filter, s, page],
|
||||
([viewId, sortValue, filterValue, sValue, pageValue]) => {
|
||||
const viewIdChanged = viewId !== lastSyncedViewId
|
||||
lastSyncedViewId = viewId
|
||||
|
||||
// An invalid `?page=` becomes NaN via `transform: Number`; treat it as
|
||||
// the default so it neither blocks restoration nor wipes stored state.
|
||||
const currentPage = Number.isInteger(pageValue) ? pageValue : 1
|
||||
const urlIsEmpty = !sortValue && !filterValue && !sValue && currentPage === 1
|
||||
if (viewIdChanged && urlIsEmpty) {
|
||||
const storedQuery = viewFiltersStore.getViewQuery(viewId)
|
||||
if (Object.keys(storedQuery).length > 0) {
|
||||
// Merge so unrelated query params on the route survive the restore.
|
||||
// Swallow navigation failures (e.g. aborted/duplicated) so the
|
||||
// ignored promise can't surface as an unhandled rejection.
|
||||
router.replace({query: {...router.currentRoute.value.query, ...storedQuery}})
|
||||
.catch(failure => {
|
||||
if (!isNavigationFailure(failure)) throw failure
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const query = buildStoredQuery({
|
||||
sort: sortValue as string | undefined,
|
||||
filter: filterValue as string | undefined,
|
||||
s: sValue as string | undefined,
|
||||
page: currentPage,
|
||||
})
|
||||
if (Object.keys(query).length > 0) {
|
||||
viewFiltersStore.setViewQuery(viewId, query)
|
||||
} else {
|
||||
viewFiltersStore.clearViewQuery(viewId)
|
||||
}
|
||||
},
|
||||
{immediate: true},
|
||||
)
|
||||
|
||||
const allParams = computed(() => {
|
||||
const loadParams = {...params.value}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import {type Page} from '@playwright/test'
|
||||
import {test, expect} from '../../support/fixtures'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {createProjects} from './prepareProjects'
|
||||
|
||||
async function selectSortInList(page: Page, optionLabel: string) {
|
||||
await page.locator('.filter-container').getByRole('button', {name: 'Sort', exact: true}).click()
|
||||
await page.getByLabel('Sort by').selectOption({label: optionLabel})
|
||||
await page.getByRole('button', {name: 'Apply sort'}).click()
|
||||
}
|
||||
|
||||
async function navigateViaSidebar(page: Page, projectTitle: string) {
|
||||
await page.locator('.menu-list .list-menu-link', {
|
||||
has: page.locator('.project-menu-title', {hasText: new RegExp(`^${projectTitle}$`)}),
|
||||
}).first().click()
|
||||
}
|
||||
|
||||
test.describe('Sort persistence across sidebar navigation (#2753)', () => {
|
||||
test('List view: sort persists after navigating to another project and back', async ({authenticatedPage: page}) => {
|
||||
const projects = await createProjects(2)
|
||||
const [projectA, projectB] = projects
|
||||
await TaskFactory.create(3, {
|
||||
id: '{increment}',
|
||||
project_id: projectA.id,
|
||||
title: 'Task {increment}',
|
||||
})
|
||||
|
||||
const listViewA = projectA.views[0].id
|
||||
await page.goto(`/projects/${projectA.id}/${listViewA}`)
|
||||
await expect(page).not.toHaveURL(/sort=/)
|
||||
|
||||
await selectSortInList(page, 'Due date (Earliest first)')
|
||||
await expect(page).toHaveURL(/sort=due_date:asc/)
|
||||
|
||||
await navigateViaSidebar(page, projectB.title)
|
||||
await expect(page).toHaveURL(new RegExp(`/projects/${projectB.id}/`))
|
||||
|
||||
await navigateViaSidebar(page, projectA.title)
|
||||
await expect(page).toHaveURL(new RegExp(`/projects/${projectA.id}/`))
|
||||
await expect(page).toHaveURL(/sort=due_date:asc/)
|
||||
})
|
||||
|
||||
test('List view: explicit URL sort wins over stored sort', async ({authenticatedPage: page}) => {
|
||||
const projects = await createProjects(1)
|
||||
const listView = projects[0].views[0].id
|
||||
|
||||
// Seed the store with one sort by visiting with it set.
|
||||
await page.goto(`/projects/${projects[0].id}/${listView}?sort=due_date:asc`)
|
||||
await expect(page).toHaveURL(/sort=due_date:asc/)
|
||||
|
||||
// Visit a URL that explicitly sets a different sort — that should win.
|
||||
await page.goto(`/projects/${projects[0].id}/${listView}?sort=priority:desc`)
|
||||
await expect(page).toHaveURL(/sort=priority:desc/)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue