mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-26 15:15:19 +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
|
workers: 1, // No parallelization initially
|
||||||
reporter: process.env.CI ? [['html'], ['list']] : 'html',
|
reporter: process.env.CI ? [['html'], ['list']] : 'html',
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://127.0.0.1:4173',
|
baseURL: process.env.BASE_URL || 'http://127.0.0.1:4173',
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
testIdAttribute: 'data-cy', // Preserve existing data-cy selectors
|
testIdAttribute: 'data-cy', // Preserve existing data-cy selectors
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
{ 'is-menu-enabled': menuActive },
|
{ 'is-menu-enabled': menuActive },
|
||||||
$route.name,
|
$route.name,
|
||||||
]"
|
]"
|
||||||
|
:style="{'--sidebar-width': sidebarWidth}"
|
||||||
>
|
>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
v-show="menuActive"
|
v-show="menuActive"
|
||||||
@@ -84,6 +85,7 @@ import {useProjectStore} from '@/stores/projects'
|
|||||||
|
|
||||||
import {useRouteWithModal} from '@/composables/useRouteWithModal'
|
import {useRouteWithModal} from '@/composables/useRouteWithModal'
|
||||||
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
|
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
|
||||||
|
import {useSidebarResize} from '@/composables/useSidebarResize'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -91,6 +93,8 @@ const backgroundBrightness = computed(() =>
|
|||||||
authStore.settings?.frontendSettings?.backgroundBrightness,
|
authStore.settings?.frontendSettings?.backgroundBrightness,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const {sidebarWidth} = useSidebarResize()
|
||||||
|
|
||||||
const {routeWithModal, currentModal, closeModal} = useRouteWithModal()
|
const {routeWithModal, currentModal, closeModal} = useRouteWithModal()
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
@@ -173,6 +177,8 @@ projectStore.loadAllProjects()
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-content {
|
.app-content {
|
||||||
|
--sidebar-width: #{$navbar-width};
|
||||||
|
|
||||||
display: flow-root;
|
display: flow-root;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -192,7 +198,7 @@ projectStore.loadAllProjects()
|
|||||||
|
|
||||||
&.is-menu-enabled {
|
&.is-menu-enabled {
|
||||||
@media screen and (min-width: $tablet) {
|
@media screen and (min-width: $tablet) {
|
||||||
margin-inline-start: $navbar-width;
|
margin-inline-start: var(--sidebar-width);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside
|
<aside
|
||||||
:class="{'is-active': baseStore.menuActive}"
|
:class="{'is-active': baseStore.menuActive, 'is-resizing': isResizing}"
|
||||||
class="menu-container"
|
class="menu-container"
|
||||||
|
:style="{'--sidebar-width': sidebarWidth}"
|
||||||
>
|
>
|
||||||
<nav class="menu top-menu">
|
<nav class="menu top-menu">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
@@ -113,6 +114,13 @@
|
|||||||
class="mbs-auto"
|
class="mbs-auto"
|
||||||
utm-medium="navigation"
|
utm-medium="navigation"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!isMobile"
|
||||||
|
class="resize-handle"
|
||||||
|
@mousedown="startResize"
|
||||||
|
@touchstart="startResize"
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -127,10 +135,13 @@ import {useBaseStore} from '@/stores/base'
|
|||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
import ProjectsNavigation from '@/components/home/ProjectsNavigation.vue'
|
||||||
import type {IProject} from '@/modelTypes/IProject'
|
import type {IProject} from '@/modelTypes/IProject'
|
||||||
|
import {useSidebarResize} from '@/composables/useSidebarResize'
|
||||||
|
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
|
|
||||||
|
const {sidebarWidth, isResizing, startResize, isMobile} = useSidebarResize()
|
||||||
|
|
||||||
// Cast readonly arrays to mutable type - the arrays are not actually mutated by the component
|
// Cast readonly arrays to mutable type - the arrays are not actually mutated by the component
|
||||||
const projects = computed(() => projectStore.notArchivedRootProjects as IProject[])
|
const projects = computed(() => projectStore.notArchivedRootProjects as IProject[])
|
||||||
const favoriteProjects = computed(() => projectStore.favoriteProjects as IProject[])
|
const favoriteProjects = computed(() => projectStore.favoriteProjects as IProject[])
|
||||||
@@ -151,6 +162,8 @@ const savedFilterProjects = computed(() => projectStore.savedFilterProjects as I
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-container {
|
.menu-container {
|
||||||
|
--sidebar-width: #{$navbar-width};
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--site-background);
|
background: var(--site-background);
|
||||||
@@ -162,7 +175,7 @@ const savedFilterProjects = computed(() => projectStore.savedFilterProjects as I
|
|||||||
inset-block-end: 0;
|
inset-block-end: 0;
|
||||||
inset-inline-start: 0;
|
inset-inline-start: 0;
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
inline-size: $navbar-width;
|
inline-size: var(--sidebar-width);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
[dir="rtl"] & {
|
[dir="rtl"] & {
|
||||||
@@ -179,6 +192,27 @@ const savedFilterProjects = computed(() => projectStore.savedFilterProjects as I
|
|||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
transition: transform $transition-duration ease-out;
|
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 {
|
.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>>{
|
const cypressDirective = <Directive<HTMLElement,string>>{
|
||||||
mounted(el, {arg, value}) {
|
mounted(el, {arg, value}) {
|
||||||
const testingId = 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)
|
el.setAttribute('data-cy', testingId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface IFrontendSettings {
|
|||||||
defaultTaskRelationType: IRelationKind
|
defaultTaskRelationType: IRelationKind
|
||||||
backgroundBrightness: number | null
|
backgroundBrightness: number | null
|
||||||
alwaysShowBucketTaskCount: boolean
|
alwaysShowBucketTaskCount: boolean
|
||||||
|
sidebarWidth: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IExtraSettingsLink {
|
export interface IExtraSettingsLink {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export default class UserSettingsModel extends AbstractModel<IUserSettings> impl
|
|||||||
timeFormat: TIME_FORMAT.HOURS_24,
|
timeFormat: TIME_FORMAT.HOURS_24,
|
||||||
defaultTaskRelationType: RELATION_KIND.RELATED,
|
defaultTaskRelationType: RELATION_KIND.RELATED,
|
||||||
alwaysShowBucketTaskCount: false,
|
alwaysShowBucketTaskCount: false,
|
||||||
|
sidebarWidth: null,
|
||||||
}
|
}
|
||||||
extraSettingsLinks = {}
|
extraSettingsLinks = {}
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
timeFormat: TIME_FORMAT.HOURS_24,
|
timeFormat: TIME_FORMAT.HOURS_24,
|
||||||
defaultTaskRelationType: RELATION_KIND.RELATED,
|
defaultTaskRelationType: RELATION_KIND.RELATED,
|
||||||
backgroundBrightness: 100,
|
backgroundBrightness: 100,
|
||||||
|
sidebarWidth: null,
|
||||||
...newSettings.frontendSettings,
|
...newSettings.frontendSettings,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref, watchEffect} from 'vue'
|
import {computed, ref, watch, watchEffect} from 'vue'
|
||||||
import {useRoute, useRouter} from 'vue-router'
|
import {useRoute, useRouter} from 'vue-router'
|
||||||
import {useI18n} from 'vue-i18n'
|
import {useI18n} from 'vue-i18n'
|
||||||
|
|
||||||
@@ -182,6 +182,7 @@ const pageTitle = computed(() => {
|
|||||||
const hasTasks = computed(() => tasks.value && tasks.value.length > 0)
|
const hasTasks = computed(() => tasks.value && tasks.value.length > 0)
|
||||||
const userAuthenticated = computed(() => authStore.authenticated)
|
const userAuthenticated = computed(() => authStore.authenticated)
|
||||||
const loading = computed(() => taskStore.isLoading || taskCollectionService.value.loading)
|
const loading = computed(() => taskStore.isLoading || taskCollectionService.value.loading)
|
||||||
|
const filterIdUsedOnOverview = computed(() => authStore.settings?.frontendSettings?.filterIdUsedOnOverview)
|
||||||
|
|
||||||
interface dateStrings {
|
interface dateStrings {
|
||||||
dateFrom: string,
|
dateFrom: string,
|
||||||
@@ -224,7 +225,7 @@ function clearLabelFilter() {
|
|||||||
emit('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.
|
// 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 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
|
// Since this component is mounted as the home page before unauthenticated users get redirected
|
||||||
@@ -261,7 +262,6 @@ async function loadPendingTasks(from: Date|string, to: Date|string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let projectId = null
|
let projectId = null
|
||||||
const filterId = authStore.settings.frontendSettings.filterIdUsedOnOverview
|
|
||||||
if (showAll.value && filterId && typeof projectStore.projects[filterId] !== 'undefined') {
|
if (showAll.value && filterId && typeof projectStore.projects[filterId] !== 'undefined') {
|
||||||
projectId = filterId
|
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))
|
watchEffect(() => setTitle(pageTitle.value))
|
||||||
</script>
|
</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 body = await response.json()
|
||||||
const token = body.token
|
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) {
|
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)
|
window.localStorage.setItem('token', token)
|
||||||
}, token)
|
window.localStorage.setItem('API_URL', apiUrl)
|
||||||
|
window.API_URL = apiUrl
|
||||||
|
}, {token, apiUrl})
|
||||||
}
|
}
|
||||||
|
|
||||||
return {user, token}
|
return {user, token}
|
||||||
|
|||||||
Reference in New Issue
Block a user