From 1288d0a99c72145c45a5a0c104c6d6c3995f60e6 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Dec 2025 11:46:46 +0100 Subject: [PATCH] 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. --- frontend/playwright.config.ts | 2 +- frontend/src/components/home/ContentAuth.vue | 12 +- frontend/src/components/home/Navigation.vue | 38 ++++- frontend/src/composables/useSidebarResize.ts | 156 ++++++++++++++++++ frontend/src/directives/cypress.ts | 4 +- frontend/src/modelTypes/IUserSettings.ts | 1 + frontend/src/models/userSettings.ts | 1 + frontend/src/stores/auth.ts | 1 + frontend/src/views/tasks/ShowTasks.vue | 24 ++- .../tests/e2e/misc/sidebar-resize.spec.ts | 123 ++++++++++++++ frontend/tests/support/authenticateUser.ts | 12 +- 11 files changed, 357 insertions(+), 17 deletions(-) create mode 100644 frontend/src/composables/useSidebarResize.ts create mode 100644 frontend/tests/e2e/misc/sidebar-resize.spec.ts diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index aab890ec7..3fcf9c097 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -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 diff --git a/frontend/src/components/home/ContentAuth.vue b/frontend/src/components/home/ContentAuth.vue index baa14a551..b713337bf 100644 --- a/frontend/src/components/home/ContentAuth.vue +++ b/frontend/src/components/home/ContentAuth.vue @@ -27,6 +27,7 @@ { 'is-menu-enabled': menuActive }, $route.name, ]" + :style="{'--sidebar-width': sidebarWidth}" > +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); } } diff --git a/frontend/src/components/home/Navigation.vue b/frontend/src/components/home/Navigation.vue index d36a014af..4d3259f95 100644 --- a/frontend/src/components/home/Navigation.vue +++ b/frontend/src/components/home/Navigation.vue @@ -1,7 +1,8 @@ @@ -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 { diff --git a/frontend/src/composables/useSidebarResize.ts b/frontend/src/composables/useSidebarResize.ts new file mode 100644 index 000000000..39e3b19fe --- /dev/null +++ b/frontend/src/composables/useSidebarResize.ts @@ -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) { + 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, + } +} diff --git a/frontend/src/directives/cypress.ts b/frontend/src/directives/cypress.ts index 418b8adec..4c1a62e94 100644 --- a/frontend/src/directives/cypress.ts +++ b/frontend/src/directives/cypress.ts @@ -9,7 +9,9 @@ declare global { const cypressDirective = >{ 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) } }, diff --git a/frontend/src/modelTypes/IUserSettings.ts b/frontend/src/modelTypes/IUserSettings.ts index 412aa143a..d66412a57 100644 --- a/frontend/src/modelTypes/IUserSettings.ts +++ b/frontend/src/modelTypes/IUserSettings.ts @@ -23,6 +23,7 @@ export interface IFrontendSettings { defaultTaskRelationType: IRelationKind backgroundBrightness: number | null alwaysShowBucketTaskCount: boolean + sidebarWidth: number | null } export interface IExtraSettingsLink { diff --git a/frontend/src/models/userSettings.ts b/frontend/src/models/userSettings.ts index 12ea6c269..d79d0b852 100644 --- a/frontend/src/models/userSettings.ts +++ b/frontend/src/models/userSettings.ts @@ -31,6 +31,7 @@ export default class UserSettingsModel extends AbstractModel impl timeFormat: TIME_FORMAT.HOURS_24, defaultTaskRelationType: RELATION_KIND.RELATED, alwaysShowBucketTaskCount: false, + sidebarWidth: null, } extraSettingsLinks = {} diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index a15ed57bd..0e02c38b0 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -138,6 +138,7 @@ export const useAuthStore = defineStore('auth', () => { timeFormat: TIME_FORMAT.HOURS_24, defaultTaskRelationType: RELATION_KIND.RELATED, backgroundBrightness: 100, + sidebarWidth: null, ...newSettings.frontendSettings, }, }) diff --git a/frontend/src/views/tasks/ShowTasks.vue b/frontend/src/views/tasks/ShowTasks.vue index 6c701634d..4ca0c80a8 100644 --- a/frontend/src/views/tasks/ShowTasks.vue +++ b/frontend/src/views/tasks/ShowTasks.vue @@ -94,7 +94,7 @@ diff --git a/frontend/tests/e2e/misc/sidebar-resize.spec.ts b/frontend/tests/e2e/misc/sidebar-resize.spec.ts new file mode 100644 index 000000000..c55cfabf2 --- /dev/null +++ b/frontend/tests/e2e/misc/sidebar-resize.spec.ts @@ -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() + }) +}) diff --git a/frontend/tests/support/authenticateUser.ts b/frontend/tests/support/authenticateUser.ts index 327b588ab..50307b182 100644 --- a/frontend/tests/support/authenticateUser.ts +++ b/frontend/tests/support/authenticateUser.ts @@ -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}