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:
kolaente
2025-12-12 11:46:46 +01:00
committed by GitHub
parent 803effbb8f
commit 1288d0a99c
11 changed files with 357 additions and 17 deletions

View File

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

View File

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

View File

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

View 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,
}
}

View File

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

View File

@@ -23,6 +23,7 @@ export interface IFrontendSettings {
defaultTaskRelationType: IRelationKind
backgroundBrightness: number | null
alwaysShowBucketTaskCount: boolean
sidebarWidth: number | null
}
export interface IExtraSettingsLink {

View File

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

View File

@@ -138,6 +138,7 @@ export const useAuthStore = defineStore('auth', () => {
timeFormat: TIME_FORMAT.HOURS_24,
defaultTaskRelationType: RELATION_KIND.RELATED,
backgroundBrightness: 100,
sidebarWidth: null,
...newSettings.frontendSettings,
},
})

View File

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

View 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()
})
})

View File

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