mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-29 19:10:51 -05:00
The back button was removed from modal mode in the previous commit. On mobile kanban, tasks open as modals, so the test now uses the close button from the Heading component instead.
1480 lines
61 KiB
TypeScript
1480 lines
61 KiB
TypeScript
import {test, expect} from '../../support/fixtures'
|
|
import dayjs from 'dayjs'
|
|
import relativeTime from 'dayjs/plugin/relativeTime.js'
|
|
|
|
dayjs.extend(relativeTime)
|
|
|
|
import {TaskFactory} from '../../factories/task'
|
|
import {ProjectFactory} from '../../factories/project'
|
|
import {TaskCommentFactory} from '../../factories/task_comment'
|
|
import {UserFactory} from '../../factories/user'
|
|
import {UserProjectFactory} from '../../factories/users_project'
|
|
import {TaskAssigneeFactory} from '../../factories/task_assignee'
|
|
import {LabelFactory} from '../../factories/labels'
|
|
import {LabelTaskFactory} from '../../factories/label_task'
|
|
import {BucketFactory} from '../../factories/bucket'
|
|
import {TaskAttachmentFactory} from '../../factories/task_attachments'
|
|
import {TaskReminderFactory} from '../../factories/task_reminders'
|
|
import {createDefaultViews} from '../project/prepareProjects'
|
|
import {TaskBucketFactory} from '../../factories/task_buckets'
|
|
import {pasteFile} from '../../support/commands'
|
|
import type {Page} from '@playwright/test'
|
|
import {readFileSync} from 'fs'
|
|
import {join, dirname} from 'path'
|
|
import {fileURLToPath} from 'url'
|
|
|
|
const __filename = fileURLToPath(import.meta.url)
|
|
const __dirname = dirname(__filename)
|
|
|
|
// Type definitions to fix linting errors
|
|
interface Project {
|
|
id: number;
|
|
title: string;
|
|
identifier?: string;
|
|
}
|
|
|
|
interface Task {
|
|
id: number;
|
|
title: string;
|
|
description: string;
|
|
project_id: number;
|
|
index: number;
|
|
}
|
|
|
|
interface User {
|
|
id: number;
|
|
username: string;
|
|
}
|
|
|
|
interface Label {
|
|
id: number;
|
|
title: string;
|
|
}
|
|
|
|
interface Bucket {
|
|
id: number;
|
|
project_view_id: number;
|
|
}
|
|
|
|
async function addLabelToTaskAndVerify(page: Page, labelTitle: string) {
|
|
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Add Labels'}).click()
|
|
await page.locator('.task-view .details.labels-list .multiselect input').fill(labelTitle)
|
|
// Wait for search results to appear before clicking
|
|
const searchResults = page.locator('.task-view .details.labels-list .multiselect .search-results')
|
|
await searchResults.waitFor({state: 'visible'})
|
|
await searchResults.locator('> *').first().click()
|
|
|
|
await expect(page.locator('.global-notification')).toContainText('Success', {timeout: 4000})
|
|
await expect(page.locator('.task-view .details.labels-list .multiselect .input-wrapper span.tag')).toBeVisible()
|
|
await expect(page.locator('.task-view .details.labels-list .multiselect .input-wrapper span.tag')).toContainText(labelTitle)
|
|
}
|
|
|
|
async function uploadAttachmentAndVerify(page: Page, taskId: number) {
|
|
const uploadAttachmentPromise = page.waitForResponse(response =>
|
|
response.url().includes(`/tasks/${taskId}/attachments`) && response.request().method() === 'PUT',
|
|
)
|
|
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Add Attachments'}).click()
|
|
await page.locator('input[type=file]#files').setInputFiles('tests/fixtures/image.jpg')
|
|
await uploadAttachmentPromise
|
|
|
|
await expect(page.locator('.attachments .attachments .files button.attachment')).toBeVisible()
|
|
}
|
|
|
|
test.describe('Task', () => {
|
|
let projects: Project[]
|
|
let buckets: Bucket[]
|
|
|
|
test.beforeEach(async ({authenticatedPage: page}) => {
|
|
projects = await ProjectFactory.create(1) as Project[]
|
|
const views = await createDefaultViews(projects[0].id)
|
|
buckets = await BucketFactory.create(1, {
|
|
project_view_id: views[3].id,
|
|
}) as Bucket[]
|
|
await TaskFactory.truncate()
|
|
await UserProjectFactory.truncate()
|
|
})
|
|
|
|
test('Should be created new', async ({authenticatedPage: page}) => {
|
|
await page.goto('/projects/1/1')
|
|
await page.locator('.input[placeholder="Add a task…"]').fill('New Task')
|
|
await page.locator('.button').filter({hasText: 'Add'}).click()
|
|
await expect(page.locator('.tasks .task .tasktext').first()).toContainText('New Task')
|
|
})
|
|
|
|
test('Inserts new tasks at the top of the project', async ({authenticatedPage: page}) => {
|
|
await TaskFactory.create(1)
|
|
|
|
await page.goto('/projects/1/1')
|
|
await expect(page.locator('.project-is-empty-notice')).not.toBeVisible()
|
|
await page.locator('.input[placeholder="Add a task…"]').fill('New Task')
|
|
await page.locator('.button').filter({hasText: 'Add'}).click()
|
|
|
|
await page.waitForTimeout(1000) // Wait for the request
|
|
await expect(page.locator('.tasks .task .tasktext').first()).toContainText('New Task')
|
|
})
|
|
|
|
test('Marks a task as done', async ({authenticatedPage: page}) => {
|
|
await TaskFactory.create(1)
|
|
|
|
await page.goto('/projects/1/1')
|
|
await page.locator('.tasks .task .fancy-checkbox').first().click()
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
})
|
|
|
|
test('Can add a task to favorites', async ({authenticatedPage: page}) => {
|
|
await TaskFactory.create(1)
|
|
|
|
await page.goto('/projects/1/1')
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
// Wait for tasks to be visible
|
|
const favoriteButton = page.locator('.tasks .task .favorite').first()
|
|
await expect(favoriteButton).toBeVisible({timeout: 10000})
|
|
|
|
// Wait for the favorite API response
|
|
const favoritePromise = page.waitForResponse(response =>
|
|
response.url().includes('/tasks/') && response.request().method() === 'POST',
|
|
)
|
|
await favoriteButton.click()
|
|
await favoritePromise
|
|
|
|
// The Favorites menu item should appear after a task is favorited
|
|
await expect(page.locator('.menu-container')).toContainText('Favorites', {timeout: 10000})
|
|
})
|
|
|
|
test('Should show a task description icon if the task has a description', async ({authenticatedPage: page}) => {
|
|
const loadTasksPromise = page.waitForResponse(response =>
|
|
response.url().includes('/projects/1/views/') && response.url().includes('/tasks'),
|
|
)
|
|
await TaskFactory.create(1, {
|
|
description: 'Lorem Ipsum',
|
|
})
|
|
|
|
await page.goto('/projects/1/1')
|
|
await loadTasksPromise
|
|
|
|
await expect(page.locator('.tasks .task .project-task-icon .fa-align-left')).toBeVisible()
|
|
})
|
|
|
|
test('Should not show a task description icon if the task has an empty description', async ({authenticatedPage: page}) => {
|
|
const loadTasksPromise = page.waitForResponse(response =>
|
|
response.url().includes('/projects/1/views/') && response.url().includes('/tasks'),
|
|
)
|
|
await TaskFactory.create(1, {
|
|
description: '',
|
|
})
|
|
|
|
await page.goto('/projects/1/1')
|
|
await loadTasksPromise
|
|
|
|
await expect(page.locator('.tasks .task .project-task-icon .fa-align-left')).not.toBeVisible()
|
|
})
|
|
|
|
test('Should not show a task description icon if the task has a description containing only an empty p tag', async ({authenticatedPage: page}) => {
|
|
const loadTasksPromise = page.waitForResponse(response =>
|
|
response.url().includes('/projects/1/views/') && response.url().includes('/tasks'),
|
|
)
|
|
await TaskFactory.create(1, {
|
|
description: '<p></p>',
|
|
})
|
|
|
|
await page.goto('/projects/1/1')
|
|
await loadTasksPromise
|
|
|
|
await expect(page.locator('.tasks .task .project-task-icon .fa-align-left')).not.toBeVisible()
|
|
})
|
|
|
|
test.describe('Task Detail View', () => {
|
|
test.beforeEach(async ({authenticatedPage: page}) => {
|
|
await TaskCommentFactory.truncate()
|
|
await LabelTaskFactory.truncate()
|
|
await TaskAttachmentFactory.truncate()
|
|
})
|
|
|
|
test('provides back navigation to the project in the list view', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1)
|
|
const loadTasksPromise = page.waitForResponse(response =>
|
|
response.url().includes('/projects/1/views/') && response.url().includes('/tasks'),
|
|
)
|
|
await page.goto('/projects/1/1')
|
|
await loadTasksPromise
|
|
await page.locator('.list-view .task').first().locator('a.task-link').click()
|
|
await expect(page.locator('.task-view .back-button')).toBeVisible()
|
|
await page.locator('.task-view .back-button').click()
|
|
await expect(page).toHaveURL(/\/projects\/1\/\d+/)
|
|
})
|
|
|
|
test('provides back navigation to the project in the table view', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1)
|
|
const loadTasksPromise = page.waitForResponse(response =>
|
|
response.url().includes('/projects/1/views/') && response.url().includes('/tasks'),
|
|
)
|
|
await page.goto('/projects/1/3')
|
|
await loadTasksPromise
|
|
await page.locator('tbody tr').first().locator('a').first().click()
|
|
await expect(page.locator('.task-view .back-button')).toBeVisible()
|
|
await page.locator('.task-view .back-button').click()
|
|
await expect(page).toHaveURL(/\/projects\/1\/\d+/)
|
|
})
|
|
|
|
test('provides close navigation to the project in the kanban view on mobile', async ({authenticatedPage: page}) => {
|
|
await page.setViewportSize({width: 375, height: 667}) // iphone-8
|
|
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
project_id: projects[0].id,
|
|
})
|
|
// Task must be in a bucket to appear in kanban view
|
|
await TaskBucketFactory.create(1, {
|
|
task_id: tasks[0].id,
|
|
bucket_id: buckets[0].id,
|
|
project_view_id: buckets[0].project_view_id,
|
|
})
|
|
await page.goto(`/projects/${projects[0].id}/4`)
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
// Wait for kanban view and task to be visible
|
|
const taskLocator = page.locator('.kanban-view .tasks .task').first()
|
|
await expect(taskLocator).toBeVisible({timeout: 10000})
|
|
await taskLocator.click()
|
|
// On mobile, the task opens as a modal with a close button instead of a back button
|
|
await expect(page.locator('.task-view .task-properties .close')).toBeVisible()
|
|
await page.locator('.task-view .task-properties .close').click()
|
|
await expect(page).toHaveURL(/\/projects\/\d+\/\d+/)
|
|
})
|
|
|
|
test('does not provide back navigation to the project in the kanban view on desktop', async ({authenticatedPage: page}) => {
|
|
await page.setViewportSize({width: 1440, height: 900}) // macbook-15
|
|
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
project_id: projects[0].id,
|
|
})
|
|
// Task must be in a bucket to appear in kanban view
|
|
await TaskBucketFactory.create(1, {
|
|
task_id: tasks[0].id,
|
|
bucket_id: buckets[0].id,
|
|
project_view_id: buckets[0].project_view_id,
|
|
})
|
|
await page.goto(`/projects/${projects[0].id}/4`)
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
// Wait for kanban view and task to be visible
|
|
const taskLocator = page.locator('.kanban-view .tasks .task').first()
|
|
await expect(taskLocator).toBeVisible({timeout: 10000})
|
|
await taskLocator.click()
|
|
await expect(page.locator('.task-view .back-button')).not.toBeVisible()
|
|
})
|
|
|
|
test('Shows a 404 page for nonexisting tasks', async ({authenticatedPage: page}) => {
|
|
await page.goto('/tasks/9999')
|
|
await expect(page.locator('body')).toContainText('Not found')
|
|
})
|
|
|
|
test('Shows all task details', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
index: 1,
|
|
description: 'Lorem ipsum dolor sit amet.',
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await expect(page.locator('.task-view h1.title.input')).toContainText(tasks[0].title)
|
|
await expect(page.locator('.task-view h1.title.task-id')).toContainText('#1')
|
|
await expect(page.locator('.task-view h6.subtitle')).toContainText(projects[0].title)
|
|
await expect(page.locator('.task-view .details.content.description')).toContainText(tasks[0].description)
|
|
await expect(page.locator('.task-view .action-buttons p.created')).toContainText('Created')
|
|
})
|
|
|
|
test('Shows a done label for done tasks', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
index: 1,
|
|
done: true,
|
|
done_at: new Date().toISOString(),
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await expect(page.locator('.task-view .heading .is-done')).toBeVisible()
|
|
await expect(page.locator('.task-view .heading .is-done')).toContainText('Done')
|
|
await page.locator('.task-view .action-buttons p.created').scrollIntoViewIfNeeded()
|
|
await expect(page.locator('.task-view .action-buttons p.created')).toBeVisible()
|
|
await expect(page.locator('.task-view .action-buttons p.created')).toContainText('Done')
|
|
})
|
|
|
|
test('Can mark a task as done', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
done: false,
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Mark task done!'}).click()
|
|
|
|
await expect(page.locator('.task-view .heading .is-done')).toBeVisible()
|
|
await expect(page.locator('.task-view .heading .is-done')).toContainText('Done')
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
await expect(page.locator('.task-view .action-buttons .button').filter({hasText: 'Mark as undone'})).toBeVisible()
|
|
})
|
|
|
|
test('Shows a task identifier since the project has one', async ({authenticatedPage: page}) => {
|
|
const projects = await ProjectFactory.create(1, {
|
|
id: 1,
|
|
identifier: 'TEST',
|
|
})
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
project_id: projects[0].id,
|
|
index: 1,
|
|
})
|
|
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await expect(page.locator('.task-view h1.title.task-id')).toContainText(`${projects[0].identifier}-${tasks[0].index}`)
|
|
})
|
|
|
|
test('Can edit the description', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
description: 'Lorem ipsum dolor sit amet.',
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
// Wait for the edit button to be visible
|
|
const editButton = page.locator('.task-view .details.content.description .tiptap button.done-edit')
|
|
await expect(editButton).toBeVisible({timeout: 10000})
|
|
await editButton.click()
|
|
|
|
const editor = page.locator('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror')
|
|
await expect(editor).toBeVisible()
|
|
await editor.fill('New Description')
|
|
|
|
const saveButton = page.locator('[data-cy="saveEditor"]').filter({hasText: 'Save'})
|
|
await expect(saveButton).toBeVisible()
|
|
await saveButton.click()
|
|
|
|
await expect(page.locator('.task-view .details.content.description h3 span.is-small.has-text-success')).toContainText('Saved!')
|
|
})
|
|
|
|
test('autosaves the description when leaving the task view', async ({authenticatedPage: page}) => {
|
|
await TaskFactory.create(1, {
|
|
id: 1,
|
|
project_id: projects[0].id,
|
|
description: 'Old Description',
|
|
})
|
|
|
|
await page.goto('/tasks/1')
|
|
|
|
await page.locator('.task-view .details.content.description .tiptap button.done-edit', {timeout: 30_000}).click()
|
|
await page.locator('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror').fill('New Description')
|
|
|
|
await page.locator('.task-view h6.subtitle a').first().click()
|
|
|
|
await page.goto('/tasks/1')
|
|
await expect(page.locator('.task-view .details.content.description')).toContainText('New Description')
|
|
})
|
|
|
|
test('Shows an empty editor when the description of a task is empty', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
description: '',
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await expect(page.locator('.task-view .details.content.description .tiptap.ProseMirror p')).toHaveAttribute('data-placeholder')
|
|
await expect(page.locator('.task-view .details.content.description .tiptap button.done-edit')).not.toBeVisible()
|
|
})
|
|
|
|
test('Shows a preview editor when the description of a task is not empty', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
description: 'Lorem Ipsum dolor sit amet',
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await expect(page.locator('.task-view .details.content.description .tiptap.ProseMirror p')).not.toHaveAttribute('data-placeholder')
|
|
await expect(page.locator('.task-view .details.content.description .tiptap button.done-edit')).toBeVisible()
|
|
})
|
|
|
|
test('Shows a preview editor when the description of a task contains html', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
description: '<p>Lorem Ipsum dolor sit amet</p>',
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await expect(page.locator('.task-view .details.content.description .tiptap.ProseMirror p')).not.toHaveAttribute('data-placeholder')
|
|
await expect(page.locator('.task-view .details.content.description .tiptap button.done-edit')).toBeVisible()
|
|
})
|
|
|
|
test('Can add a new comment', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await expect(page.locator('.task-view .comments .media.comment .tiptap__editor .tiptap.ProseMirror')).toBeVisible()
|
|
await page.locator('.task-view .comments .media.comment .tiptap__editor .tiptap.ProseMirror').fill('New Comment')
|
|
await page.locator('.task-view .comments .media.comment .button:not([disabled])').filter({hasText: 'Comment'}).click()
|
|
|
|
await expect(page.locator('.task-view .comments .media.comment .tiptap__editor').first()).toContainText('New Comment')
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
})
|
|
|
|
test('Can move a task to another project', async ({authenticatedPage: page}) => {
|
|
const projects = await ProjectFactory.create(2)
|
|
const views = await createDefaultViews(projects[0].id)
|
|
// Also create views for the target project
|
|
await createDefaultViews(projects[1].id)
|
|
await BucketFactory.create(2, {
|
|
project_view_id: views[3].id,
|
|
})
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
project_id: projects[0].id,
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await page.locator('.task-view .action-buttons .button').filter({hasText: /^Move$/}).click()
|
|
const multiselectInput = page.locator('.task-view .content.details .field .multiselect.control .input-wrapper input')
|
|
// Use type/pressSequentially instead of fill to properly trigger Vue's input events
|
|
await multiselectInput.click()
|
|
await multiselectInput.pressSequentially(projects[1].title.substring(0, 10), {delay: 20})
|
|
// Wait for the search results to appear (there's a 200ms debounce in the multiselect)
|
|
await expect(page.locator('.task-view .content.details .field .multiselect.control .search-results')).toBeVisible({timeout: 5000})
|
|
await page.locator('.task-view .content.details .field .multiselect.control .search-results').locator('> *').first().click()
|
|
|
|
await expect(page.locator('.task-view h6.subtitle')).toContainText(projects[1].title)
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
})
|
|
|
|
test('Can delete a task', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
project_id: 1,
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await expect(page.locator('.task-view .action-buttons .button').filter({hasText: 'Delete'})).toBeVisible()
|
|
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Delete'}).click()
|
|
await expect(page.locator('.modal-mask .modal-container .modal-content .modal-header')).toContainText('Delete this task')
|
|
await page.locator('.modal-mask .modal-container .modal-content .actions .button').filter({hasText: 'Do it!'}).click()
|
|
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
await expect(page).toHaveURL(new RegExp(`/projects/${tasks[0].project_id}/`))
|
|
})
|
|
|
|
test('Can add an assignee to a task', async ({authenticatedPage: page}) => {
|
|
await TaskAssigneeFactory.truncate()
|
|
|
|
// Create users with IDs starting at 100 to avoid conflict with logged-in user (ID 1)
|
|
// Don't truncate to preserve the authenticated user from the fixture
|
|
const users = await UserFactory.create(5, {
|
|
id: (i: number) => 100 + i,
|
|
}, false)
|
|
const projects = await ProjectFactory.create(1)
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
project_id: projects[0].id,
|
|
})
|
|
// Create project membership for all users at once (to avoid truncate issue)
|
|
await UserProjectFactory.create(5, {
|
|
project_id: projects[0].id,
|
|
user_id: (i: number) => users[i - 1].id,
|
|
})
|
|
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
// Wait for the assign button to be visible
|
|
const assignButton = page.locator('[data-cy="taskDetail.assign"]')
|
|
await expect(assignButton).toBeVisible({timeout: 10000})
|
|
await assignButton.click()
|
|
|
|
const input = page.locator('.task-view .column.assignees .multiselect input')
|
|
const userToAssign = users[0]
|
|
// Use type/pressSequentially instead of fill to properly trigger Vue's input events
|
|
await input.click()
|
|
await input.pressSequentially(userToAssign.username.substring(0, 10), {delay: 20})
|
|
// Wait for search results (200ms debounce + API request time)
|
|
await expect(page.locator('.task-view .column.assignees .multiselect .search-results')).toBeVisible({timeout: 5000})
|
|
await page.locator('.task-view .column.assignees .multiselect .search-results').locator('> *').first().click()
|
|
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
await expect(page.locator('.task-view .column.assignees .multiselect .input-wrapper span.assignee')).toBeVisible()
|
|
})
|
|
|
|
test('Can remove an assignee from a task', async ({authenticatedPage: page}) => {
|
|
const users = await UserFactory.create(2)
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
project_id: 1,
|
|
})
|
|
await UserProjectFactory.create(5, {
|
|
project_id: 1,
|
|
user_id: '{increment}',
|
|
})
|
|
await TaskAssigneeFactory.create(1, {
|
|
task_id: tasks[0].id,
|
|
user_id: users[1].id,
|
|
})
|
|
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await page.locator('.task-view .column.assignees .multiselect .input-wrapper span.assignee .remove-assignee').click()
|
|
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
await expect(page.locator('.task-view .column.assignees .multiselect .input-wrapper span.assignee')).not.toBeVisible()
|
|
})
|
|
|
|
test('Can add a new label to a task', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
project_id: 1,
|
|
})
|
|
await LabelFactory.truncate()
|
|
const newLabelText = 'some new label'
|
|
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await expect(page.locator('.task-view .action-buttons .button').filter({hasText: 'Add Labels'})).toBeVisible()
|
|
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Add Labels'}).click()
|
|
await page.locator('.task-view .details.labels-list .multiselect input').fill(newLabelText)
|
|
await page.locator('.task-view .details.labels-list .multiselect .search-results').locator('> *').first().click()
|
|
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
await expect(page.locator('.task-view .details.labels-list .multiselect .input-wrapper span.tag')).toBeVisible()
|
|
await expect(page.locator('.task-view .details.labels-list .multiselect .input-wrapper span.tag')).toContainText(newLabelText)
|
|
})
|
|
|
|
test('Can add an existing label to a task', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
project_id: 1,
|
|
})
|
|
const labels = await LabelFactory.create(1)
|
|
await LabelTaskFactory.truncate()
|
|
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await addLabelToTaskAndVerify(page, labels[0].title)
|
|
})
|
|
|
|
test('Can add a label to a task and it shows up on the kanban board afterwards', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
project_id: projects[0].id,
|
|
})
|
|
const labels = await LabelFactory.create(1)
|
|
await LabelTaskFactory.truncate()
|
|
await TaskBucketFactory.create(1, {
|
|
task_id: tasks[0].id,
|
|
bucket_id: buckets[0].id,
|
|
project_view_id: buckets[0].project_view_id,
|
|
})
|
|
|
|
await page.goto(`/projects/${projects[0].id}/4`)
|
|
|
|
await page.locator('.bucket .task').filter({hasText: tasks[0].title}).click()
|
|
|
|
await addLabelToTaskAndVerify(page, labels[0].title)
|
|
|
|
await page.locator('.modal-container > .close').click()
|
|
|
|
await expect(page.locator('.bucket .task')).toContainText(labels[0].title)
|
|
})
|
|
|
|
test('Can remove a label from a task', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
project_id: 1,
|
|
})
|
|
const labels = await LabelFactory.create(1)
|
|
await LabelTaskFactory.create(1, {
|
|
task_id: tasks[0].id,
|
|
label_id: labels[0].id,
|
|
})
|
|
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
const labelWrapper = page.locator('.task-view .details.labels-list .multiselect .input-wrapper')
|
|
await expect(labelWrapper).toBeVisible({timeout: 10000})
|
|
await expect(labelWrapper).toContainText(labels[0].title)
|
|
|
|
// Hover over the label to reveal the remove button
|
|
const labelItem = labelWrapper.locator('> *').first()
|
|
await labelItem.hover()
|
|
const removeButton = labelItem.locator('[data-cy="taskDetail.removeLabel"]')
|
|
await expect(removeButton).toBeVisible()
|
|
await removeButton.click()
|
|
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
await expect(labelWrapper).not.toContainText(labels[0].title)
|
|
})
|
|
|
|
test('Can set a due date for a task', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
done: false,
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
const setDueDateButton = page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Due Date'})
|
|
await expect(setDueDateButton).toBeVisible({timeout: 10000})
|
|
await setDueDateButton.click()
|
|
|
|
const datepickerShow = page.locator('.task-view .columns.details .column').filter({hasText: 'Due Date'}).locator('.date-input .datepicker .show')
|
|
await expect(datepickerShow).toBeVisible()
|
|
await datepickerShow.click()
|
|
|
|
const tomorrowButton = page.locator('.datepicker .datepicker-popup button').filter({hasText: 'Tomorrow'})
|
|
await expect(tomorrowButton).toBeVisible()
|
|
await tomorrowButton.click()
|
|
|
|
const confirmButton = page.locator('[data-cy="closeDatepicker"]').filter({hasText: 'Confirm'})
|
|
await expect(confirmButton).toBeVisible()
|
|
await confirmButton.click()
|
|
|
|
await expect(page.locator('.task-view .columns.details .column').filter({hasText: 'Due Date'}).locator('.date-input .datepicker-popup')).not.toBeVisible()
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
})
|
|
|
|
test('Can set a due date to a specific date for a task', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
done: false,
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
const setDueDateButton = page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Due Date'})
|
|
await expect(setDueDateButton).toBeVisible({timeout: 10000})
|
|
await setDueDateButton.click()
|
|
|
|
const datepickerShow = page.locator('.task-view .columns.details .column').filter({hasText: 'Due Date'}).locator('.date-input .datepicker .show')
|
|
await expect(datepickerShow).toBeVisible()
|
|
await datepickerShow.click()
|
|
|
|
const todayButton = page.locator('.datepicker-popup .flatpickr-innerContainer .flatpickr-days .flatpickr-day.today')
|
|
await expect(todayButton).toBeVisible()
|
|
await todayButton.click()
|
|
|
|
const confirmButton = page.locator('[data-cy="closeDatepicker"]').filter({hasText: 'Confirm'})
|
|
await expect(confirmButton).toBeVisible()
|
|
await confirmButton.click()
|
|
|
|
const today = new Date()
|
|
today.setHours(12)
|
|
today.setMinutes(0)
|
|
today.setSeconds(0)
|
|
await expect(page.locator('.task-view .columns.details .column').filter({hasText: 'Due Date'}).locator('.date-input .datepicker-popup')).not.toBeVisible()
|
|
await expect(page.locator('.task-view .columns.details .column').filter({hasText: 'Due Date'}).locator('.date-input')).toContainText(dayjs(today).fromNow())
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
})
|
|
|
|
test('Can change a due date to a specific date for a task', async ({authenticatedPage: page}) => {
|
|
const dueDate = new Date(2025, 2, 20)
|
|
dueDate.setHours(12)
|
|
dueDate.setMinutes(0)
|
|
dueDate.setSeconds(0)
|
|
dueDate.setDate(1)
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
done: false,
|
|
due_date: dueDate.toISOString(),
|
|
})
|
|
|
|
const today = new Date(2025, 2, 5)
|
|
today.setHours(12)
|
|
today.setMinutes(0)
|
|
today.setSeconds(0)
|
|
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
const setDueDateButton = page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Due Date'})
|
|
await expect(setDueDateButton).toBeVisible({timeout: 10000})
|
|
await setDueDateButton.click()
|
|
|
|
const datepickerShow = page.locator('.task-view .columns.details .column').filter({hasText: 'Due Date'}).locator('.date-input .datepicker .show')
|
|
await expect(datepickerShow).toBeVisible()
|
|
await datepickerShow.click()
|
|
|
|
const dateButton = page.locator(`.datepicker-popup .flatpickr-innerContainer .flatpickr-days [aria-label="${today.toLocaleString('en-US', {month: 'long'})} ${today.getDate()}, ${today.getFullYear()}"]`)
|
|
await expect(dateButton).toBeVisible()
|
|
await dateButton.click()
|
|
|
|
const confirmButton = page.locator('[data-cy="closeDatepicker"]').filter({hasText: 'Confirm'})
|
|
await expect(confirmButton).toBeVisible()
|
|
await confirmButton.click()
|
|
|
|
await expect(page.locator('.task-view .columns.details .column').filter({hasText: 'Due Date'}).locator('.date-input .datepicker-popup')).not.toBeVisible()
|
|
await expect(page.locator('.task-view .columns.details .column').filter({hasText: 'Due Date'}).locator('.date-input')).toContainText(dayjs(today).fromNow())
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
})
|
|
|
|
test('Can paste an image into the description editor which uploads it as an attachment', async ({authenticatedPage: page}) => {
|
|
await TaskAttachmentFactory.truncate()
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
}) as Task[]
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
const uploadAttachmentPromise = page.waitForResponse(response =>
|
|
response.url().includes(`/tasks/${tasks[0].id}/attachments`) && response.request().method() === 'PUT',
|
|
)
|
|
|
|
const editor = page.locator('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror')
|
|
await expect(editor).toBeVisible({timeout: 30_000})
|
|
await pasteFile(editor, 'image.jpg', 'image/jpeg')
|
|
|
|
await uploadAttachmentPromise
|
|
await expect(page.locator('.attachments .attachments .files button.attachment')).toBeVisible()
|
|
const img = page.locator('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror img')
|
|
await expect(img).toBeVisible()
|
|
const naturalWidth = await img.evaluate((el: HTMLImageElement) => el.naturalWidth)
|
|
expect(naturalWidth).toBeGreaterThan(0)
|
|
})
|
|
|
|
test('Can set a reminder', async ({authenticatedPage: page}) => {
|
|
await TaskReminderFactory.truncate()
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
done: false,
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Reminders'}).click()
|
|
await page.locator('.task-view .columns.details .column button').filter({hasText: 'Add a reminder'}).click()
|
|
await page.locator('.datepicker__quick-select-date').filter({hasText: 'Tomorrow'}).click()
|
|
|
|
const openPopup = page.locator('.reminder-options-popup.is-open')
|
|
await openPopup.locator('button').filter({hasText: 'Confirm'}).click()
|
|
|
|
await expect(page.locator('.reminder-options-popup.is-open')).not.toBeVisible()
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
})
|
|
|
|
test('Allows to set a relative reminder when the task already has a due date', async ({authenticatedPage: page}) => {
|
|
await TaskReminderFactory.truncate()
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
done: false,
|
|
due_date: (new Date()).toISOString(),
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Reminders'}).click()
|
|
await page.locator('.task-view .columns.details .column button').filter({hasText: 'Add a reminder'}).click()
|
|
await expect(page.locator('.datepicker__quick-select-date')).not.toBeVisible()
|
|
// Use .is-open to target the currently open popup
|
|
const openPopup = page.locator('.reminder-options-popup.is-open')
|
|
await expect(openPopup.locator('.card-content')).toContainText('1 day before Due Date')
|
|
await openPopup.locator('.card-content button').filter({hasText: '1 day before Due Date'}).click()
|
|
|
|
await expect(page.locator('.reminder-options-popup.is-open')).not.toBeVisible()
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
})
|
|
|
|
test('Allows to set a relative reminder when the task already has a start date', async ({authenticatedPage: page}) => {
|
|
await TaskReminderFactory.truncate()
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
done: false,
|
|
start_date: (new Date()).toISOString(),
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Reminders'}).click()
|
|
await page.locator('.task-view .columns.details .column button').filter({hasText: 'Add a reminder'}).click()
|
|
await expect(page.locator('.datepicker__quick-select-date')).not.toBeVisible()
|
|
// Use .is-open to target the currently open popup
|
|
const openPopup = page.locator('.reminder-options-popup.is-open')
|
|
await expect(openPopup.locator('.card-content')).toContainText('1 day before Start Date')
|
|
await openPopup.locator('.card-content').filter({hasText: '1 day before Start Date'}).click()
|
|
|
|
await expect(page.locator('.reminder-options-popup.is-open')).not.toBeVisible()
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
})
|
|
|
|
test('Allows to set a custom relative reminder when the task already has a due date', async ({authenticatedPage: page}) => {
|
|
await TaskReminderFactory.truncate()
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
done: false,
|
|
due_date: (new Date()).toISOString(),
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Reminders'}).click()
|
|
await page.locator('.task-view .columns.details .column button').filter({hasText: 'Add a reminder'}).click()
|
|
await expect(page.locator('.datepicker__quick-select-date')).not.toBeVisible()
|
|
// Use .is-open to target the currently open popup
|
|
const openPopup = page.locator('.reminder-options-popup.is-open')
|
|
await openPopup.locator('.option-button').filter({hasText: 'Custom'}).click()
|
|
// Wait for the custom form to appear
|
|
await expect(openPopup.locator('.reminder-period')).toBeVisible()
|
|
await openPopup.locator('.reminder-period input').fill('10')
|
|
await openPopup.locator('.reminder-period select').first().selectOption('days')
|
|
await openPopup.locator('button').filter({hasText: 'Confirm'}).click()
|
|
|
|
await expect(page.locator('.reminder-options-popup.is-open')).not.toBeVisible()
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
})
|
|
|
|
test('Allows to set a fixed reminder when the task already has a due date', async ({authenticatedPage: page}) => {
|
|
await TaskReminderFactory.truncate()
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
done: false,
|
|
due_date: (new Date()).toISOString(),
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Reminders'}).click()
|
|
await page.locator('.task-view .columns.details .column button').filter({hasText: 'Add a reminder'}).click()
|
|
await expect(page.locator('.datepicker__quick-select-date')).not.toBeVisible()
|
|
// Use .is-open to target the currently open popup
|
|
const openPopup = page.locator('.reminder-options-popup.is-open')
|
|
await openPopup.locator('.option-button').filter({hasText: 'Date and time'}).click()
|
|
// Wait for the datepicker to appear within the popup
|
|
await expect(openPopup.locator('.datepicker__quick-select-date').first()).toBeVisible()
|
|
await openPopup.locator('.datepicker__quick-select-date').filter({hasText: 'Tomorrow'}).click()
|
|
|
|
await openPopup.locator('button').filter({hasText: 'Confirm'}).click()
|
|
|
|
await expect(page.locator('.reminder-options-popup.is-open')).not.toBeVisible()
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
})
|
|
|
|
test('Does not auto-save when clicking a date in the absolute reminder picker', async ({authenticatedPage: page}) => {
|
|
await TaskReminderFactory.truncate()
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
done: false,
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Reminders'}).click()
|
|
await page.locator('.task-view .columns.details .column button').filter({hasText: 'Add a reminder'}).click()
|
|
|
|
const openPopup = page.locator('.reminder-options-popup.is-open')
|
|
// Wait for the flatpickr calendar to appear
|
|
await expect(openPopup.locator('.flatpickr-innerContainer')).toBeVisible()
|
|
|
|
// Track whether any task save request fires
|
|
let saveRequestFired = false
|
|
await page.route('**/api/v1/tasks/*', async (route) => {
|
|
if (route.request().method() === 'POST' || route.request().method() === 'PUT') {
|
|
saveRequestFired = true
|
|
}
|
|
await route.continue()
|
|
})
|
|
|
|
// Click a day in the calendar
|
|
await openPopup.locator('.flatpickr-innerContainer .flatpickr-days .flatpickr-day:not(.flatpickr-disabled)').first().click()
|
|
|
|
// Wait a moment to ensure no request fires
|
|
await page.waitForTimeout(1000)
|
|
expect(saveRequestFired).toBe(false)
|
|
|
|
// The popup should still be open
|
|
await expect(openPopup).toBeVisible()
|
|
|
|
// The Confirm button should be visible
|
|
const confirmButton = openPopup.locator('button').filter({hasText: 'Confirm'})
|
|
await expect(confirmButton).toBeVisible()
|
|
|
|
// Now click Confirm — this should trigger the save
|
|
await confirmButton.click()
|
|
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
})
|
|
|
|
test('Shows Confirm button for absolute date reminder when task has no due date', async ({authenticatedPage: page}) => {
|
|
await TaskReminderFactory.truncate()
|
|
// Task with no due_date — defaultRelativeTo will be null
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
done: false,
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Reminders'}).click()
|
|
await page.locator('.task-view .columns.details .column button').filter({hasText: 'Add a reminder'}).click()
|
|
|
|
const openPopup = page.locator('.reminder-options-popup.is-open')
|
|
// When no due date, the absolute date form should show directly
|
|
await expect(openPopup.locator('.flatpickr-innerContainer')).toBeVisible()
|
|
|
|
// The Confirm button must be visible
|
|
await expect(openPopup.locator('button').filter({hasText: 'Confirm'})).toBeVisible()
|
|
})
|
|
|
|
test('Can set a priority for a task', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Priority'}).click()
|
|
await page.locator('.task-view .columns.details .column').filter({hasText: 'Priority'}).locator('.select select').selectOption('Urgent')
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
|
|
await expect(page.locator('.task-view .columns.details .column').filter({hasText: 'Priority'}).locator('.select select')).toHaveValue('4')
|
|
})
|
|
|
|
test('Can set the progress for a task', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Set Progress'}).click()
|
|
await page.locator('.task-view .columns.details .column').filter({hasText: 'Progress'}).locator('.select select').selectOption('50%')
|
|
await expect(page.locator('.global-notification')).toContainText('Success')
|
|
|
|
await page.waitForTimeout(200)
|
|
|
|
await expect(page.locator('.task-view .columns.details .column').filter({hasText: 'Progress'}).locator('.select select')).toBeVisible()
|
|
await expect(page.locator('.task-view .columns.details .column').filter({hasText: 'Progress'}).locator('.select select')).toHaveValue('0.5')
|
|
})
|
|
|
|
test('Can add an attachment to a task', async ({authenticatedPage: page}) => {
|
|
await TaskAttachmentFactory.truncate()
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await uploadAttachmentAndVerify(page, tasks[0].id)
|
|
})
|
|
|
|
test('Can add an attachment to a task and see it appearing on kanban', async ({authenticatedPage: page}) => {
|
|
await TaskAttachmentFactory.truncate()
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
project_id: projects[0].id,
|
|
})
|
|
const labels = await LabelFactory.create(1)
|
|
await LabelTaskFactory.truncate()
|
|
await TaskBucketFactory.create(1, {
|
|
task_id: tasks[0].id,
|
|
bucket_id: buckets[0].id,
|
|
project_view_id: buckets[0].project_view_id,
|
|
})
|
|
|
|
await page.goto(`/projects/${projects[0].id}/4`)
|
|
|
|
await page.locator('.bucket .task').filter({hasText: tasks[0].title}).click()
|
|
|
|
await uploadAttachmentAndVerify(page, tasks[0].id)
|
|
|
|
await page.locator('.modal-container > .close').click()
|
|
|
|
await expect(page.locator('.bucket .task .footer .icon svg.fa-paperclip')).toBeVisible()
|
|
})
|
|
|
|
test('Can check items off a checklist', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
description: `
|
|
<ul data-type="taskList">
|
|
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
|
<div><p>First Item</p></div>
|
|
</li>
|
|
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
|
<div><p>Second Item</p></div>
|
|
</li>
|
|
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
|
<div><p>Third Item</p></div>
|
|
</li>
|
|
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
|
<div><p>Fourth Item</p></div>
|
|
</li>
|
|
<li data-checked="true" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
|
<div><p>Fifth Item</p></div>
|
|
</li>
|
|
</ul>`,
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await expect(page.locator('.task-view .checklist-summary')).toContainText('1 of 5 tasks')
|
|
await page.locator('.tiptap__editor ul > li input[type=checkbox]').nth(2).click()
|
|
|
|
await expect(page.locator('.task-view .details.content.description h3 span.is-small.has-text-success')).toContainText('Saved!')
|
|
await expect(page.locator('.tiptap__editor ul > li input[type=checkbox]').nth(2)).toBeChecked()
|
|
await expect(page.locator('.tiptap__editor input[type=checkbox]')).toHaveCount(5)
|
|
await expect(page.locator('.task-view .checklist-summary')).toContainText('2 of 5 tasks')
|
|
})
|
|
|
|
test('Persists checked checklist items after reload', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
description: `
|
|
<ul data-type="taskList">
|
|
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
|
<div><p>First Item</p></div>
|
|
</li>
|
|
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
|
<div><p>Second Item</p></div>
|
|
</li>
|
|
</ul>`,
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await expect(page.locator('.task-view .checklist-summary')).toContainText('0 of 2 tasks')
|
|
await page.locator('.tiptap__editor ul > li input[type=checkbox]').first().click()
|
|
|
|
await expect(page.locator('.task-view .details.content.description h3 span.is-small.has-text-success')).toContainText('Saved!')
|
|
|
|
await expect(page.locator('.task-view .checklist-summary')).toContainText('1 of 2 tasks')
|
|
|
|
await page.reload()
|
|
|
|
await expect(page.locator('.task-view .checklist-summary')).toContainText('1 of 2 tasks')
|
|
await expect(page.locator('.tiptap__editor ul > li input[type=checkbox]').first()).toBeChecked()
|
|
})
|
|
|
|
test('Loads old checklist data without task IDs and generates unique IDs (backwards compatibility)', async ({authenticatedPage: page}) => {
|
|
// Old checklist HTML format without data-task-id attributes
|
|
// This simulates data saved before the checkbox persistence fix
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
description: `
|
|
<ul data-type="taskList">
|
|
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
|
<div><p>Item One</p></div>
|
|
</li>
|
|
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
|
<div><p>Item Two</p></div>
|
|
</li>
|
|
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
|
<div><p>Item Three</p></div>
|
|
</li>
|
|
</ul>`,
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
// Verify all checkboxes are rendered
|
|
await expect(page.locator('.tiptap__editor ul > li input[type=checkbox]')).toHaveCount(3)
|
|
await expect(page.locator('.task-view .checklist-summary')).toContainText('0 of 3 tasks')
|
|
|
|
// Check that unique IDs were generated for each task item
|
|
const taskIds = await page.evaluate(() => {
|
|
const items = document.querySelectorAll('.tiptap__editor [data-task-id]')
|
|
return Array.from(items).map(el => el.getAttribute('data-task-id'))
|
|
})
|
|
expect(taskIds).toHaveLength(3)
|
|
// All IDs should be unique
|
|
const uniqueIds = new Set(taskIds)
|
|
expect(uniqueIds.size).toBe(3)
|
|
|
|
// Toggle only the second checkbox
|
|
await page.locator('.tiptap__editor ul > li input[type=checkbox]').nth(1).click()
|
|
|
|
await expect(page.locator('.task-view .details.content.description h3 span.is-small.has-text-success')).toContainText('Saved!')
|
|
await expect(page.locator('.task-view .checklist-summary')).toContainText('1 of 3 tasks')
|
|
|
|
// Verify only the second checkbox is checked, not the others
|
|
await expect(page.locator('.tiptap__editor ul > li input[type=checkbox]').nth(0)).not.toBeChecked()
|
|
await expect(page.locator('.tiptap__editor ul > li input[type=checkbox]').nth(1)).toBeChecked()
|
|
await expect(page.locator('.tiptap__editor ul > li input[type=checkbox]').nth(2)).not.toBeChecked()
|
|
|
|
// Reload and verify persistence
|
|
await page.reload()
|
|
|
|
await expect(page.locator('.task-view .checklist-summary')).toContainText('1 of 3 tasks')
|
|
await expect(page.locator('.tiptap__editor ul > li input[type=checkbox]').nth(0)).not.toBeChecked()
|
|
await expect(page.locator('.tiptap__editor ul > li input[type=checkbox]').nth(1)).toBeChecked()
|
|
await expect(page.locator('.tiptap__editor ul > li input[type=checkbox]').nth(2)).not.toBeChecked()
|
|
})
|
|
|
|
test('Should use the editor to render description', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
description: `
|
|
<h1>Lorem Ipsum</h1>
|
|
<p>Dolor sit amet</p>
|
|
<ul data-type="taskList">
|
|
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
|
<div><p>First Item</p></div>
|
|
</li>
|
|
<li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label>
|
|
<div><p>Second Item</p></div>
|
|
</li>
|
|
</ul>`,
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
await expect(page.locator('.tiptap__editor ul > li input[type=checkbox]').first()).toBeVisible()
|
|
await expect(page.locator('.tiptap__editor h1').filter({hasText: 'Lorem Ipsum'})).toBeVisible()
|
|
await expect(page.locator('.tiptap__editor p').filter({hasText: 'Dolor sit amet'})).toBeVisible()
|
|
})
|
|
|
|
test('Should render an image from attachment', async ({authenticatedPage: page, apiContext}) => {
|
|
await TaskAttachmentFactory.truncate()
|
|
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
description: '',
|
|
})
|
|
|
|
const filePath = join(__dirname, '../../fixtures/image.jpg')
|
|
const fileBuffer = readFileSync(filePath)
|
|
|
|
// Navigate to a page first to establish context for localStorage access
|
|
await page.goto('/')
|
|
const token = await page.evaluate(() => localStorage.getItem('token'))
|
|
|
|
// Get the window.API_URL from the page - this is what the TipTap CustomImage extension checks against
|
|
const apiUrl = await page.evaluate(() => window.API_URL)
|
|
|
|
const response = await apiContext.put(`tasks/${tasks[0].id}/attachments`, {
|
|
multipart: {
|
|
files: {
|
|
name: 'image.jpg',
|
|
mimeType: 'image/jpeg',
|
|
buffer: fileBuffer,
|
|
},
|
|
},
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
},
|
|
})
|
|
|
|
const {success} = await response.json()
|
|
|
|
// The URL format MUST match window.API_URL for the CustomImage extension to
|
|
// recognize it as an attachment URL and load it with authentication
|
|
await TaskFactory.create(1, {
|
|
id: 1,
|
|
description: `<img src="${apiUrl}/tasks/${tasks[0].id}/attachments/${success[0].id}" alt="test image">`,
|
|
})
|
|
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
|
|
// Wait for the page to load
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
// Get the description editor (first tiptap editor, not comments)
|
|
const descriptionEditor = page.locator('.tiptap__editor').first()
|
|
const img = descriptionEditor.locator('img')
|
|
await expect(img).toBeVisible()
|
|
|
|
// Wait for the image to be loaded (the editor loads images asynchronously via blob URL)
|
|
await page.waitForFunction(
|
|
() => {
|
|
// Get the first tiptap editor (description)
|
|
const editor = document.querySelector('.tiptap__editor')
|
|
const img = editor?.querySelector('img') as HTMLImageElement
|
|
return img && img.naturalWidth > 0
|
|
},
|
|
{timeout: 10000},
|
|
)
|
|
|
|
const naturalWidth = await img.evaluate((el: HTMLImageElement) => el.naturalWidth)
|
|
expect(naturalWidth).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
test.describe('Scroll to bottom button', () => {
|
|
test('Shows scroll-to-bottom button when content is long and hides when at bottom', async ({authenticatedPage: page}) => {
|
|
// Create a task with a very long description to ensure scrollable content
|
|
const longDescription = `
|
|
<h1>Introduction</h1>
|
|
<p>This is a very long description to test the scroll-to-bottom button functionality.</p>
|
|
${Array(30).fill('<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.</p>').join('\n')}
|
|
<h2>Conclusion</h2>
|
|
<p>End of the long description.</p>
|
|
`
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
description: longDescription,
|
|
})
|
|
|
|
// Set viewport to ensure content is scrollable
|
|
await page.setViewportSize({width: 1280, height: 800})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
// Scroll to top and wait for scroll to complete
|
|
await page.evaluate(() => window.scrollTo(0, 0))
|
|
await page.waitForFunction(() => window.scrollY <= 5)
|
|
|
|
// The scroll-to-bottom button should be visible when not at bottom
|
|
const scrollButton = page.locator('.scroll-to-comments-button')
|
|
await expect(scrollButton).toBeVisible({timeout: 5000})
|
|
|
|
// Click the button to scroll to bottom
|
|
await scrollButton.click()
|
|
|
|
// Wait for the bottom marker to be in or near the viewport (within 50px tolerance)
|
|
const bottomMarker = page.locator('.content-bottom-marker')
|
|
await expect(async () => {
|
|
const markerTop = await bottomMarker.evaluate((el) => el.getBoundingClientRect().top)
|
|
const viewportHeight = await page.evaluate(() => window.innerHeight)
|
|
expect(markerTop).toBeLessThanOrEqual(viewportHeight + 50)
|
|
}).toPass({timeout: 5000})
|
|
|
|
// The button should be hidden when at the bottom
|
|
await expect(scrollButton).not.toBeVisible({timeout: 5000})
|
|
})
|
|
|
|
test('Shows scroll-to-bottom button with long comments', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
description: 'Short description',
|
|
})
|
|
|
|
// Create a long comment to ensure scrollable content
|
|
const longComment = `
|
|
# Code Review Summary
|
|
|
|
This is a very long comment that should make the page scrollable.
|
|
|
|
## Changes Overview
|
|
|
|
${Array(20).fill('- Lorem ipsum dolor sit amet, consectetur adipiscing elit').join('\n')}
|
|
|
|
## Detailed Analysis
|
|
|
|
${Array(10).fill('The implementation looks good overall. Here are some specific points to consider:\n\n1. Performance implications\n2. Security considerations\n3. Code maintainability\n\n').join('\n')}
|
|
|
|
## Conclusion
|
|
|
|
Everything looks good!
|
|
`
|
|
await TaskCommentFactory.create(1, {
|
|
task_id: tasks[0].id,
|
|
comment: longComment,
|
|
})
|
|
|
|
// Set viewport to ensure content is scrollable
|
|
await page.setViewportSize({width: 1280, height: 800})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
// Scroll to top and wait for scroll to complete
|
|
await page.evaluate(() => window.scrollTo(0, 0))
|
|
await page.waitForFunction(() => window.scrollY <= 5)
|
|
|
|
// The scroll-to-bottom button should be visible
|
|
const scrollButton = page.locator('.scroll-to-comments-button')
|
|
await expect(scrollButton).toBeVisible({timeout: 5000})
|
|
|
|
// Click the button to scroll to bottom
|
|
await scrollButton.click()
|
|
|
|
// Wait for the bottom marker to be in or near the viewport (within 50px tolerance)
|
|
const bottomMarker = page.locator('.content-bottom-marker')
|
|
await expect(async () => {
|
|
const markerTop = await bottomMarker.evaluate((el) => el.getBoundingClientRect().top)
|
|
const viewportHeight = await page.evaluate(() => window.innerHeight)
|
|
expect(markerTop).toBeLessThanOrEqual(viewportHeight + 50)
|
|
}).toPass({timeout: 5000})
|
|
|
|
// The button should be hidden when at the bottom
|
|
await expect(scrollButton).not.toBeVisible({timeout: 5000})
|
|
})
|
|
|
|
test('Does not show scroll-to-bottom button when already at bottom', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
description: 'Short description',
|
|
})
|
|
|
|
// Set viewport
|
|
await page.setViewportSize({width: 1280, height: 800})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
// Scroll to bottom of page and wait for scroll to complete
|
|
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
|
|
await page.waitForFunction(() => {
|
|
const scrollTop = window.scrollY || document.documentElement.scrollTop
|
|
const scrollHeight = document.documentElement.scrollHeight
|
|
const clientHeight = document.documentElement.clientHeight
|
|
return scrollTop + clientHeight >= scrollHeight - 5
|
|
})
|
|
|
|
// The scroll-to-bottom button should not be visible when already at bottom
|
|
const scrollButton = page.locator('.scroll-to-comments-button')
|
|
await expect(scrollButton).not.toBeVisible({timeout: 3000})
|
|
})
|
|
|
|
test('Does not show scroll-to-bottom button on mobile', async ({authenticatedPage: page}) => {
|
|
// Create a task with long content
|
|
const longDescription = Array(30).fill('<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>').join('\n')
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
description: longDescription,
|
|
})
|
|
|
|
// Set mobile viewport
|
|
await page.setViewportSize({width: 375, height: 667})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
// Scroll to top and wait for scroll to complete
|
|
await page.evaluate(() => window.scrollTo(0, 0))
|
|
await page.waitForFunction(() => window.scrollY <= 5)
|
|
|
|
// The scroll-to-bottom button should be hidden on mobile (CSS hides it)
|
|
const scrollButton = page.locator('.scroll-to-comments-button')
|
|
await expect(scrollButton).not.toBeVisible({timeout: 3000})
|
|
})
|
|
})
|
|
|
|
test.describe('Link functionality in description editor', () => {
|
|
test('Should show URL input when clicking link button without scroll', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
description: 'Test text for link',
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
// Click edit button to open editor
|
|
const editButton = page.locator('.task-view .details.content.description .tiptap button.done-edit')
|
|
await expect(editButton).toBeVisible({timeout: 10000})
|
|
await editButton.click()
|
|
|
|
// Wait for editor to be visible
|
|
const editor = page.locator('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror')
|
|
await expect(editor).toBeVisible()
|
|
|
|
// Select text by triple-clicking
|
|
await editor.click({clickCount: 3})
|
|
await page.waitForTimeout(200)
|
|
|
|
// Wait for bubble menu to appear and click Link button (6th button - chain icon)
|
|
const bubbleMenu = page.locator('.editor-bubble__wrapper')
|
|
await expect(bubbleMenu).toBeVisible({timeout: 5000})
|
|
const linkButton = bubbleMenu.locator('button').nth(5)
|
|
await linkButton.click()
|
|
|
|
// Verify URL input popup appears
|
|
const urlInput = page.locator('input[placeholder="URL"]')
|
|
await expect(urlInput).toBeVisible({timeout: 2000})
|
|
|
|
// Verify input is positioned near the toolbar button (not at top/bottom of viewport)
|
|
const urlInputBox = await urlInput.boundingBox()
|
|
const linkButtonBox = await linkButton.boundingBox()
|
|
expect(urlInputBox).not.toBeNull()
|
|
expect(linkButtonBox).not.toBeNull()
|
|
|
|
// URL input should be near the link button (within 200px vertically)
|
|
const verticalDistance = Math.abs(urlInputBox!.y - linkButtonBox!.y)
|
|
expect(verticalDistance).toBeLessThan(200)
|
|
})
|
|
|
|
test('Should position URL input correctly when page is scrolled (issue #1899)', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
description: 'Test text for link',
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
// Scroll the page down
|
|
await page.evaluate(() => window.scrollBy(0, 500))
|
|
await page.waitForTimeout(100)
|
|
|
|
// Click edit button to open editor
|
|
const editButton = page.locator('.task-view .details.content.description .tiptap button.done-edit')
|
|
await expect(editButton).toBeVisible({timeout: 10000})
|
|
await editButton.click()
|
|
|
|
// Wait for editor to be visible
|
|
const editor = page.locator('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror')
|
|
await expect(editor).toBeVisible()
|
|
|
|
// Select text by triple-clicking
|
|
await editor.click({clickCount: 3})
|
|
await page.waitForTimeout(200)
|
|
|
|
// Wait for bubble menu and click Link button
|
|
const bubbleMenu = page.locator('.editor-bubble__wrapper')
|
|
await expect(bubbleMenu).toBeVisible({timeout: 5000})
|
|
const linkButton = bubbleMenu.locator('button').nth(5)
|
|
await linkButton.click()
|
|
|
|
// Verify URL input popup appears and is positioned correctly (not off-screen)
|
|
const urlInput = page.locator('input[placeholder="URL"]')
|
|
await expect(urlInput).toBeVisible({timeout: 2000})
|
|
|
|
// Verify input is positioned near the toolbar button
|
|
const urlInputBox = await urlInput.boundingBox()
|
|
const linkButtonBox = await linkButton.boundingBox()
|
|
expect(urlInputBox).not.toBeNull()
|
|
expect(linkButtonBox).not.toBeNull()
|
|
|
|
// URL input should be near the link button even after scroll
|
|
const verticalDistance = Math.abs(urlInputBox!.y - linkButtonBox!.y)
|
|
expect(verticalDistance).toBeLessThan(200)
|
|
|
|
// Verify URL input is visible in viewport (not off-screen at top)
|
|
const viewportHeight = page.viewportSize()!.height
|
|
expect(urlInputBox!.y).toBeGreaterThan(0)
|
|
expect(urlInputBox!.y).toBeLessThan(viewportHeight)
|
|
})
|
|
|
|
test('Should follow scroll when URL input is open', async ({authenticatedPage: page}) => {
|
|
const tasks = await TaskFactory.create(1, {
|
|
id: 1,
|
|
description: 'Test text for link',
|
|
})
|
|
await page.goto(`/tasks/${tasks[0].id}`)
|
|
await page.waitForLoadState('networkidle')
|
|
|
|
// Click edit button to open editor
|
|
const editButton = page.locator('.task-view .details.content.description .tiptap button.done-edit')
|
|
await expect(editButton).toBeVisible({timeout: 10000})
|
|
await editButton.click()
|
|
|
|
// Wait for editor and select text
|
|
const editor = page.locator('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror')
|
|
await expect(editor).toBeVisible()
|
|
await editor.click({clickCount: 3})
|
|
await page.waitForTimeout(200)
|
|
|
|
// Click Link button to open URL input
|
|
const bubbleMenu = page.locator('.editor-bubble__wrapper')
|
|
await expect(bubbleMenu).toBeVisible({timeout: 5000})
|
|
const linkButton = bubbleMenu.locator('button').nth(5)
|
|
await linkButton.click()
|
|
|
|
// Verify URL input is visible
|
|
const urlInput = page.locator('input[placeholder="URL"]')
|
|
await expect(urlInput).toBeVisible({timeout: 2000})
|
|
|
|
// Get initial position
|
|
const initialBox = await urlInput.boundingBox()
|
|
expect(initialBox).not.toBeNull()
|
|
|
|
// Scroll down while URL input is open
|
|
await page.evaluate(() => window.scrollBy(0, 300))
|
|
await page.waitForTimeout(400)
|
|
|
|
// Get new position after scroll
|
|
const afterScrollBox = await urlInput.boundingBox()
|
|
expect(afterScrollBox).not.toBeNull()
|
|
|
|
// URL input should have moved with the scroll (Y position should change)
|
|
// The input should follow the content, so its position relative to viewport should adjust
|
|
const positionChanged = Math.abs(afterScrollBox!.y - initialBox!.y) > 50
|
|
expect(positionChanged).toBe(true)
|
|
|
|
// Verify input is still near the link button after scroll
|
|
const linkButtonBox = await linkButton.boundingBox()
|
|
expect(linkButtonBox).not.toBeNull()
|
|
const verticalDistance = Math.abs(afterScrollBox!.y - linkButtonBox!.y)
|
|
expect(verticalDistance).toBeLessThan(200)
|
|
})
|
|
})
|
|
})
|