mirror of https://github.com/go-gitea/gitea.git
Add project column picker to issue and pull request sidebar (#37037)
Why? You are working on a ticket, it's ready to be moved to the QA column in your project. Currently you have to go to the project, find the issue card, then move it. With this change you can move the issue's column on the issue page. When an issue or pull request belongs to a project board, a dropdown appears in the sidebar to move it between columns without opening the board view. Read-only users see the current column name instead. * Fix #13520 * Replace #30617 This was written using Claude Code and Opus. Closed: <img width="1346" height="507" alt="image" src="https://github.com/user-attachments/assets/7c1ea7ee-b71c-40af-bb14-aeb1d2beff73" /> Open: <img width="1315" height="577" alt="image" src="https://github.com/user-attachments/assets/4d64b065-44c2-42c7-8d20-84b5caea589a" /> --------- Signed-off-by: silverwind <me@silverwind.io> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Nicolas <bircni@icloud.com> Co-authored-by: Cursor <cursor@cursor.com>pull/37236/merge
parent
6ed861589a
commit
2f5b5a9e9c
|
|
@ -7,5 +7,6 @@
|
|||
- Never force-push, amend, or squash unless asked. Use new commits and normal push for pull request updates
|
||||
- Preserve existing code comments, do not remove or rewrite comments that are still relevant
|
||||
- In TypeScript, use `!` (non-null assertion) instead of `?.`/`??` when a value is known to always exist
|
||||
- For CSS layout, prefer `flex-*` helpers over per-child `tw-ml-*` / `tw-mr-*` margins; fall back to `tw-*` utilities when specificity requires `!important`
|
||||
- Include authorship attribution in issue and pull request comments
|
||||
- Add `Co-Authored-By` lines to all commits, indicating name and model used
|
||||
|
|
|
|||
|
|
@ -592,6 +592,17 @@ func GetIssueByID(ctx context.Context, id int64) (*Issue, error) {
|
|||
return issue, nil
|
||||
}
|
||||
|
||||
func GetIssueByRepoID(ctx context.Context, repoID, issueID int64) (*Issue, error) {
|
||||
issue := new(Issue)
|
||||
has, err := db.GetEngine(ctx).ID(issueID).Where("repo_id=?", repoID).Get(issue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, ErrIssueNotExist{issueID, repoID, 0}
|
||||
}
|
||||
return issue, nil
|
||||
}
|
||||
|
||||
// GetIssuesByIDs return issues with the given IDs.
|
||||
// If keepOrder is true, the order of the returned issues will be the same as the given IDs.
|
||||
func GetIssuesByIDs(ctx context.Context, issueIDs []int64, keepOrder ...bool) (IssueList, error) {
|
||||
|
|
|
|||
|
|
@ -115,17 +115,10 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo
|
|||
panic("newColumnID must not be zero") // shouldn't happen
|
||||
}
|
||||
|
||||
res := struct {
|
||||
MaxSorting int64
|
||||
IssueCount int64
|
||||
}{}
|
||||
if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").Table("project_issue").
|
||||
Where("project_id=?", newProjectID).
|
||||
And("project_board_id=?", newColumnID).
|
||||
Get(&res); err != nil {
|
||||
newSorting, err := project_model.GetColumnIssueNextSorting(ctx, newProjectID, newColumnID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)
|
||||
return db.Insert(ctx, &project_model.ProjectIssue{
|
||||
IssueID: issue.ID,
|
||||
ProjectID: newProjectID,
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ func deleteColumnByID(ctx context.Context, columnID int64) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if err = column.moveIssuesToAnotherColumn(ctx, defaultColumn); err != nil {
|
||||
if err = moveIssuesToAnotherColumn(ctx, column, defaultColumn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ func Test_moveIssuesToAnotherColumn(t *testing.T) {
|
|||
assert.Len(t, issues, 1)
|
||||
assert.EqualValues(t, 3, issues[0].ID)
|
||||
|
||||
err = column1.moveIssuesToAnotherColumn(t.Context(), column2)
|
||||
err = moveIssuesToAnotherColumn(t.Context(), column1, column2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
issues, err = column1.GetIssues(t.Context())
|
||||
|
|
|
|||
|
|
@ -33,38 +33,45 @@ func deleteProjectIssuesByProjectID(ctx context.Context, projectID int64) error
|
|||
return err
|
||||
}
|
||||
|
||||
func (c *Column) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Column) error {
|
||||
if c.ProjectID != newColumn.ProjectID {
|
||||
return errors.New("columns have to be in the same project")
|
||||
}
|
||||
|
||||
if c.ID == newColumn.ID {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetColumnIssueNextSorting returns the sorting value to append an issue at the end of the column.
|
||||
func GetColumnIssueNextSorting(ctx context.Context, projectID, columnID int64) (int64, error) {
|
||||
res := struct {
|
||||
MaxSorting int64
|
||||
IssueCount int64
|
||||
}{}
|
||||
if _, err := db.GetEngine(ctx).Select("max(sorting) as max_sorting, count(*) as issue_count").
|
||||
if _, err := db.GetEngine(ctx).Select("max(sorting) AS max_sorting, count(*) AS issue_count").
|
||||
Table("project_issue").
|
||||
Where("project_id=?", newColumn.ProjectID).
|
||||
And("project_board_id=?", newColumn.ID).
|
||||
Where("project_id=?", projectID).
|
||||
And("project_board_id=?", columnID).
|
||||
Get(&res); err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
return util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0), nil
|
||||
}
|
||||
|
||||
issues, err := c.GetIssues(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
func moveIssuesToAnotherColumn(ctx context.Context, oldColumn, newColumn *Column) error {
|
||||
if oldColumn.ProjectID != newColumn.ProjectID {
|
||||
return errors.New("columns have to be in the same project")
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
|
||||
if oldColumn.ID == newColumn.ID {
|
||||
return nil
|
||||
}
|
||||
|
||||
nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)
|
||||
movedIssues, err := oldColumn.GetIssues(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(movedIssues) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
nextSorting, err := GetColumnIssueNextSorting(ctx, newColumn.ProjectID, newColumn.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.WithTx(ctx, func(ctx context.Context) error {
|
||||
for i, issue := range issues {
|
||||
for i, issue := range movedIssues {
|
||||
issue.ProjectColumnID = newColumn.ID
|
||||
issue.Sorting = nextSorting + int64(i)
|
||||
if _, err := db.GetEngine(ctx).ID(issue.ID).Cols("project_board_id", "sorting").Update(issue); err != nil {
|
||||
|
|
|
|||
|
|
@ -1407,11 +1407,12 @@
|
|||
"repo.issues.new": "New Issue",
|
||||
"repo.issues.new.title_empty": "Title cannot be empty",
|
||||
"repo.issues.new.labels": "Labels",
|
||||
"repo.issues.new.no_label": "No Label",
|
||||
"repo.issues.new.no_labels": "No labels",
|
||||
"repo.issues.new.clear_labels": "Clear labels",
|
||||
"repo.issues.new.projects": "Projects",
|
||||
"repo.issues.new.clear_projects": "Clear projects",
|
||||
"repo.issues.new.no_projects": "No project",
|
||||
"repo.issues.new.no_projects": "No projects",
|
||||
"repo.issues.new.no_column": "No column",
|
||||
"repo.issues.new.open_projects": "Open Projects",
|
||||
"repo.issues.new.closed_projects": "Closed Projects",
|
||||
"repo.issues.new.no_items": "No items",
|
||||
|
|
|
|||
|
|
@ -33,12 +33,15 @@ type issueSidebarAssigneesData struct {
|
|||
CandidateAssignees []*user_model.User
|
||||
}
|
||||
|
||||
type issueSidebarProjectCardData struct {
|
||||
Project *project_model.Project
|
||||
Columns []*project_model.Column
|
||||
SelectedColumn *project_model.Column
|
||||
}
|
||||
|
||||
type issueSidebarProjectsData struct {
|
||||
SelectedProjectIDs []int64 // TODO: support multiple projects in the future
|
||||
|
||||
// the "selected" fields are only valid when len(SelectedProjectIDs)==1
|
||||
SelectedProjectColumns []*project_model.Column
|
||||
SelectedProjectColumn *project_model.Column
|
||||
ProjectCards []*issueSidebarProjectCardData
|
||||
|
||||
OpenProjects []*project_model.Project
|
||||
ClosedProjects []*project_model.Project
|
||||
|
|
@ -172,30 +175,37 @@ func (d *IssuePageMetaData) retrieveProjectData(ctx *context.Context) {
|
|||
if d.Issue == nil || d.Issue.Project == nil {
|
||||
return
|
||||
}
|
||||
d.ProjectsData.SelectedProjectIDs = []int64{d.Issue.Project.ID}
|
||||
columns, err := d.Issue.Project.GetColumns(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetProjectColumns", err)
|
||||
return
|
||||
}
|
||||
d.ProjectsData.SelectedProjectColumns = columns
|
||||
columnID, err := d.Issue.ProjectColumnID(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("ProjectColumnID", err)
|
||||
return
|
||||
}
|
||||
var selectedColumn *project_model.Column
|
||||
for _, col := range columns {
|
||||
if col.ID == columnID {
|
||||
d.ProjectsData.SelectedProjectColumn = col
|
||||
selectedColumn = col
|
||||
break
|
||||
}
|
||||
}
|
||||
d.ProjectsData.ProjectCards = []*issueSidebarProjectCardData{
|
||||
{
|
||||
Project: d.Issue.Project,
|
||||
Columns: columns,
|
||||
SelectedColumn: selectedColumn,
|
||||
},
|
||||
}
|
||||
d.ProjectsData.SelectedProjectIDs = make([]int64, 0, len(d.ProjectsData.ProjectCards))
|
||||
for _, card := range d.ProjectsData.ProjectCards {
|
||||
d.ProjectsData.SelectedProjectIDs = append(d.ProjectsData.SelectedProjectIDs, card.Project.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) {
|
||||
if d.Issue != nil && d.Issue.Project != nil {
|
||||
d.ProjectsData.SelectedProjectIDs = []int64{d.Issue.Project.ID}
|
||||
}
|
||||
d.ProjectsData.OpenProjects, d.ProjectsData.ClosedProjects = retrieveProjectsInternal(ctx, ctx.Repo.Repository)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -464,6 +464,54 @@ func UpdateIssueProject(ctx *context.Context) {
|
|||
ctx.JSONOK()
|
||||
}
|
||||
|
||||
// UpdateIssueProjectColumn moves an issue to a different column within its project
|
||||
func UpdateIssueProjectColumn(ctx *context.Context) {
|
||||
issue, err := issues_model.GetIssueByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormInt64("issue_id"))
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetIssueByID", issues_model.IsErrIssueNotExist, err)
|
||||
return
|
||||
}
|
||||
column, err := project_model.GetColumn(ctx, ctx.FormInt64("id"))
|
||||
if err != nil {
|
||||
ctx.NotFoundOrServerError("GetColumn", project_model.IsErrProjectColumnNotExist, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := issue.LoadProject(ctx); err != nil {
|
||||
ctx.ServerError("LoadProject", err)
|
||||
return
|
||||
}
|
||||
|
||||
issueProjects := []*project_model.Project{issue.Project} // TODO: this is for the multiple project support in the future
|
||||
|
||||
// it must make sure the requested column is in this issue's projects
|
||||
var columnProject *project_model.Project
|
||||
for _, project := range issueProjects {
|
||||
if column.ProjectID == project.ID {
|
||||
columnProject = project
|
||||
break
|
||||
}
|
||||
}
|
||||
if columnProject == nil {
|
||||
ctx.NotFound(nil)
|
||||
return
|
||||
}
|
||||
|
||||
// append to the end of the target column so we don't collide with existing sorting values
|
||||
newSorting, err := project_model.GetColumnIssueNextSorting(ctx, columnProject.ID, column.ID)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetColumnIssueNextSorting", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, map[int64]int64{newSorting: issue.ID}); err != nil {
|
||||
ctx.ServerError("MoveIssuesOnProjectColumn", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSONOK()
|
||||
}
|
||||
|
||||
// DeleteProjectColumn allows for the deletion of a project column
|
||||
func DeleteProjectColumn(ctx *context.Context) {
|
||||
if ctx.Doer == nil {
|
||||
|
|
|
|||
|
|
@ -1355,6 +1355,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
|
|||
m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel)
|
||||
m.Post("/milestone", reqRepoIssuesOrPullsWriter, repo.UpdateIssueMilestone)
|
||||
m.Post("/projects", reqRepoIssuesOrPullsWriter, reqRepoProjectsReader, repo.UpdateIssueProject)
|
||||
m.Post("/projects/column", reqRepoIssuesOrPullsWriter, reqRepoProjectsWriter, repo.UpdateIssueProjectColumn)
|
||||
m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee)
|
||||
m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus)
|
||||
m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues)
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
</div>
|
||||
|
||||
<div class="ui list labels-list">
|
||||
<span class="item empty-list {{if $data.SelectedLabelIDs}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_label"}}</span>
|
||||
<span class="item empty-list {{if $data.SelectedLabelIDs}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_labels"}}</span>
|
||||
{{range $data.AllLabels}}
|
||||
{{if .IsChecked}}
|
||||
<a class="item" href="{{$listBaseLink}}?labels={{.ID}}">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
{{$pageMeta := .}}
|
||||
{{$data := .ProjectsData}}
|
||||
{{$issueProject := NIL}}{{if and $pageMeta.Issue $pageMeta.Issue.Project}}{{$issueProject = $pageMeta.Issue.Project}}{{end}}
|
||||
<div class="divider"></div>
|
||||
<div class="issue-sidebar-combo" data-selection-mode="single" data-update-algo="all"
|
||||
|
||||
{{/* project selector */}}
|
||||
<div class="issue-sidebar-combo sidebar-project-combo" data-selection-mode="single" data-update-algo="all"
|
||||
{{if $pageMeta.Issue}}data-update-url="{{$pageMeta.RepoLink}}/issues/projects?issue_ids={{$pageMeta.Issue.ID}}"{{end}}
|
||||
>
|
||||
<input class="combo-value" name="project_id" type="hidden" value="{{if and $pageMeta.CanModifyIssueOrPull $data.SelectedProjectIDs}}{{index $data.SelectedProjectIDs 0}}{{end}}">
|
||||
|
|
@ -24,7 +25,8 @@
|
|||
<div class="header">{{ctx.Locale.Tr "repo.issues.new.open_projects"}}</div>
|
||||
{{range $data.OpenProjects}}
|
||||
<a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}">
|
||||
{{svg .IconName 18 "tw-shrink-0"}}<span class="tw-flex-1 tw-break-anywhere">{{.Title}}</span>
|
||||
<span class="item-check-mark">{{svg "octicon-check"}}</span>
|
||||
{{svg .IconName 18}}<span class="tw-flex-1 tw-break-anywhere">{{.Title}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
|
@ -33,19 +35,67 @@
|
|||
<div class="header">{{ctx.Locale.Tr "repo.issues.new.closed_projects"}}</div>
|
||||
{{range $data.ClosedProjects}}
|
||||
<a class="item muted" data-value="{{.ID}}" href="{{.Link ctx}}">
|
||||
{{svg .IconName 18 "tw-shrink-0"}}<span class="tw-flex-1 tw-break-anywhere">{{.Title}}</span>
|
||||
<span class="item-check-mark">{{svg "octicon-check"}}</span>
|
||||
{{svg .IconName 18}}<span class="tw-flex-1 tw-break-anywhere">{{.Title}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui list muted-links flex-items-block">
|
||||
<span class="item empty-list {{if $issueProject}}tw-hidden{{end}}">{{ctx.Locale.Tr "repo.issues.new.no_projects"}}</span>
|
||||
{{if $issueProject}}
|
||||
<a class="item" href="{{$issueProject.Link ctx}}">
|
||||
{{svg $issueProject.IconName 18 "tw-shrink-0"}}<span class="tw-flex-1 tw-break-anywhere">{{$issueProject.Title}}</span>
|
||||
</div>
|
||||
|
||||
{{/* project cards (column selectors) */}}
|
||||
{{if not $data.ProjectCards}}
|
||||
<div class="ui list">
|
||||
<div class="item empty-list">{{ctx.Locale.Tr "repo.issues.new.no_projects"}}</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="flex-relaxed-list">
|
||||
{{range $card := $data.ProjectCards}}
|
||||
{{$selectedColumn := $card.SelectedColumn}}
|
||||
<div class="item sidebar-project-card">
|
||||
<div class="tw-mb-1">
|
||||
<a class="suppressed flex-text-block" href="{{$card.Project.Link ctx}}">
|
||||
{{svg $card.Project.IconName 16}}
|
||||
<span class="gt-ellipsis">{{$card.Project.Title}}</span>
|
||||
</a>
|
||||
</div>
|
||||
{{if $pageMeta.CanModifyIssueOrPull}}
|
||||
<div class="issue-sidebar-combo sidebar-project-column-combo" data-selection-mode="single" data-update-algo="all"
|
||||
data-update-url="{{$pageMeta.RepoLink}}/issues/projects/column?issue_id={{$pageMeta.Issue.ID}}"
|
||||
>
|
||||
<input class="combo-value" name="column_id" type="hidden" value="{{if $selectedColumn}}{{$selectedColumn.ID}}{{end}}">
|
||||
<div class="ui dropdown full-width">
|
||||
<div class="flex-text-block tw-ml-[16px]">{{/* align with the "project" icon */}}
|
||||
<div class="interact-bg tw-px-2 tw-py-1 tw-rounded flex-text-block">
|
||||
{{if $selectedColumn}}
|
||||
{{if $card.SelectedColumn.Color}}<span class="color-icon icon-size-8" style="background-color: {{$card.SelectedColumn.Color}}"></span>{{end}}
|
||||
<div class="gt-ellipsis" data-testid="sidebar-project-column-text">{{$card.SelectedColumn.Title}}</div>
|
||||
{{else}}
|
||||
<div class="gt-ellipsis" data-testid="sidebar-project-column-text">{{ctx.Locale.Tr "repo.issues.new.no_column"}}</div>
|
||||
{{end}}
|
||||
{{svg "octicon-triangle-down" 14}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu flex-items-menu">
|
||||
{{range $columnItem := $card.Columns}}
|
||||
<a class="item" data-value="{{$columnItem.ID}}">
|
||||
<span class="item-check-mark">{{svg "octicon-check"}}</span>
|
||||
{{if $columnItem.Color}}<span class="color-icon icon-size-8" style="background-color: {{$columnItem.Color}}"></span>{{end}}
|
||||
<div class="gt-ellipsis">{{$columnItem.Title}}</div>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else if $selectedColumn}}
|
||||
<div class="flex-text-block tw-my-1 tw-ml-[22px]">{{/* align with the "project" icon */}}
|
||||
{{if $selectedColumn.Color}}<span class="color-icon icon-size-8" style="background-color: {{$selectedColumn.Color}}"></span>{{end}}
|
||||
<div class="gt-ellipsis">{{$selectedColumn.Title}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import {env} from 'node:process';
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {login, apiCreateRepo, apiCreateIssue, apiDeleteRepo, createProjectColumn, randomString, timeoutFactor} from './utils.ts';
|
||||
|
||||
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})]);
|
||||
|
||||
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),
|
||||
));
|
||||
|
||||
await apiCreateIssue(page.request, user, repoName, {title: 'Column picker test'});
|
||||
|
||||
// Same ceiling as tests/e2e/events.test.ts; Playwright defaults are 5000*factor (see playwright.config.ts).
|
||||
const slowTimeout = 15_000 * timeoutFactor;
|
||||
|
||||
await page.goto(`/${user}/${repoName}/issues/1`);
|
||||
await page.locator('.sidebar-project-combo .ui.dropdown').click();
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) => resp.url().includes('/issues/projects') && resp.status() === 200,
|
||||
{timeout: slowTimeout},
|
||||
),
|
||||
page.locator('.sidebar-project-combo .menu .item', {hasText: 'Kanban Board'}).click(),
|
||||
]);
|
||||
|
||||
const columnCombo = page.locator('.sidebar-project-column-combo');
|
||||
await expect(columnCombo).toBeVisible({timeout: slowTimeout});
|
||||
await columnCombo.locator('.ui.dropdown').click();
|
||||
await columnCombo.locator('.menu').waitFor({state: 'visible', timeout: slowTimeout});
|
||||
|
||||
const inProgressItem = columnCombo.locator('a.item', {hasText: 'In Progress'});
|
||||
await expect(inProgressItem).toBeVisible({timeout: slowTimeout});
|
||||
await inProgressItem.scrollIntoViewIfNeeded();
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.request().method() === 'POST' &&
|
||||
resp.url().includes('/issues/projects/column') &&
|
||||
resp.ok(),
|
||||
{timeout: slowTimeout},
|
||||
),
|
||||
inProgressItem.click(),
|
||||
]);
|
||||
|
||||
await expect(columnCombo.getByTestId('sidebar-project-column-text')).toContainText('In Progress', {
|
||||
timeout: slowTimeout,
|
||||
});
|
||||
await expect(page.locator('.timeline-item', {hasText: 'moved this to In Progress'})).toBeVisible({
|
||||
timeout: slowTimeout,
|
||||
});
|
||||
|
||||
await apiDeleteRepo(page.request, user, repoName);
|
||||
});
|
||||
|
|
@ -74,6 +74,13 @@ export async function apiCreateBranch(requestContext: APIRequestContext, owner:
|
|||
}), 'apiCreateBranch');
|
||||
}
|
||||
|
||||
export async function createProjectColumn(requestContext: APIRequestContext, owner: string, repo: string, projectID: string, title: string) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/${owner}/${repo}/projects/${projectID}/columns/new`, {
|
||||
headers: apiHeaders(),
|
||||
form: {title},
|
||||
}), 'createProjectColumn');
|
||||
}
|
||||
|
||||
export async function apiDeleteRepo(requestContext: APIRequestContext, owner: string, name: string) {
|
||||
await apiRetry(() => requestContext.delete(`${baseUrl()}/api/v1/repos/${owner}/${name}`, {
|
||||
headers: apiHeaders(),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues"
|
||||
|
|
@ -89,6 +90,110 @@ func TestMoveRepoProjectColumns(t *testing.T) {
|
|||
assert.NoError(t, project_model.DeleteProjectByID(t.Context(), project1.ID))
|
||||
}
|
||||
|
||||
func TestUpdateIssueProjectColumn(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// fixture: issue 3 is in project 1 of repo user2/repo1, column "In Progress" (id=2)
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
|
||||
assert.EqualValues(t, 1, issue.RepoID)
|
||||
|
||||
sess := loginUser(t, "user2")
|
||||
|
||||
t.Run("MoveColumn", func(t *testing.T) {
|
||||
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{
|
||||
"issue_id": "3",
|
||||
"id": "3",
|
||||
})
|
||||
sess.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
pi := unittest.AssertExistsAndLoadBean(t, &project_model.ProjectIssue{IssueID: 3})
|
||||
assert.EqualValues(t, 3, pi.ProjectColumnID)
|
||||
})
|
||||
|
||||
t.Run("InvalidIssueID", func(t *testing.T) {
|
||||
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{
|
||||
"issue_id": "0",
|
||||
"id": "3",
|
||||
})
|
||||
sess.MakeRequest(t, req, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("WrongRepo", func(t *testing.T) {
|
||||
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{
|
||||
"issue_id": "6",
|
||||
"id": "3",
|
||||
})
|
||||
sess.MakeRequest(t, req, http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("WrongProject", func(t *testing.T) {
|
||||
project2 := project_model.Project{
|
||||
Title: "second project on repo1",
|
||||
RepoID: 1,
|
||||
Type: project_model.TypeRepository,
|
||||
TemplateType: project_model.TemplateTypeNone,
|
||||
}
|
||||
require.NoError(t, project_model.NewProject(t.Context(), &project2))
|
||||
require.NoError(t, project_model.NewColumn(t.Context(), &project_model.Column{
|
||||
Title: "other column",
|
||||
ProjectID: project2.ID,
|
||||
}))
|
||||
columns, err := project2.GetColumns(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, columns)
|
||||
|
||||
req := NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects/column", map[string]string{
|
||||
"issue_id": "1",
|
||||
"id": strconv.FormatInt(columns[0].ID, 10),
|
||||
})
|
||||
sess.MakeRequest(t, req, http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIssueSidebarProjectColumn(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// fixture: issue 5 (index=4) is in project 1 of repo user2/repo1, column "Done" (id=3)
|
||||
sess := loginUser(t, "user2")
|
||||
|
||||
req := NewRequest(t, "GET", "/user2/repo1/issues/4")
|
||||
resp := sess.MakeRequest(t, req, http.StatusOK)
|
||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
|
||||
cards := htmlDoc.Find(".sidebar-project-card")
|
||||
assert.Equal(t, 1, cards.Length())
|
||||
|
||||
title := cards.Find(".sidebar-project-card a.suppressed .gt-ellipsis")
|
||||
assert.Contains(t, strings.TrimSpace(title.Text()), "First project")
|
||||
|
||||
columnCombo := cards.Find(".sidebar-project-column-combo")
|
||||
assert.Equal(t, 1, columnCombo.Length())
|
||||
|
||||
defaultItem := columnCombo.Find(`.menu .item[data-value="1"]`)
|
||||
assert.Equal(t, 1, defaultItem.Length())
|
||||
|
||||
inProgressItem := columnCombo.Find(`.menu .item[data-value="2"]`)
|
||||
assert.Equal(t, 1, inProgressItem.Length())
|
||||
doneItem := columnCombo.Find(`.menu .item[data-value="3"]`)
|
||||
assert.Equal(t, 1, doneItem.Length())
|
||||
|
||||
comboVal, exists := columnCombo.Find("input.combo-value").Attr("value")
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, "3", comboVal)
|
||||
|
||||
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/projects?issue_ids=5", map[string]string{
|
||||
"id": "0",
|
||||
})
|
||||
sess.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
req = NewRequest(t, "GET", "/user2/repo1/issues/4")
|
||||
resp = sess.MakeRequest(t, req, http.StatusOK)
|
||||
htmlDoc = NewHTMLParser(t, resp.Body)
|
||||
|
||||
cards = htmlDoc.Find(".sidebar-project-card")
|
||||
assert.Equal(t, 0, cards.Length())
|
||||
}
|
||||
|
||||
// getProjectIssueIDs returns the set of issue IDs rendered as cards on the project board page.
|
||||
func getProjectIssueIDs(t *testing.T, htmlDoc *HTMLDoc) map[int64]struct{} {
|
||||
t.Helper()
|
||||
|
|
|
|||
|
|
@ -782,11 +782,17 @@ tr.top-line-blame:first-of-type {
|
|||
|
||||
.color-icon {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--border-radius-full);
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.icon-size-8 {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.rss-icon {
|
||||
display: inline-flex;
|
||||
color: var(--color-text-light-1);
|
||||
|
|
|
|||
|
|
@ -6,10 +6,6 @@
|
|||
fill: currentcolor;
|
||||
}
|
||||
|
||||
.middle .svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* some browsers like Chrome have a bug: when a SVG is in a "display: none" container and referenced
|
||||
somewhere else by `<use href="#id">`, it won't be rendered correctly. e.g.: ".kts -> kotlin" */
|
||||
.svg-icon-container {
|
||||
|
|
@ -50,3 +46,9 @@
|
|||
.svg[width="36"] { min-width: 36px; }
|
||||
.svg[width="48"] { min-width: 48px; }
|
||||
.svg[width="56"] { min-width: 56px; }
|
||||
|
||||
/* when the svg is used in menu or item, it's certain that we don't want it to be shrunk */
|
||||
.menu .svg,
|
||||
.item .svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,12 +62,23 @@
|
|||
visibility: hidden;
|
||||
}
|
||||
|
||||
.sidebar-project-card {
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
margin-top: var(--gap-block);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.issue-content-right .ui.list.labels-list {
|
||||
display: flex;
|
||||
gap: var(--gap-inline);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.issue-content-right .empty-list {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.issue-content-left,
|
||||
.issue-content-right {
|
||||
|
|
|
|||
Loading…
Reference in New Issue