Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d2a71a6
feat: project column picker with per-project cards in issue sidebar
myers Apr 3, 2026
aff2a41
fix: address review feedback from wxiaoguang
myers Apr 11, 2026
2b8282e
Address review feedback from bircni and wxiaoguang
myers Apr 12, 2026
8669d0c
Merge branch 'main' into feat/issue-column-picker
silverwind Apr 18, 2026
c2d4359
Fix gofmt alignment and use integer pixel sizes in sidebar CSS
silverwind Apr 18, 2026
b69a043
Render "No projects"/"No Labels" placeholder when empty
silverwind Apr 18, 2026
13d1a80
Lowercase "No labels" and unify empty-list placeholder font size to 12px
silverwind Apr 18, 2026
227c8a0
Polish project card: suppressed link, button dropdown, spacing
silverwind Apr 18, 2026
d9355b4
Address review: 404 on not-found, append at end, drop unused template…
silverwind Apr 18, 2026
1ee939c
Simplify: SelectedColumn helper, 404 on not-found, dedup comments, CS…
silverwind Apr 18, 2026
55fdd87
Simplify e2e test and AGENTS.md flex-* instruction
silverwind Apr 18, 2026
7cde0d7
Merge branch 'main' into feat/issue-column-picker
silverwind Apr 18, 2026
68775e3
Extract createProjectColumn helper in e2e utils
silverwind Apr 18, 2026
85df7e0
Align column dropdown items with other sidebar dropdowns
silverwind Apr 18, 2026
a1eb15a
tw-gap-2 -> tw-gap-1 on column dropdown items
silverwind Apr 18, 2026
586873a
Fix chromium e2e failure: use div.ui.dropdown instead of button
silverwind Apr 18, 2026
5305ec2
Extract column max-sorting query into Column.NextSorting model method
silverwind Apr 18, 2026
3782f0a
Merge branch 'main' into feat/issue-column-picker
wxiaoguang Apr 19, 2026
ec92619
refactor
wxiaoguang Apr 19, 2026
a24235a
clean up
wxiaoguang Apr 19, 2026
f56814c
Merge branch 'main' into feat/issue-column-picker
bircni Apr 19, 2026
57145f5
fix: restore DB and e2e tests for issue project column picker
bircni Apr 19, 2026
900125e
Decouple placeholder font size from labels
silverwind Apr 19, 2026
bbb5e82
refix ai slops
bircni Apr 19, 2026
34ac99a
test(e2e): harden issue-project column step for slow CI and Firefox
bircni Apr 19, 2026
e3147e7
harden ci
bircni Apr 19, 2026
40c68e6
Merge branch 'main' into feat/issue-column-picker
wxiaoguang Apr 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 11 additions & 0 deletions models/issues/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
11 changes: 2 additions & 9 deletions models/issues/issue_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion models/project/column.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion models/project/column_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
41 changes: 24 additions & 17 deletions models/project/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Comment thread
wxiaoguang marked this conversation as resolved.
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)
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 oldColumn.ID == newColumn.ID {
return nil
}

movedIssues, err := oldColumn.GetIssues(ctx)
if err != nil {
return err
}
if len(issues) == 0 {
if len(movedIssues) == 0 {
return nil
}

nextSorting := util.Iif(res.IssueCount > 0, res.MaxSorting+1, 0)
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 {
Expand Down
5 changes: 3 additions & 2 deletions options/locale/locale_en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 20 additions & 10 deletions routers/web/repo/issue_page_meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
wxiaoguang marked this conversation as resolved.
Comment thread
myers marked this conversation as resolved.

OpenProjects []*project_model.Project
ClosedProjects []*project_model.Project
Expand Down Expand Up @@ -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)
}

Expand Down
54 changes: 54 additions & 0 deletions routers/web/repo/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,60 @@ func UpdateIssueProject(ctx *context.Context) {
ctx.JSONOK()
}

// UpdateIssueProjectColumn moves an issue to a different column within its project
func UpdateIssueProjectColumn(ctx *context.Context) {
issueID := ctx.FormInt64("issue_id")
if issueID <= 0 {
ctx.JSONError("invalid issue_id")
return
}

issue, err := issues_model.GetIssueByRepoID(ctx, ctx.Repo.Repository.ID, issueID)
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.JSONError("column does not belong to the issue's project")
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 {
Expand Down
1 change: 1 addition & 0 deletions routers/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion templates/repo/issue/sidebar/label_list.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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}}">
Expand Down
74 changes: 62 additions & 12 deletions templates/repo/issue/sidebar/project_list.tmpl
Original file line number Diff line number Diff line change
@@ -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}}">
Expand All @@ -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}}
Expand All @@ -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">{{$card.SelectedColumn.Title}}</div>
{{else}}
<div class="gt-ellipsis">{{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}}
Loading
Loading