mirror of
https://github.com/go-gitea/gitea.git
synced 2026-11-09 17:37:20 -06:00
The board reported every card of the target column on each drag, and the server wrote all of them into that column. A stale board in another browser therefore pulled cards moved by someone else back to their old column. The client now sends only the dragged issue and its predecessor, and the server computes the position in one transaction: - Only the dragged issue changes column, the others are just renumbered. - A predecessor that is no longer in the column puts the card at the end. Added an e2e test. Fixes https://github.com/go-gitea/gitea/issues/21243 Fixes https://github.com/go-gitea/gitea/issues/32070 Fixes https://github.com/go-gitea/gitea/issues/13498 --- Assisted-by: Codet:claude --------- Co-authored-by: silverwind <me@silverwind.io>
485 lines
19 KiB
TypeScript
485 lines
19 KiB
TypeScript
import {env} from 'node:process';
|
|
import {test, expect} from '@playwright/test';
|
|
import {
|
|
login, apiCreateRepo, apiCreateIssue, apiCreateProject, apiDeleteRepo, createProject, createProjectColumn, randomString,
|
|
} from './utils.ts';
|
|
import type {Page} from '@playwright/test';
|
|
|
|
const projectCards = (page: Page, column: string) => page.locator('.project-column', {hasText: column}).locator('.cards');
|
|
|
|
async function dragProjectCardToTop(page: Page, title: string, column: string) {
|
|
const card = page.locator('.issue-card', {hasText: title});
|
|
const cards = projectCards(page, column);
|
|
const moved = page.waitForResponse((resp) => resp.url().endsWith('/move'));
|
|
await card.hover();
|
|
await page.mouse.down();
|
|
await card.hover({position: {x: 10, y: 10}});
|
|
await expect(card).toHaveClass(/card-ghost/); // sortable ignores dragover until its deferred drag start ran
|
|
await cards.hover({position: {x: 10, y: 10}});
|
|
await expect(cards.locator('.issue-card').first()).toContainText(title);
|
|
await page.mouse.up();
|
|
await moved;
|
|
}
|
|
|
|
test('assign issue to project and change column', async ({page}) => {
|
|
const repoName = `e2e-issue-project-${randomString(8)}`;
|
|
const user = env.GITEA_TEST_E2E_USER;
|
|
await Promise.all([login(page), apiCreateRepo(page.request, {name: repoName, autoInit: false})]);
|
|
await page.goto(`/${user}/${repoName}/projects/new`);
|
|
await page.locator('input[name="title"]').fill('Kanban Board');
|
|
await page.getByRole('button', {name: 'Create Project'}).click();
|
|
const projectLink = page.locator('.milestone-list a', {hasText: 'Kanban Board'}).first();
|
|
await expect(projectLink).toBeVisible();
|
|
const href = await projectLink.getAttribute('href');
|
|
const projectID = href!.split('/').pop()!;
|
|
// columns created via POST because the web UI uses modals that are hard to drive
|
|
await Promise.all([
|
|
...['Backlog', 'In Progress', 'Done'].map((title) => createProjectColumn(page.request, user, repoName, projectID, title)),
|
|
apiCreateIssue(page.request, {owner: user, repo: repoName, title: 'Column picker test'}),
|
|
]);
|
|
await page.goto(`/${user}/${repoName}/issues/1`);
|
|
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
|
await page.locator('.sidebar-project-combo > .ui.dropdown .item:has-text("Kanban Board")').click();
|
|
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
|
await page.locator('.sidebar-project-column-combo .ui.dropdown').click();
|
|
await page.locator('.sidebar-project-column-combo .ui.dropdown .item:has-text("In Progress")').click();
|
|
await expect(page.locator('.sidebar-project-column-combo .ui.dropdown .fixed-text')).toHaveText('In Progress');
|
|
await apiDeleteRepo(page.request, user, repoName);
|
|
});
|
|
|
|
test('create a project', async ({page}) => {
|
|
const repoName = `e2e-project-repo-${Date.now()}`;
|
|
const projectTitle = 'Test Project';
|
|
|
|
await login(page);
|
|
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
|
|
|
|
try {
|
|
// Navigate to new project page
|
|
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects/new`);
|
|
|
|
// Fill in project details
|
|
await page.getByLabel('Title').fill(projectTitle);
|
|
|
|
// Submit the form
|
|
await page.getByRole('button', {name: 'Create Project'}).click();
|
|
|
|
// Verify project was created by checking we're redirected to the projects list
|
|
await expect(page).toHaveURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects$`));
|
|
|
|
// Verify the project appears in the list
|
|
await expect(page.locator('.milestone-list')).toContainText(projectTitle);
|
|
} finally {
|
|
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
|
}
|
|
});
|
|
|
|
test('assign issue to multiple projects via sidebar', async ({page}) => {
|
|
const repoName = `e2e-multi-project-${Date.now()}`;
|
|
const project1Title = 'Project Alpha';
|
|
const project2Title = 'Project Beta';
|
|
const issueTitle = 'Test issue for multiple projects';
|
|
|
|
await login(page);
|
|
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
|
|
|
|
try {
|
|
// Create two projects via UI
|
|
const project1 = await createProject(page, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: project1Title,
|
|
});
|
|
const project2 = await createProject(page, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: project2Title,
|
|
});
|
|
|
|
// Create an issue without any project
|
|
const issue = await apiCreateIssue(page.request, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: issueTitle,
|
|
});
|
|
|
|
// Navigate to the issue page
|
|
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/${issue.index}`);
|
|
|
|
// Open the projects dropdown in the sidebar
|
|
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
|
|
|
// Select both projects
|
|
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project1.id}"]`).click();
|
|
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
|
|
|
|
// Click outside to close the dropdown and trigger the update
|
|
await page.locator('.issue-content-left').click();
|
|
|
|
// Verify both projects are shown in the sidebar
|
|
await expect(page.locator(`.item.sidebar-project-card:has-text("${project1Title}")`)).toBeVisible();
|
|
await expect(page.locator(`.item.sidebar-project-card:has-text("${project2Title}")`)).toBeVisible();
|
|
} finally {
|
|
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
|
}
|
|
});
|
|
|
|
test('create issue with multiple projects pre-selected', async ({page}) => {
|
|
const repoName = `e2e-issue-multi-proj-${Date.now()}`;
|
|
const project1Title = 'Project One';
|
|
const project2Title = 'Project Two';
|
|
const issueTitle = 'Issue with multiple projects';
|
|
|
|
await login(page);
|
|
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
|
|
|
|
try {
|
|
// Create two projects via UI
|
|
const project1 = await createProject(page, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: project1Title,
|
|
});
|
|
const project2 = await createProject(page, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: project2Title,
|
|
});
|
|
|
|
// Navigate to new issue page
|
|
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/new`);
|
|
|
|
// Fill in the issue title
|
|
await page.locator('input[name="title"]').fill(issueTitle);
|
|
|
|
// Open the projects dropdown
|
|
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
|
|
|
// Select both projects
|
|
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project1.id}"]`).click();
|
|
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
|
|
|
|
// Click outside to close the dropdown
|
|
await page.locator('.issue-content-left').click();
|
|
|
|
// Submit the form
|
|
await page.getByRole('button', {name: 'Create Issue'}).click();
|
|
|
|
// Wait for issue to be created and page to redirect
|
|
await page.waitForURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/\\d+`));
|
|
|
|
// Verify both projects are shown in the sidebar
|
|
await expect(page.locator(`.item.sidebar-project-card:has-text("${project1Title}")`)).toBeVisible();
|
|
await expect(page.locator(`.item.sidebar-project-card:has-text("${project2Title}")`)).toBeVisible();
|
|
} finally {
|
|
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
|
}
|
|
});
|
|
|
|
test('filter issues by multiple projects in issue list', async ({page}) => {
|
|
const repoName = `e2e-filter-projects-${Date.now()}`;
|
|
const project1Title = 'Filter Project A';
|
|
const project2Title = 'Filter Project B';
|
|
|
|
await login(page);
|
|
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
|
|
|
|
try {
|
|
// Create two projects via UI
|
|
const project1 = await createProject(page, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: project1Title,
|
|
});
|
|
const project2 = await createProject(page, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: project2Title,
|
|
});
|
|
|
|
// Create issues: one in project1, one in project2, one in both
|
|
await apiCreateIssue(page.request, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: 'Issue in Project A only',
|
|
projects: [project1.id],
|
|
});
|
|
await apiCreateIssue(page.request, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: 'Issue in Project B only',
|
|
projects: [project2.id],
|
|
});
|
|
await apiCreateIssue(page.request, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: 'Issue in both projects',
|
|
projects: [project1.id, project2.id],
|
|
});
|
|
// Create an issue with no project
|
|
await apiCreateIssue(page.request, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: 'Issue with no project',
|
|
});
|
|
|
|
// Verify only project1 issues are visible
|
|
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?project=${project1.id}`);
|
|
await expect(page.locator('#issue-list')).toContainText('Issue in Project A only');
|
|
await expect(page.locator('#issue-list')).toContainText('Issue in both projects');
|
|
await expect(page.locator('#issue-list')).not.toContainText('Issue in Project B only');
|
|
await expect(page.locator('#issue-list')).not.toContainText('Issue with no project');
|
|
|
|
// Verify only project2 issues are visible
|
|
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?project=${project2.id}`);
|
|
await expect(page.locator('#issue-list')).toContainText('Issue in Project B only');
|
|
await expect(page.locator('#issue-list')).toContainText('Issue in both projects');
|
|
await expect(page.locator('#issue-list')).not.toContainText('Issue in Project A only');
|
|
await expect(page.locator('#issue-list')).not.toContainText('Issue with no project');
|
|
} finally {
|
|
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
|
}
|
|
});
|
|
|
|
test('remove issue from one project keeping others', async ({page}) => {
|
|
const repoName = `e2e-remove-project-${Date.now()}`;
|
|
const project1Title = 'Keep This Project';
|
|
const project2Title = 'Remove This Project';
|
|
const issueTitle = 'Issue to modify projects';
|
|
|
|
await login(page);
|
|
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
|
|
|
|
try {
|
|
// Create two projects via UI
|
|
const project1 = await createProject(page, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: project1Title,
|
|
});
|
|
const project2 = await createProject(page, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: project2Title,
|
|
});
|
|
|
|
// Create an issue in both projects
|
|
const issue = await apiCreateIssue(page.request, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: issueTitle,
|
|
projects: [project1.id, project2.id],
|
|
});
|
|
|
|
// Navigate to the issue page
|
|
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/${issue.index}`);
|
|
|
|
// Verify both projects are initially shown
|
|
await expect(page.locator(`.item.sidebar-project-card:has-text("${project1Title}")`)).toBeVisible();
|
|
await expect(page.locator(`.item.sidebar-project-card.item:has-text("${project2Title}")`)).toBeVisible();
|
|
|
|
// Open the projects dropdown
|
|
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
|
|
|
// Deselect project2 (click on the already selected item to deselect)
|
|
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
|
|
|
|
// Click outside to close the dropdown and trigger the update
|
|
await page.locator('.issue-content-left').click();
|
|
|
|
// Verify project1 is still shown but project2 is removed
|
|
await expect(page.locator(`.item.sidebar-project-card.item:has-text("${project1Title}")`)).toBeVisible();
|
|
await expect(page.locator(`.item.sidebar-project-card.item:has-text("${project2Title}")`)).toBeHidden();
|
|
|
|
// Reload the page to see the timeline comment
|
|
await page.reload();
|
|
|
|
// Verify the timeline shows "removed this from the project" comment
|
|
const timelineComments = page.locator('.timeline-item.event');
|
|
await expect(timelineComments.filter({hasText: 'removed this from the'})).toBeVisible();
|
|
} finally {
|
|
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
|
}
|
|
});
|
|
|
|
test('filter issues with no project using project=-1', async ({page}) => {
|
|
const repoName = `e2e-no-project-filter-${Date.now()}`;
|
|
const projectTitle = 'Some Project';
|
|
|
|
await login(page);
|
|
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
|
|
|
|
try {
|
|
// Create a project via UI
|
|
const project = await createProject(page, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: projectTitle,
|
|
});
|
|
|
|
// Create an issue with a project
|
|
await apiCreateIssue(page.request, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: 'Issue with project assigned',
|
|
projects: [project.id],
|
|
});
|
|
|
|
// Create issues with no project
|
|
await apiCreateIssue(page.request, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: 'Issue without any project',
|
|
});
|
|
await apiCreateIssue(page.request, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: 'Another unassigned issue',
|
|
});
|
|
|
|
// First verify we can see all issues without the filter
|
|
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?type=all&state=open`);
|
|
await expect(page.locator('#issue-list')).toContainText('Issue with project assigned');
|
|
await expect(page.locator('#issue-list')).toContainText('Issue without any project');
|
|
await expect(page.locator('#issue-list')).toContainText('Another unassigned issue');
|
|
|
|
// Navigate to issue list filtering for issues with no project (project=-1)
|
|
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues?type=all&state=open&project=-1`);
|
|
|
|
// Verify only issues with no project are visible
|
|
await expect(page.locator('#issue-list')).toContainText('Issue without any project');
|
|
await expect(page.locator('#issue-list')).toContainText('Another unassigned issue');
|
|
|
|
// Verify the issue with a project is NOT visible
|
|
await expect(page.locator('#issue-list')).not.toContainText('Issue with project assigned');
|
|
|
|
// Verify the last item in the list is NOT the issue with a project
|
|
const issueItems = page.locator('#issue-list .item');
|
|
const lastIssueItem = issueItems.last();
|
|
await expect(lastIssueItem).not.toContainText('Issue with project assigned');
|
|
} finally {
|
|
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
|
}
|
|
});
|
|
|
|
test('close project and view in closed projects list', async ({page}) => {
|
|
const repoName = `e2e-close-project-${Date.now()}`;
|
|
const openProjectTitle = 'Open Project';
|
|
const closedProjectTitle = 'Project To Close';
|
|
|
|
await login(page);
|
|
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
|
|
|
|
try {
|
|
// Create two projects via UI
|
|
await createProject(page, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: openProjectTitle,
|
|
});
|
|
const projectToClose = await createProject(page, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: closedProjectTitle,
|
|
});
|
|
|
|
// Navigate to projects list
|
|
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects`);
|
|
|
|
// Verify both projects are visible in open state
|
|
await expect(page.locator('.milestone-list')).toContainText(openProjectTitle);
|
|
await expect(page.locator('.milestone-list')).toContainText(closedProjectTitle);
|
|
|
|
// Close the second project by clicking the close link
|
|
const projectCard = page.locator('.milestone-list > .item').filter({hasText: closedProjectTitle});
|
|
await projectCard.locator('a.link-action[data-url$="/close"]').click();
|
|
|
|
// Wait for redirect back to project view page
|
|
await page.waitForURL(new RegExp(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects/${projectToClose.id}`));
|
|
|
|
// Navigate to projects list
|
|
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/projects`);
|
|
|
|
// Click on "Closed" tab to view closed projects
|
|
await page.locator('.list-header-toggle a.item').filter({hasText: 'Closed'}).click();
|
|
|
|
// Wait for the page to load with closed projects
|
|
await page.waitForURL(/state=closed/);
|
|
|
|
// Verify only the closed project is visible
|
|
await expect(page.locator('.milestone-list')).toContainText(closedProjectTitle);
|
|
await expect(page.locator('.milestone-list')).not.toContainText(openProjectTitle);
|
|
|
|
// Verify the "Closed" tab is active
|
|
await expect(page.locator('.list-header-toggle a.item.active')).toContainText('Closed');
|
|
} finally {
|
|
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
|
}
|
|
});
|
|
|
|
test('select projects on new issue page shows in sidebar', async ({page}) => {
|
|
const repoName = `e2e-new-issue-project-${Date.now()}`;
|
|
const project1Title = 'Project One';
|
|
const project2Title = 'Project Two';
|
|
|
|
await login(page);
|
|
await apiCreateRepo(page.request, {name: repoName, autoInit: false});
|
|
|
|
try {
|
|
// Create two projects
|
|
const project1 = await createProject(page, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: project1Title,
|
|
});
|
|
const project2 = await createProject(page, {
|
|
owner: env.GITEA_TEST_E2E_USER,
|
|
repo: repoName,
|
|
title: project2Title,
|
|
});
|
|
|
|
// Navigate to new issue page
|
|
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/issues/new`);
|
|
|
|
// Open the projects dropdown in the sidebar
|
|
await page.locator('.sidebar-project-combo > .ui.dropdown').click();
|
|
|
|
// Select both projects
|
|
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project1.id}"]`).click();
|
|
await page.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project2.id}"]`).click();
|
|
|
|
// Click outside to close dropdown
|
|
await page.locator('.issue-content-left').click();
|
|
|
|
// Verify both projects appear in the sidebar list below the dropdown
|
|
// On new issue page, these are simple cloned items rendered in the list container
|
|
const projectList = page.locator('.sidebar-project-combo > .ui.list');
|
|
await expect(projectList.locator(`.item:has-text("${project1Title}")`).first()).toBeVisible();
|
|
await expect(projectList.locator(`.item:has-text("${project2Title}")`).first()).toBeVisible();
|
|
} finally {
|
|
await apiDeleteRepo(page.request, env.GITEA_TEST_E2E_USER, repoName);
|
|
}
|
|
});
|
|
|
|
test('project board drag keeps a card moved in another tab', async ({page}) => {
|
|
const owner = env.GITEA_TEST_E2E_USER;
|
|
const repo = `e2e-project-board-${randomString(8)}`;
|
|
const [otherPage] = await Promise.all([
|
|
page.context().newPage(), login(page), apiCreateRepo(page.request, {name: repo, autoInit: false}),
|
|
]);
|
|
try {
|
|
const projectID = await apiCreateProject(page.request, owner, repo, 'Board', {templateType: 'basic_kanban'});
|
|
for (const title of ['first', 'second']) await apiCreateIssue(page.request, {owner, repo, title, projects: [projectID]});
|
|
await Promise.all([page, otherPage].map((board) => board.goto(`/${owner}/${repo}/projects/${projectID}`)));
|
|
|
|
await dragProjectCardToTop(otherPage, 'first', 'To Do');
|
|
await dragProjectCardToTop(page, 'second', 'Backlog');
|
|
|
|
await page.reload();
|
|
await expect(projectCards(page, 'Backlog').locator('.issue-card')).toHaveText([/second/]);
|
|
await expect(projectCards(page, 'To Do').locator('.issue-card')).toHaveText([/first/]);
|
|
} finally {
|
|
await apiDeleteRepo(page.request, owner, repo);
|
|
}
|
|
});
|