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
Myers Carpenter 2026-04-19 08:53:02 -04:00 committed by GitHub
parent 6ed861589a
commit 2f5b5a9e9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 380 additions and 60 deletions

View File

@ -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

View File

@ -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) {

View File

@ -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,

View File

@ -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
}

View File

@ -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())

View File

@ -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
}
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")
}
issues, err := c.GetIssues(ctx)
if err != nil {
return err
}
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 {

View File

@ -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",

View File

@ -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)
}

View File

@ -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 {

View File

@ -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)

View File

@ -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}}">

View File

@ -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>
</a>
{{end}}
</div>
</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}}

View File

@ -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);
});

View File

@ -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(),

View File

@ -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()

View File

@ -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);

View File

@ -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;
}

View File

@ -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 {