diff --git a/frontend/src/composables/useTaskList.test.ts b/frontend/src/composables/useTaskList.test.ts new file mode 100644 index 000000000..9703ab29a --- /dev/null +++ b/frontend/src/composables/useTaskList.test.ts @@ -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({}) + }) +}) diff --git a/frontend/src/composables/useTaskList.ts b/frontend/src/composables/useTaskList.ts index b595e5e09..0f4ac40c1 100644 --- a/frontend/src/composables/useTaskList.ts +++ b/frontend/src/composables/useTaskList.ts @@ -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({...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} diff --git a/frontend/tests/e2e/project/sort-persistence.spec.ts b/frontend/tests/e2e/project/sort-persistence.spec.ts new file mode 100644 index 000000000..4aaeed3dc --- /dev/null +++ b/frontend/tests/e2e/project/sort-persistence.spec.ts @@ -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/) + }) +})