This commit is contained in:
surfingbytes 2026-06-30 07:00:07 -05:00 committed by GitHub
commit 5b4eb91acc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 302 additions and 20 deletions

View File

@ -4,16 +4,17 @@
aria-label="main navigation"
class="navbar d-print-none"
>
<RouterLink
:to="{ name: 'home' }"
<a
href="/"
class="logo-link"
:aria-label="$t('navigation.home')"
@click.prevent="openLandingPage"
>
<Logo
width="164"
height="48"
/>
</RouterLink>
</a>
<MenuButton class="menu-button" />
@ -129,7 +130,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { PERMISSIONS as Permissions } from '@/constants/permissions'
@ -147,6 +148,7 @@ import OpenQuickActions from '@/components/misc/OpenQuickActions.vue'
import { getProjectTitle } from '@/helpers/getProjectTitle'
import { isEditorContentEmpty } from '@/helpers/editorContentEmpty'
import { getDefaultPageRoute } from '@/helpers/getDefaultPageRoute'
import { useBaseStore } from '@/stores/base'
import { useConfigStore } from '@/stores/config'
@ -165,6 +167,7 @@ const menuActive = computed(() => baseStore.menuActive)
// Standalone pages (no project) surface their route's title in the header.
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const pageTitle = computed(() => {
const title = route.meta.title as string | undefined
@ -177,6 +180,10 @@ const configStore = useConfigStore()
const imprintUrl = computed(() => configStore.legal.imprintUrl)
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
const adminPanelEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.ADMIN_PANEL))
async function openLandingPage() {
await router.push(await getDefaultPageRoute() ?? {name: 'home'})
}
</script>
<style lang="scss" scoped>

View File

@ -5,16 +5,17 @@
:style="{'--sidebar-width': sidebarWidth}"
>
<nav class="menu top-menu">
<RouterLink
:to="{name: 'home'}"
<a
href="/"
class="logo"
:aria-label="$t('navigation.home')"
@click.prevent="openLandingPage"
>
<Logo
width="164"
height="48"
/>
</RouterLink>
</a>
<menu class="menu-list other-menu-items">
<li>
<RouterLink
@ -134,6 +135,7 @@
<script setup lang="ts">
import {computed} from 'vue'
import {useRouter} from 'vue-router'
import PoweredByLink from '@/components/home/PoweredByLink.vue'
import Logo from '@/components/home/Logo.vue'
@ -146,10 +148,12 @@ import {PRO_FEATURE} from '@/constants/proFeatures'
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
import type {IProject} from '@/modelTypes/IProject'
import {useSidebarResize} from '@/composables/useSidebarResize'
import {getDefaultPageRoute} from '@/helpers/getDefaultPageRoute'
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const configStore = useConfigStore()
const router = useRouter()
const timeTrackingEnabled = computed(() => configStore.isProFeatureEnabled(PRO_FEATURE.TIME_TRACKING))
@ -159,6 +163,10 @@ const {sidebarWidth, isResizing, startResize, isMobile} = useSidebarResize()
const projects = computed(() => projectStore.notArchivedRootProjects as IProject[])
const favoriteProjects = computed(() => projectStore.favoriteProjects as IProject[])
const savedFilterProjects = computed(() => projectStore.savedFilterProjects as IProject[])
async function openLandingPage() {
await router.push(await getDefaultPageRoute() ?? {name: 'home'})
}
</script>
<style lang="scss" scoped>

View File

@ -1,4 +1,6 @@
import {useRouter} from 'vue-router'
import {getDefaultPageRoute} from '@/helpers/getDefaultPageRoute'
import {getLastVisited, clearLastVisited} from '@/helpers/saveLastVisited'
export function useRedirectToLastVisited() {
@ -19,10 +21,10 @@ export function useRedirectToLastVisited() {
}
}
function redirectIfSaved() {
async function redirectIfSaved() {
const lastRoute = getLastVisitedRoute()
if (!lastRoute) {
return router.push({name: 'home'})
return router.push(await getDefaultPageRoute() ?? {name: 'home'})
}
return router.push(lastRoute)

View File

@ -0,0 +1,8 @@
export const DEFAULT_PAGE = {
OVERVIEW: 'overview',
UPCOMING: 'upcoming',
DEFAULT_PROJECT: 'defaultProject',
LAST_VISITED: 'lastVisited',
} as const
export type DefaultPage = typeof DEFAULT_PAGE[keyof typeof DEFAULT_PAGE]

View File

@ -0,0 +1,38 @@
import type {RouteLocationRaw} from 'vue-router'
import {DEFAULT_PAGE} from '@/constants/defaultPage'
import {getLastVisitedPage} from '@/helpers/saveLastVisited'
import {useAuthStore} from '@/stores/auth'
import {useProjectStore} from '@/stores/projects'
export async function getDefaultPageRoute(): Promise<RouteLocationRaw | undefined> {
const authStore = useAuthStore()
const projectStore = useProjectStore()
const defaultPage = authStore.settings?.frontendSettings?.defaultPage
switch (defaultPage) {
case DEFAULT_PAGE.UPCOMING:
return {name: 'tasks.range'}
case DEFAULT_PAGE.DEFAULT_PROJECT: {
const projectId = authStore.settings?.defaultProjectId
if (projectId) {
try {
await projectStore.loadProject(projectId)
return {name: 'project.index', params: {projectId}}
} catch {
return undefined
}
}
return undefined
}
case DEFAULT_PAGE.LAST_VISITED: {
const last = getLastVisitedPage()
if (last) {
return {name: last.name, params: last.params, query: last.query}
}
return undefined
}
default:
return undefined
}
}

View File

@ -1,4 +1,5 @@
const LAST_VISITED_KEY = 'lastVisited'
const LAST_VISITED_PAGE_KEY = 'lastVisitedPage'
export const saveLastVisited = (name: string | undefined, params: object, query: object) => {
if (typeof name === 'undefined') {
@ -20,3 +21,20 @@ export const getLastVisited = () => {
export const clearLastVisited = () => {
return localStorage.removeItem(LAST_VISITED_KEY)
}
export const saveLastVisitedPage = (name: string | undefined, params: object, query: object) => {
if (typeof name === 'undefined') {
return
}
localStorage.setItem(LAST_VISITED_PAGE_KEY, JSON.stringify({name, params, query}))
}
export const getLastVisitedPage = () => {
const lastVisited = localStorage.getItem(LAST_VISITED_PAGE_KEY)
if (lastVisited === null) {
return null
}
return JSON.parse(lastVisited)
}

View File

@ -177,6 +177,13 @@
"12h": "12-hour (AM/PM)",
"24h": "24-hour (HH:mm)"
},
"defaultPage": "Default page",
"defaultPageOptions": {
"overview": "Overview",
"upcoming": "Upcoming",
"defaultProject": "Default project",
"lastVisited": "Last visited"
},
"externalUserNameChange": "Your name is managed by your login provider ({provider}). To change it, please update it there instead."
},
"sections": {

View File

@ -9,6 +9,7 @@ import type {Priority} from '@/constants/priorities'
import type {DateDisplay} from '@/constants/dateDisplay'
import type {TimeFormat} from '@/constants/timeFormat'
import type {IRelationKind} from '@/types/IRelationKind'
import type {DefaultPage} from '@/constants/defaultPage'
export interface IFrontendSettings {
playSoundWhenDone: boolean
@ -26,6 +27,7 @@ export interface IFrontendSettings {
showLastViewed: boolean
sidebarWidth: number | null
commentSortOrder: 'asc' | 'desc'
defaultPage: DefaultPage
desktopQuickEntryShortcut: string
quickAddDefaultReminders: ITaskReminder[]
timeTrackingDefaultStart?: string

View File

@ -8,6 +8,7 @@ import {PRIORITIES} from '@/constants/priorities'
import {DATE_DISPLAY} from '@/constants/dateDisplay'
import {TIME_FORMAT} from '@/constants/timeFormat'
import {RELATION_KIND} from '@/types/IRelationKind'
import {DEFAULT_PAGE} from '@/constants/defaultPage'
export default class UserSettingsModel extends AbstractModel<IUserSettings> implements IUserSettings {
name = ''
@ -36,6 +37,7 @@ export default class UserSettingsModel extends AbstractModel<IUserSettings> impl
showLastViewed: true,
sidebarWidth: null,
commentSortOrder: 'asc',
defaultPage: DEFAULT_PAGE.LAST_VISITED,
desktopQuickEntryShortcut: 'CmdOrCtrl+Shift+A',
quickAddDefaultReminders: [],
}

View File

@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteLocation } from 'vue-router'
import {saveLastVisited} from '@/helpers/saveLastVisited'
import {saveLastVisited, saveLastVisitedPage} from '@/helpers/saveLastVisited'
import {getDefaultPageRoute} from '@/helpers/getDefaultPageRoute'
import {getProjectViewId} from '@/helpers/projectView'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
@ -46,6 +47,13 @@ const router = createRouter({
path: '/',
name: 'home',
component: () => import('@/views/Home.vue'),
async beforeEnter(_to, from) {
if (from.name !== undefined) {
return
}
return getDefaultPageRoute()
},
},
{
path: '/:pathMatch(.*)*',
@ -537,7 +545,7 @@ export async function getAuthForRoute(to: RouteLocation, authStore) {
const isValidUserAppRoute = !AUTH_ROUTE_NAMES.has(to.name as string) &&
localStorage.getItem('emailConfirmToken') === null
if (isValidUserAppRoute) {
if (isValidUserAppRoute && to.name !== 'home') {
saveLastVisited(to.name as string, to.params, to.query)
}
@ -622,4 +630,10 @@ router.beforeEach(async (to, from) => {
}
})
router.afterEach((to) => {
if (!AUTH_ROUTE_NAMES.has(to.name as string) && to.name !== 'home') {
saveLastVisitedPage(to.name as string, to.params, to.query)
}
})
export default router

View File

@ -26,6 +26,7 @@ import {PrefixMode} from '@/modules/quickAddMagic'
import {DATE_DISPLAY} from '@/constants/dateDisplay'
import {TIME_FORMAT} from '@/constants/timeFormat'
import {RELATION_KIND} from '@/types/IRelationKind'
import {DEFAULT_PAGE} from '@/constants/defaultPage'
import type {IProvider} from '@/types/IProvider'
// Set on explicit logout so the login page won't immediately bounce the user
@ -159,6 +160,7 @@ export const useAuthStore = defineStore('auth', () => {
showLastViewed: true,
sidebarWidth: null,
commentSortOrder: 'asc',
defaultPage: DEFAULT_PAGE.LAST_VISITED,
desktopQuickEntryShortcut: 'CmdOrCtrl+Shift+A',
...newSettings.frontendSettings,
},

View File

@ -38,6 +38,15 @@
:loading="loading"
>
<div class="field-group">
<FormField
:label="$t('user.settings.general.defaultPage')"
layout="two-col"
>
<FormSelect
v-model="settings.frontendSettings.defaultPage"
:options="defaultPageOptions"
/>
</FormField>
<FormField
:label="$t('user.settings.general.defaultView')"
layout="two-col"
@ -325,6 +334,7 @@ import {DATE_DISPLAY} from '@/constants/dateDisplay'
import {TIME_FORMAT} from '@/constants/timeFormat'
import {PRO_FEATURE} from '@/constants/proFeatures'
import {RELATION_KINDS} from '@/types/IRelationKind'
import {DEFAULT_PAGE} from '@/constants/defaultPage'
import {isDesktopApp} from '@/helpers/desktopAuth'
import ShortcutRecorder from '@/components/misc/ShortcutRecorder.vue'
import Reminders from '@/components/tasks/partials/Reminders.vue'
@ -339,6 +349,13 @@ useTitle(() => `${t('user.settings.general.title')} - ${t('user.settings.title')
const DEFAULT_PROJECT_ID = 0
const defaultPageOptions = computed(() => [
{value: DEFAULT_PAGE.OVERVIEW, label: t('user.settings.general.defaultPageOptions.overview')},
{value: DEFAULT_PAGE.UPCOMING, label: t('user.settings.general.defaultPageOptions.upcoming')},
{value: DEFAULT_PAGE.DEFAULT_PROJECT, label: t('user.settings.general.defaultPageOptions.defaultProject')},
{value: DEFAULT_PAGE.LAST_VISITED, label: t('user.settings.general.defaultPageOptions.lastVisited')},
])
const defaultViewOptions = computed(() =>
Object.values(DEFAULT_PROJECT_VIEW_SETTINGS).map(view => ({
value: view,
@ -428,7 +445,7 @@ const settings = ref<IUserSettings>({
timeFormat: authStore.settings.frontendSettings.timeFormat ?? TIME_FORMAT.HOURS_12,
// Add fallback for old settings that don't have the default task relation type set
defaultTaskRelationType: authStore.settings.frontendSettings.defaultTaskRelationType ?? 'related',
// Clone to escape the store's readonly array type.
defaultPage: authStore.settings.frontendSettings.defaultPage ?? DEFAULT_PAGE.LAST_VISITED,
quickAddDefaultReminders: [...(authStore.settings.frontendSettings.quickAddDefaultReminders ?? [])],
timeTrackingDefaultStart: authStore.settings.frontendSettings.timeTrackingDefaultStart ?? '09:00',
},

View File

@ -0,0 +1,129 @@
import {test, expect} from '../../support/fixtures'
import {ProjectFactory} from '../../factories/project'
import {UserFactory} from '../../factories/user'
import {TaskFactory} from '../../factories/task'
import {login as apiLogin, setupApiUrl} from '../../support/authenticateUser'
import {updateUserSettings} from '../../support/updateUserSettings'
import {createDefaultViews} from '../project/prepareProjects'
import {TEST_PASSWORD} from '../../support/constants'
test.describe('Default Landing Page', () => {
test('shows overview page with default settings when no last visited page exists', async ({authenticatedPage: page}) => {
await page.goto('/')
await page.waitForLoadState('networkidle')
await expect(page).toHaveURL('/')
await expect(page.locator('.home.app-content')).toBeVisible()
})
test('redirects to upcoming when set as default page', async ({page, apiContext}) => {
const user = (await UserFactory.create(1, {
frontend_settings: JSON.stringify({defaultPage: 'upcoming'}),
}))[0]
await ProjectFactory.create(1, {owner_id: user.id})
await apiLogin(page, apiContext, user)
await page.goto('/')
await page.waitForURL('**/tasks/by/upcoming**')
})
test('redirects to default project when set as default page', async ({page, apiContext}) => {
const user = (await UserFactory.create(1, {
frontend_settings: JSON.stringify({defaultPage: 'defaultProject'}),
}))[0]
const project = (await ProjectFactory.create(1, {owner_id: user.id}))[0]
await createDefaultViews(project.id)
const {token} = await apiLogin(page, apiContext, user)
await updateUserSettings(apiContext, token, {
default_project_id: project.id,
overdue_tasks_reminders_time: '9:00',
})
await page.goto('/')
await page.waitForURL(`**/projects/${project.id}/**`)
})
test('redirects to default project after logging in from root', async ({page, apiContext}) => {
const user = (await UserFactory.create(1, {
frontend_settings: JSON.stringify({defaultPage: 'defaultProject'}),
}))[0]
const project = (await ProjectFactory.create(1, {owner_id: user.id}))[0]
await createDefaultViews(project.id)
const {token} = await apiLogin(null, apiContext, user)
await updateUserSettings(apiContext, token, {
default_project_id: project.id,
overdue_tasks_reminders_time: '9:00',
})
await setupApiUrl(page)
await page.goto('/')
await expect(page).toHaveURL(/\/login/)
await page.locator('input[id=username]').fill(user.username)
await page.locator('input[id=password]').fill(TEST_PASSWORD)
await page.locator('.button').filter({hasText: 'Login'}).click()
await page.waitForURL(`**/projects/${project.id}/**`)
await page.locator('nav.menu.top-menu a').filter({hasText: 'Overview'}).click()
await page.waitForLoadState('networkidle')
await expect(page).toHaveURL('/')
await page.locator('.logo-link').click()
await page.waitForURL(`**/projects/${project.id}/**`)
})
test('falls back to overview when default project does not exist', async ({page, apiContext}) => {
const user = (await UserFactory.create(1, {
frontend_settings: JSON.stringify({defaultPage: 'defaultProject'}),
}))[0]
await ProjectFactory.create(1, {owner_id: user.id})
const {token} = await apiLogin(page, apiContext, user)
await updateUserSettings(apiContext, token, {
default_project_id: 999999,
overdue_tasks_reminders_time: '9:00',
})
await page.goto('/')
await page.waitForLoadState('networkidle')
await expect(page).toHaveURL('/')
})
test('redirects to last visited page when set as default page', async ({page, apiContext}) => {
const user = (await UserFactory.create(1, {
frontend_settings: JSON.stringify({defaultPage: 'lastVisited'}),
}))[0]
const project = (await ProjectFactory.create(1, {owner_id: user.id}))[0]
const views = await createDefaultViews(project.id)
await TaskFactory.create(1, {project_id: project.id})
await apiLogin(page, apiContext, user)
await page.goto(`/projects/${project.id}/${views[0].id}`)
await page.waitForLoadState('networkidle')
await page.goto('/')
await page.waitForURL(`**/projects/${project.id}/${views[0].id}`)
})
test('redirects to default page when clicking the logo', async ({page, apiContext}) => {
const user = (await UserFactory.create(1, {
frontend_settings: JSON.stringify({defaultPage: 'upcoming'}),
}))[0]
const project = (await ProjectFactory.create(1, {owner_id: user.id}))[0]
await createDefaultViews(project.id)
await TaskFactory.create(1, {project_id: project.id})
await apiLogin(page, apiContext, user)
await page.goto(`/projects/${project.id}/1`)
await page.waitForLoadState('networkidle')
await page.locator('.logo-link').click()
await page.waitForURL('**/tasks/by/upcoming**')
})
})

View File

@ -4,7 +4,16 @@ import {ProjectViewFactory} from '../../factories/project_view'
import {updateUserSettings} from '../../support/updateUserSettings'
import type {Page} from '@playwright/test'
async function visitProjectsToBuildHistory(page: Page, projects: any[]) {
interface ProjectHistoryEntry {
id: number
}
interface ProjectSeed {
id: number
title: string
}
async function visitProjectsToBuildHistory(page: Page, projects: ProjectSeed[]) {
for (const project of projects) {
const loadProjectPromise = page.waitForResponse(response =>
response.url().includes(`/projects/${project.id}`) && response.request().method() === 'GET',
@ -14,7 +23,7 @@ async function visitProjectsToBuildHistory(page: Page, projects: any[]) {
await page.waitForFunction(
(projectId) => {
const history = JSON.parse(localStorage.getItem('projectHistory') || '[]')
return history.some((h: any) => h.id === projectId)
return history.some((h: ProjectHistoryEntry) => h.id === projectId)
},
project.id,
)
@ -47,7 +56,7 @@ test.describe('Project History', () => {
await page.waitForFunction(
(projectId) => {
const history = JSON.parse(localStorage.getItem('projectHistory') || '[]')
return history.some((h: any) => h.id === projectId)
return history.some((h: ProjectHistoryEntry) => h.id === projectId)
},
projects[i].id,
)
@ -76,15 +85,25 @@ test.describe('Project History', () => {
}, false)
}
// Keep reloads on the overview page while this test focuses on the
// last-viewed section visibility.
await page.goto('/')
await page.waitForLoadState('networkidle')
const token = await page.evaluate(() => localStorage.getItem('token'))
await updateUserSettings(apiContext, token!, {
frontendSettings: {
defaultPage: 'overview',
},
})
// Visit projects to build up history
await visitProjectsToBuildHistory(page, projects)
// Go to overview and verify section is visible
await page.goto('/')
await page.locator('nav.menu.top-menu a').filter({hasText: 'Overview'}).click()
await expect(page.locator('body')).toContainText('Last viewed')
// Disable the setting via API
const token = await page.evaluate(() => localStorage.getItem('token'))
await updateUserSettings(apiContext, token!, {
frontendSettings: {
showLastViewed: false,
@ -116,6 +135,7 @@ test.describe('Project History', () => {
const token = await page.evaluate(() => localStorage.getItem('token'))
await updateUserSettings(apiContext, token!, {
frontendSettings: {
defaultPage: 'overview',
showLastViewed: false,
},
})

View File

@ -3,14 +3,21 @@ import {TaskFactory} from '../../factories/task'
import {ProjectFactory} from '../../factories/project'
import {createProjects} from './prepareProjects'
interface ProjectWithViews {
id: number
title: string
views: Array<{id: number}>
}
test.describe('Projects', () => {
test.use({
// Use authenticated page for all tests
})
let projects: any[]
let projects: ProjectWithViews[]
test.beforeEach(async ({authenticatedPage}) => {
test.beforeEach(async ({authenticatedPage: page}) => {
void page
projects = await createProjects()
})
@ -72,7 +79,8 @@ test.describe('Projects', () => {
await expect(page.locator('.project-title')).toContainText(newProjectName)
await expect(page.locator('.project-title')).not.toContainText(projects[0].title)
await expect(page.locator('.menu-container .menu-list').getByRole('listitem').filter({hasText: newProjectName})).toBeVisible()
await page.goto('/')
await page.locator('nav.menu.top-menu a').filter({hasText: 'Overview'}).click()
await page.waitForLoadState('networkidle')
await expect(page.locator('.project-grid')).toContainText(newProjectName)
await expect(page.locator('.project-grid')).not.toContainText(projects[0].title)
})