fix(views): persist list/table sort across sidebar navigation (#2778)

This commit is contained in:
Tink 2026-06-19 22:08:06 +02:00 committed by GitHub
parent 63b7f32379
commit 82dae774f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 160 additions and 0 deletions

View File

@ -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({})
})
})

View File

@ -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}

View File

@ -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/)
})
})