mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-02-01 14:44:05 +00:00
feat: make sidebar resizable (#1965)
Closes: #525 * Sidebar width is now adjustable via dragging * The width is automatically saved to settings and restored across sessions.
This commit is contained in:
@@ -22,7 +22,7 @@ export default defineConfig({
|
||||
workers: 1, // No parallelization initially
|
||||
reporter: process.env.CI ? [['html'], ['list']] : 'html',
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4173',
|
||||
baseURL: process.env.BASE_URL || 'http://127.0.0.1:4173',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
testIdAttribute: 'data-cy', // Preserve existing data-cy selectors
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
{ 'is-menu-enabled': menuActive },
|
||||
$route.name,
|
||||
]"
|
||||
:style="{'--sidebar-width': sidebarWidth}"
|
||||
>
|
||||
<BaseButton
|
||||
v-show="menuActive"
|
||||
@@ -84,13 +85,16 @@ import {useProjectStore} from '@/stores/projects'
|
||||
|
||||
import {useRouteWithModal} from '@/composables/useRouteWithModal'
|
||||
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {useSidebarResize} from '@/composables/useSidebarResize'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const backgroundBrightness = computed(() =>
|
||||
const backgroundBrightness = computed(() =>
|
||||
authStore.settings?.frontendSettings?.backgroundBrightness,
|
||||
)
|
||||
|
||||
const {sidebarWidth} = useSidebarResize()
|
||||
|
||||
const {routeWithModal, currentModal, closeModal} = useRouteWithModal()
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
@@ -173,6 +177,8 @@ projectStore.loadAllProjects()
|
||||
}
|
||||
|
||||
.app-content {
|
||||
--sidebar-width: #{$navbar-width};
|
||||
|
||||
display: flow-root;
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
@@ -192,7 +198,7 @@ projectStore.loadAllProjects()
|
||||
|
||||
&.is-menu-enabled {
|
||||
@media screen and (min-width: $tablet) {
|
||||
margin-inline-start: $navbar-width;
|
||||
margin-inline-start: var(--sidebar-width);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<aside
|
||||
:class="{'is-active': baseStore.menuActive}"
|
||||
:class="{'is-active': baseStore.menuActive, 'is-resizing': isResizing}"
|
||||
class="menu-container"
|
||||
:style="{'--sidebar-width': sidebarWidth}"
|
||||
>
|
||||
<nav class="menu top-menu">
|
||||
<RouterLink
|
||||
@@ -113,6 +114,13 @@
|
||||
class="mbs-auto"
|
||||
utm-medium="navigation"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="!isMobile"
|
||||
class="resize-handle"
|
||||
@mousedown="startResize"
|
||||
@touchstart="startResize"
|
||||
/>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -127,10 +135,13 @@ import {useBaseStore} from '@/stores/base'
|
||||
import {useProjectStore} from '@/stores/projects'
|
||||
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||
import type {IProject} from '@/modelTypes/IProject'
|
||||
import {useSidebarResize} from '@/composables/useSidebarResize'
|
||||
|
||||
const baseStore = useBaseStore()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const {sidebarWidth, isResizing, startResize, isMobile} = useSidebarResize()
|
||||
|
||||
// Cast readonly arrays to mutable type - the arrays are not actually mutated by the component
|
||||
const projects = computed(() => projectStore.notArchivedRootProjects as IProject[])
|
||||
const favoriteProjects = computed(() => projectStore.favoriteProjects as IProject[])
|
||||
@@ -151,6 +162,8 @@ const savedFilterProjects = computed(() => projectStore.savedFilterProjects as I
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
--sidebar-width: #{$navbar-width};
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--site-background);
|
||||
@@ -162,7 +175,7 @@ const savedFilterProjects = computed(() => projectStore.savedFilterProjects as I
|
||||
inset-block-end: 0;
|
||||
inset-inline-start: 0;
|
||||
transform: translateX(-100%);
|
||||
inline-size: $navbar-width;
|
||||
inline-size: var(--sidebar-width);
|
||||
overflow-y: auto;
|
||||
|
||||
[dir="rtl"] & {
|
||||
@@ -179,6 +192,27 @@ const savedFilterProjects = computed(() => projectStore.savedFilterProjects as I
|
||||
transform: translateX(0);
|
||||
transition: transform $transition-duration ease-out;
|
||||
}
|
||||
|
||||
&.is-resizing {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-block-end: 0;
|
||||
inset-inline-end: 0;
|
||||
inline-size: 4px;
|
||||
cursor: ew-resize;
|
||||
background: transparent;
|
||||
transition: background-color $transition-duration ease;
|
||||
touch-action: none;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.top-menu .menu-list {
|
||||
|
||||
156
frontend/src/composables/useSidebarResize.ts
Normal file
156
frontend/src/composables/useSidebarResize.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import {ref, computed, onMounted, onUnmounted, watch} from 'vue'
|
||||
import {useMediaQuery} from '@vueuse/core'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
|
||||
const BULMA_MOBILE_BREAKPOINT = 768
|
||||
const DEFAULT_SIDEBAR_WIDTH = 300
|
||||
const MIN_SIDEBAR_WIDTH = 200
|
||||
const MAX_SIDEBAR_WIDTH = 500
|
||||
|
||||
function clampWidth(width: number): number {
|
||||
return Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, width))
|
||||
}
|
||||
|
||||
// Shared state across all component instances
|
||||
const isResizing = ref(false)
|
||||
const currentWidth = ref(DEFAULT_SIDEBAR_WIDTH)
|
||||
let initialized = false
|
||||
let watcherInitialized = false
|
||||
|
||||
// Captured body styles to restore after resize
|
||||
let previousUserSelect: string | undefined
|
||||
let previousCursor: string | undefined
|
||||
|
||||
function setupWatcher(authStore: ReturnType<typeof useAuthStore>) {
|
||||
if (watcherInitialized) return
|
||||
watcherInitialized = true
|
||||
|
||||
watch(
|
||||
() => authStore.settings?.frontendSettings?.sidebarWidth,
|
||||
(newWidth) => {
|
||||
if (isResizing.value) return
|
||||
|
||||
if (newWidth !== null && newWidth !== undefined) {
|
||||
currentWidth.value = clampWidth(newWidth)
|
||||
} else {
|
||||
currentWidth.value = DEFAULT_SIDEBAR_WIDTH
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function useSidebarResize() {
|
||||
const authStore = useAuthStore()
|
||||
const isMobile = useMediaQuery(`(max-width: ${BULMA_MOBILE_BREAKPOINT}px)`)
|
||||
|
||||
// Initialize width from settings only once
|
||||
onMounted(() => {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
const savedWidth = authStore.settings?.frontendSettings?.sidebarWidth
|
||||
if (savedWidth !== null && savedWidth !== undefined) {
|
||||
currentWidth.value = clampWidth(savedWidth)
|
||||
}
|
||||
})
|
||||
|
||||
// Register settings watcher only once
|
||||
setupWatcher(authStore)
|
||||
|
||||
const sidebarWidth = computed(() => {
|
||||
if (isMobile.value) {
|
||||
return '70vw'
|
||||
}
|
||||
return `${currentWidth.value}px`
|
||||
})
|
||||
|
||||
function startResize(event: MouseEvent | TouchEvent) {
|
||||
if (isMobile.value) return
|
||||
|
||||
event.preventDefault()
|
||||
isResizing.value = true
|
||||
|
||||
document.addEventListener('mousemove', handleResize)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
document.addEventListener('touchmove', handleResize)
|
||||
document.addEventListener('touchend', stopResize)
|
||||
|
||||
// Capture current styles and set new values
|
||||
previousUserSelect = document.body.style.userSelect
|
||||
previousCursor = document.body.style.cursor
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'ew-resize'
|
||||
}
|
||||
|
||||
function handleResize(event: MouseEvent | TouchEvent) {
|
||||
if (!isResizing.value) return
|
||||
|
||||
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
|
||||
|
||||
// Handle RTL direction
|
||||
const isRtl = document.dir === 'rtl'
|
||||
let newWidth: number
|
||||
|
||||
if (isRtl) {
|
||||
newWidth = window.innerWidth - clientX
|
||||
} else {
|
||||
newWidth = clientX
|
||||
}
|
||||
|
||||
currentWidth.value = clampWidth(newWidth)
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
if (!isResizing.value) return
|
||||
|
||||
isResizing.value = false
|
||||
|
||||
document.removeEventListener('mousemove', handleResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
document.removeEventListener('touchmove', handleResize)
|
||||
document.removeEventListener('touchend', stopResize)
|
||||
|
||||
// Restore previous styles
|
||||
if (previousUserSelect !== undefined) {
|
||||
document.body.style.userSelect = previousUserSelect
|
||||
previousUserSelect = undefined
|
||||
}
|
||||
if (previousCursor !== undefined) {
|
||||
document.body.style.cursor = previousCursor
|
||||
previousCursor = undefined
|
||||
}
|
||||
|
||||
// Save width to user settings
|
||||
saveWidth()
|
||||
}
|
||||
|
||||
async function saveWidth() {
|
||||
const savedWidth = authStore.settings?.frontendSettings?.sidebarWidth
|
||||
// Only save if width actually changed
|
||||
if (savedWidth === currentWidth.value) return
|
||||
|
||||
const newSettings = {
|
||||
...authStore.settings,
|
||||
frontendSettings: {
|
||||
...authStore.settings.frontendSettings,
|
||||
sidebarWidth: currentWidth.value,
|
||||
},
|
||||
}
|
||||
await authStore.saveUserSettings({
|
||||
settings: newSettings,
|
||||
showMessage: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(stopResize)
|
||||
|
||||
return {
|
||||
sidebarWidth,
|
||||
currentWidth,
|
||||
isResizing,
|
||||
startResize,
|
||||
isMobile,
|
||||
DEFAULT_SIDEBAR_WIDTH,
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,9 @@ declare global {
|
||||
const cypressDirective = <Directive<HTMLElement,string>>{
|
||||
mounted(el, {arg, value}) {
|
||||
const testingId = arg || value
|
||||
if ((window.Cypress || import.meta.env.DEV) && testingId) {
|
||||
// Always add data-cy attributes - they're harmless metadata and ensure
|
||||
// tests work in both dev mode and production builds (e.g., Playwright in CI)
|
||||
if (testingId) {
|
||||
el.setAttribute('data-cy', testingId)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface IFrontendSettings {
|
||||
defaultTaskRelationType: IRelationKind
|
||||
backgroundBrightness: number | null
|
||||
alwaysShowBucketTaskCount: boolean
|
||||
sidebarWidth: number | null
|
||||
}
|
||||
|
||||
export interface IExtraSettingsLink {
|
||||
|
||||
@@ -31,6 +31,7 @@ export default class UserSettingsModel extends AbstractModel<IUserSettings> impl
|
||||
timeFormat: TIME_FORMAT.HOURS_24,
|
||||
defaultTaskRelationType: RELATION_KIND.RELATED,
|
||||
alwaysShowBucketTaskCount: false,
|
||||
sidebarWidth: null,
|
||||
}
|
||||
extraSettingsLinks = {}
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
timeFormat: TIME_FORMAT.HOURS_24,
|
||||
defaultTaskRelationType: RELATION_KIND.RELATED,
|
||||
backgroundBrightness: 100,
|
||||
sidebarWidth: null,
|
||||
...newSettings.frontendSettings,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watchEffect} from 'vue'
|
||||
import {computed, ref, watch, watchEffect} from 'vue'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
@@ -182,6 +182,7 @@ const pageTitle = computed(() => {
|
||||
const hasTasks = computed(() => tasks.value && tasks.value.length > 0)
|
||||
const userAuthenticated = computed(() => authStore.authenticated)
|
||||
const loading = computed(() => taskStore.isLoading || taskCollectionService.value.loading)
|
||||
const filterIdUsedOnOverview = computed(() => authStore.settings?.frontendSettings?.filterIdUsedOnOverview)
|
||||
|
||||
interface dateStrings {
|
||||
dateFrom: string,
|
||||
@@ -224,7 +225,7 @@ function clearLabelFilter() {
|
||||
emit('clearLabelFilter')
|
||||
}
|
||||
|
||||
async function loadPendingTasks(from: Date|string, to: Date|string) {
|
||||
async function loadPendingTasks(from: Date|string, to: Date|string, filterId: number | null | undefined) {
|
||||
// FIXME: HACK! This should never happen.
|
||||
// Since this route is authentication only, users would get an error message if they access the page unauthenticated.
|
||||
// Since this component is mounted as the home page before unauthenticated users get redirected
|
||||
@@ -243,7 +244,7 @@ async function loadPendingTasks(from: Date|string, to: Date|string) {
|
||||
}
|
||||
|
||||
if (!showAll.value) {
|
||||
|
||||
|
||||
params.filter += ` && due_date < '${to instanceof Date ? to.toISOString() : to}'`
|
||||
|
||||
// NOTE: Ideally we could also show tasks with a start or end date in the specified range, but the api
|
||||
@@ -253,15 +254,14 @@ async function loadPendingTasks(from: Date|string, to: Date|string) {
|
||||
params.filter += ` && due_date > '${from instanceof Date ? from.toISOString() : from}'`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add label filtering
|
||||
if (props.labelIds && props.labelIds.length > 0) {
|
||||
const labelFilter = `labels in ${props.labelIds.join(', ')}`
|
||||
params.filter += params.filter ? ` && ${labelFilter}` : labelFilter
|
||||
}
|
||||
|
||||
|
||||
let projectId = null
|
||||
const filterId = authStore.settings.frontendSettings.filterIdUsedOnOverview
|
||||
if (showAll.value && filterId && typeof projectStore.projects[filterId] !== 'undefined') {
|
||||
projectId = filterId
|
||||
}
|
||||
@@ -285,7 +285,17 @@ function updateTasks(updatedTask: ITask) {
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => loadPendingTasks(props.dateFrom, props.dateTo))
|
||||
// Use watch instead of watchEffect to prevent reloading tasks when unrelated settings change.
|
||||
// watchEffect would track all reactive dependencies accessed inside loadPendingTasks,
|
||||
// which includes the entire settings object. When sidebarWidth changes, the settings
|
||||
// object is replaced, triggering the watchEffect even though filterIdUsedOnOverview
|
||||
// hasn't changed. Using watch with explicit dependencies and immediate:true gives us
|
||||
// the same behavior but only triggers when these specific values actually change.
|
||||
watch(
|
||||
[() => props.dateFrom, () => props.dateTo, filterIdUsedOnOverview],
|
||||
([from, to, filterId]) => loadPendingTasks(from, to, filterId),
|
||||
{immediate: true},
|
||||
)
|
||||
watchEffect(() => setTitle(pageTitle.value))
|
||||
</script>
|
||||
|
||||
|
||||
123
frontend/tests/e2e/misc/sidebar-resize.spec.ts
Normal file
123
frontend/tests/e2e/misc/sidebar-resize.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import {test, expect} from '../../support/fixtures'
|
||||
import {ProjectFactory} from '../../factories/project'
|
||||
import {TaskFactory} from '../../factories/task'
|
||||
import {BucketFactory} from '../../factories/bucket'
|
||||
import {createDefaultViews} from '../project/prepareProjects'
|
||||
|
||||
async function seedTasks(userId: number, numberOfTasks = 5, startDueDate = new Date()) {
|
||||
const project = (await ProjectFactory.create(1, {owner_id: userId}))[0]
|
||||
const views = await createDefaultViews(project.id)
|
||||
await BucketFactory.create(1, {
|
||||
project_view_id: views[3].id,
|
||||
})
|
||||
const tasks = []
|
||||
let dueDate = startDueDate
|
||||
for (let i = 0; i < numberOfTasks; i++) {
|
||||
const now = new Date()
|
||||
dueDate = new Date(new Date(dueDate).setDate(dueDate.getDate() + 2))
|
||||
tasks.push({
|
||||
id: i + 1,
|
||||
project_id: project.id,
|
||||
done: false,
|
||||
created_by_id: userId,
|
||||
title: 'Test Task ' + i,
|
||||
index: i + 1,
|
||||
due_date: dueDate.toISOString(),
|
||||
created: now.toISOString(),
|
||||
updated: now.toISOString(),
|
||||
})
|
||||
}
|
||||
await TaskFactory.seed(TaskFactory.table, tasks)
|
||||
return {tasks, project}
|
||||
}
|
||||
|
||||
test.describe('Sidebar Resize', () => {
|
||||
test('should not reload tasks when resizing the sidebar', async ({authenticatedPage: page, currentUser}) => {
|
||||
await page.setViewportSize({width: 1280, height: 720})
|
||||
await seedTasks(currentUser.id, 5)
|
||||
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Wait for showTasks to appear with longer timeout for CI
|
||||
await expect(page.locator('[data-cy="showTasks"]')).toBeAttached({timeout: 30000})
|
||||
await expect(page.locator('[data-cy="showTasks"] .card .task').first()).toBeVisible({timeout: 10000})
|
||||
|
||||
let taskApiCalls = 0
|
||||
page.on('request', request => {
|
||||
if (request.url().includes('/tasks') && request.method() === 'GET') {
|
||||
taskApiCalls++
|
||||
}
|
||||
})
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
taskApiCalls = 0
|
||||
|
||||
const resizeHandle = page.locator('.resize-handle')
|
||||
await expect(resizeHandle).toBeAttached()
|
||||
|
||||
const sidebar = page.locator('.menu-container')
|
||||
const sidebarBox = await sidebar.boundingBox()
|
||||
expect(sidebarBox).not.toBeNull()
|
||||
|
||||
const startX = sidebarBox!.x + sidebarBox!.width - 2
|
||||
const startY = sidebarBox!.y + sidebarBox!.height / 2
|
||||
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(startX + 100, startY, {steps: 10})
|
||||
await page.mouse.up()
|
||||
|
||||
await page.waitForTimeout(1500)
|
||||
|
||||
expect(taskApiCalls).toBe(0)
|
||||
})
|
||||
|
||||
test('should persist sidebar width after resize', async ({authenticatedPage: page, currentUser}) => {
|
||||
await page.setViewportSize({width: 1280, height: 720})
|
||||
await seedTasks(currentUser.id, 1)
|
||||
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const sidebar = page.locator('.menu-container')
|
||||
const initialBox = await sidebar.boundingBox()
|
||||
expect(initialBox).not.toBeNull()
|
||||
const initialWidth = initialBox!.width
|
||||
|
||||
const resizeHandle = page.locator('.resize-handle')
|
||||
await expect(resizeHandle).toBeAttached()
|
||||
|
||||
const startX = initialBox!.x + initialBox!.width - 2
|
||||
const startY = initialBox!.y + initialBox!.height / 2
|
||||
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(startX + 50, startY, {steps: 10})
|
||||
await page.mouse.up()
|
||||
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
const newBox = await sidebar.boundingBox()
|
||||
expect(newBox).not.toBeNull()
|
||||
expect(newBox!.width).toBeGreaterThan(initialWidth)
|
||||
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const reloadedBox = await sidebar.boundingBox()
|
||||
expect(reloadedBox).not.toBeNull()
|
||||
expect(Math.abs(reloadedBox!.width - newBox!.width)).toBeLessThan(5)
|
||||
})
|
||||
|
||||
test('should not show resize handle on mobile', async ({authenticatedPage: page, currentUser}) => {
|
||||
await seedTasks(currentUser.id, 1)
|
||||
await page.setViewportSize({width: 375, height: 667})
|
||||
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const resizeHandle = page.locator('.resize-handle')
|
||||
await expect(resizeHandle).not.toBeAttached()
|
||||
})
|
||||
})
|
||||
@@ -26,11 +26,17 @@ export async function login(page: Page | null, apiContext: APIRequestContext, us
|
||||
const body = await response.json()
|
||||
const token = body.token
|
||||
|
||||
// Set token in localStorage before navigating (only if page is provided)
|
||||
// Set token and API_URL before navigating (only if page is provided)
|
||||
if (page) {
|
||||
await page.addInitScript((token) => {
|
||||
// Use 127.0.0.1 instead of localhost to match the frontend's origin for CORS
|
||||
const apiUrl = process.env.API_URL || 'http://127.0.0.1:3456/api/v1'
|
||||
await page.addInitScript(({token, apiUrl}) => {
|
||||
// Set both localStorage AND window.API_URL
|
||||
// The app uses window.API_URL for initialization (in base.ts loadApp)
|
||||
window.localStorage.setItem('token', token)
|
||||
}, token)
|
||||
window.localStorage.setItem('API_URL', apiUrl)
|
||||
window.API_URL = apiUrl
|
||||
}, {token, apiUrl})
|
||||
}
|
||||
|
||||
return {user, token}
|
||||
|
||||
Reference in New Issue
Block a user