From 537174c1045e32cc6b0e665882b56f9d8a2f7355 Mon Sep 17 00:00:00 2001 From: Myers Carpenter Date: Sat, 28 Mar 2026 01:19:20 +0000 Subject: [PATCH 01/18] Add project column picker to issue and pull request sidebar 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. The dropdown uses Fomantic UI and updates the column via an AJAX POST, re-rendering the selector in place on success. --- routers/web/repo/issue_page_meta.go | 35 +++++++ routers/web/repo/projects.go | 89 ++++++++++++++++++ routers/web/web.go | 1 + .../repo/issue/sidebar/project_column.tmpl | 29 ++++++ .../repo/issue/sidebar/project_list.tmpl | 1 + .../features/repo-issue-sidebar-combolist.ts | 10 +- .../js/features/repo-issue-sidebar-project.ts | 92 +++++++++++++++++++ web_src/js/features/repo-issue-sidebar.ts | 4 + 8 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 templates/repo/issue/sidebar/project_column.tmpl create mode 100644 web_src/js/features/repo-issue-sidebar-project.ts diff --git a/routers/web/repo/issue_page_meta.go b/routers/web/repo/issue_page_meta.go index 719b485bc5..548134c092 100644 --- a/routers/web/repo/issue_page_meta.go +++ b/routers/web/repo/issue_page_meta.go @@ -37,6 +37,8 @@ type issueSidebarProjectsData struct { SelectedProjectID int64 OpenProjects []*project_model.Project ClosedProjects []*project_model.Project + ProjectColumns []*project_model.Column + SelectedColumnID int64 } type IssuePageMetaData struct { @@ -97,6 +99,12 @@ func retrieveRepoIssueMetaData(ctx *context.Context, repo *repo_model.Repository // A reader(creator) could update some meta (eg: target branch), but can't change assignees anymore. // For non-creator users, only writers could update some meta (eg: assignees, milestone, project) // Need to clarify the logic and add some tests in the future + // Load project column data for all users (read-only display for non-writers) + data.retrieveProjectColumnsData(ctx) + if ctx.Written() { + return data + } + data.CanModifyIssueOrPull = ctx.Repo.CanWriteIssuesOrPulls(isPull) && !ctx.Repo.Repository.IsArchived if !data.CanModifyIssueOrPull { return data @@ -158,6 +166,33 @@ func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) { ctx.Data["Assignees"] = d.AssigneesData.CandidateAssignees } +func (d *IssuePageMetaData) retrieveProjectColumnsData(ctx *context.Context) { + if d.Issue == nil || d.Issue.Project == nil { + return + } + d.ProjectsData.SelectedProjectID = d.Issue.Project.ID + columns, err := d.Issue.Project.GetColumns(ctx) + if err != nil { + ctx.ServerError("GetProjectColumns", err) + return + } + d.ProjectsData.ProjectColumns = columns + columnID, err := d.Issue.ProjectColumnID(ctx) + if err != nil { + ctx.ServerError("ProjectColumnID", err) + return + } + if columnID == 0 { + defaultColumn, err := d.Issue.Project.MustDefaultColumn(ctx) + if err != nil { + ctx.ServerError("MustDefaultColumn", err) + return + } + columnID = defaultColumn.ID + } + d.ProjectsData.SelectedColumnID = columnID +} + func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) { if d.Issue != nil && d.Issue.Project != nil { d.ProjectsData.SelectedProjectID = d.Issue.Project.ID diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index c9bdc5be76..079cc9521d 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -461,6 +461,95 @@ func UpdateIssueProject(ctx *context.Context) { } } + // Return columns for the new project so the sidebar column picker + // can update without a page reload. + type columnInfo struct { + ID int64 `json:"id"` + Title string `json:"title"` + } + result := map[string]any{"ok": true} + if projectID > 0 { + project, err := project_model.GetProjectByID(ctx, projectID) + if err != nil { + ctx.ServerError("GetProjectByID", err) + return + } + columns, err := project.GetColumns(ctx) + if err != nil { + ctx.ServerError("GetProjectColumns", err) + return + } + cols := make([]columnInfo, 0, len(columns)) + for _, c := range columns { + cols = append(cols, columnInfo{ID: c.ID, Title: c.Title}) + } + // The issue was assigned to the default column + var selectedColumnID int64 + if len(issues) > 0 { + selectedColumnID, _ = issues[0].ProjectColumnID(ctx) + if selectedColumnID == 0 { + defaultColumn, err := project.MustDefaultColumn(ctx) + if err == nil { + selectedColumnID = defaultColumn.ID + } + } + } + result["columns"] = cols + result["selected_column_id"] = selectedColumnID + } else { + result["columns"] = []columnInfo{} + result["selected_column_id"] = 0 + } + ctx.JSON(http.StatusOK, result) +} + +// UpdateIssueProjectColumn moves an issue to a different column within its current project +func UpdateIssueProjectColumn(ctx *context.Context) { + issueID := ctx.FormInt64("issue_id") + columnID := ctx.FormInt64("id") + + issue, err := issues_model.GetIssueByID(ctx, issueID) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound(nil) + return + } + ctx.ServerError("GetIssueByID", err) + return + } + if issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound(nil) + return + } + + if err := issue.LoadProject(ctx); err != nil { + ctx.ServerError("LoadProject", err) + return + } + if issue.Project == nil { + ctx.NotFound(nil) + return + } + + column, err := project_model.GetColumn(ctx, columnID) + if err != nil { + if project_model.IsErrProjectColumnNotExist(err) { + ctx.NotFound(nil) + return + } + ctx.ServerError("GetColumn", err) + return + } + if column.ProjectID != issue.Project.ID { + ctx.NotFound(nil) + return + } + + if err := project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, map[int64]int64{0: issue.ID}); err != nil { + ctx.ServerError("MoveIssuesOnProjectColumn", err) + return + } + ctx.JSONOK() } diff --git a/routers/web/web.go b/routers/web/web.go index 72d2c27eaf..89635bfa71 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1354,6 +1354,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, reqRepoProjectsReader, repo.UpdateIssueProjectColumn) m.Post("/assignee", reqRepoIssuesOrPullsWriter, repo.UpdateIssueAssignee) m.Post("/status", reqRepoIssuesOrPullsWriter, repo.UpdateIssueStatus) m.Post("/delete", reqRepoAdmin, repo.BatchDeleteIssues) diff --git a/templates/repo/issue/sidebar/project_column.tmpl b/templates/repo/issue/sidebar/project_column.tmpl new file mode 100644 index 0000000000..fc9f294c9a --- /dev/null +++ b/templates/repo/issue/sidebar/project_column.tmpl @@ -0,0 +1,29 @@ +{{$pageMeta := .}} +{{$data := .ProjectsData}} + diff --git a/templates/repo/issue/sidebar/project_list.tmpl b/templates/repo/issue/sidebar/project_list.tmpl index be8097fbbc..e6aa16a8a6 100644 --- a/templates/repo/issue/sidebar/project_list.tmpl +++ b/templates/repo/issue/sidebar/project_list.tmpl @@ -49,3 +49,4 @@ {{end}} +{{template "repo/issue/sidebar/project_column" $pageMeta}} diff --git a/web_src/js/features/repo-issue-sidebar-combolist.ts b/web_src/js/features/repo-issue-sidebar-combolist.ts index aaff3804c7..43e26ab862 100644 --- a/web_src/js/features/repo-issue-sidebar-combolist.ts +++ b/web_src/js/features/repo-issue-sidebar-combolist.ts @@ -31,6 +31,9 @@ export class IssueSidebarComboList { elComboValue: HTMLInputElement; initialValues: string[]; container: HTMLElement; + // Optional callback invoked after the backend update completes. + // If it returns true, the page reload is skipped. + onAfterUpdate?: (response: Response, changedValues: string[]) => Promise; constructor(container: HTMLElement) { this.container = container; @@ -63,6 +66,7 @@ export class IssueSidebarComboList { } async updateToBackend(changedValues: Array) { + let resp: Response | undefined; if (this.updateAlgo === 'diff') { for (const value of this.initialValues) { if (!changedValues.includes(value)) { @@ -71,12 +75,13 @@ export class IssueSidebarComboList { } for (const value of changedValues) { if (!this.initialValues.includes(value)) { - await POST(this.updateUrl, {data: new URLSearchParams({action: 'attach', id: value})}); + resp = await POST(this.updateUrl, {data: new URLSearchParams({action: 'attach', id: value})}); } } } else { - await POST(this.updateUrl, {data: new URLSearchParams({id: changedValues.join(',')})}); + resp = await POST(this.updateUrl, {data: new URLSearchParams({id: changedValues.join(',')})}); } + if (this.onAfterUpdate && resp && await this.onAfterUpdate(resp, changedValues)) return; issueSidebarReloadConfirmDraftComment(); } @@ -133,6 +138,7 @@ export class IssueSidebarComboList { } init() { + (this.container as any)._comboList = this; // init the checked items from initial value if (this.elComboValue.value && this.elComboValue.value !== '0' && !queryElems(this.elDropdown, `.menu > .item.checked`).length) { const values = this.elComboValue.value.split(','); diff --git a/web_src/js/features/repo-issue-sidebar-project.ts b/web_src/js/features/repo-issue-sidebar-project.ts new file mode 100644 index 0000000000..f9eed82e48 --- /dev/null +++ b/web_src/js/features/repo-issue-sidebar-project.ts @@ -0,0 +1,92 @@ +import {POST} from '../modules/fetch.ts'; +import type {IssueSidebarComboList} from './repo-issue-sidebar-combolist.ts'; +import {html, htmlRaw} from '../utils/html.ts'; +import {createElementFromHTML} from '../utils/dom.ts'; + +type ColumnInfo = { + id: number; + title: string; +}; + +export function initProjectColumnPicker() { + const columnSection = document.querySelector('#sidebar-project-column'); + if (!columnSection) return; + + initColumnDropdown(columnSection); + + const projectCombo = document.querySelector('.issue-sidebar-combo[data-update-url*="/issues/projects?"]'); + if (!projectCombo) return; + + const comboList = (projectCombo as any)._comboList as IssueSidebarComboList | undefined; + if (!comboList) return; + + comboList.onAfterUpdate = async (response: Response, _changedValues: string[]): Promise => { + const data = await response.json(); + const columns: ColumnInfo[] = data.columns || []; + const selectedColumnID: number = data.selected_column_id || 0; + + comboList.updateUiList(comboList.collectCheckedValues()); + + const updateUrl = columnSection.getAttribute('data-update-url') || ''; + renderColumnPicker(columnSection, columns, selectedColumnID, updateUrl); + return true; + }; +} + +function initColumnDropdown(section: HTMLElement) { + const dropdown = section.querySelector('.column-selector-dropdown'); + if (!dropdown) return; + setupFomanticDropdown(dropdown); +} + +function setupFomanticDropdown(el: HTMLElement) { + const updateUrl = el.getAttribute('data-update-url'); + if (!updateUrl) return; + + $(el).dropdown({ + onChange(value: string) { + POST(updateUrl, {data: new URLSearchParams({id: value})}); + }, + }); +} + +function renderColumnPicker( + section: HTMLElement, + columns: ColumnInfo[], + selectedColumnID: number, + baseUpdateUrl: string, +) { + section.innerHTML = ''; + + if (columns.length < 2) return; + + const selectedCol = columns.find((c) => c.id === selectedColumnID); + const selectedTitle = selectedCol ? selectedCol.title : columns[0].title; + + const svgTriangle = document.querySelector('.svg.octicon-triangle-down')?.cloneNode(true) as SVGElement | null; + const triangleHtml = svgTriangle ? (() => { + svgTriangle.setAttribute('width', '14'); + svgTriangle.setAttribute('height', '14'); + svgTriangle.classList.add('dropdown', 'icon'); + return svgTriangle.outerHTML; + })() : ''; + + let menuItemsHtml = ''; + for (const col of columns) { + const selectedClass = col.id === selectedColumnID ? ' active selected' : ''; + menuItemsHtml += html`
${col.title}
`; + } + + const dropdown = createElementFromHTML(html` + + `); + + section.append(dropdown); + setupFomanticDropdown(dropdown); +} diff --git a/web_src/js/features/repo-issue-sidebar.ts b/web_src/js/features/repo-issue-sidebar.ts index 2ff077e6db..c123cc4efb 100644 --- a/web_src/js/features/repo-issue-sidebar.ts +++ b/web_src/js/features/repo-issue-sidebar.ts @@ -1,6 +1,7 @@ import {POST} from '../modules/fetch.ts'; import {queryElems, toggleElem} from '../utils/dom.ts'; import {IssueSidebarComboList} from './repo-issue-sidebar-combolist.ts'; +import {initProjectColumnPicker} from './repo-issue-sidebar-project.ts'; function initBranchSelector() { // TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl" @@ -49,4 +50,7 @@ export function initRepoIssueSidebar() { // init the combo list: a dropdown for selecting items, and a list for showing selected items and related actions queryElems(document, '.issue-sidebar-combo', (el) => new IssueSidebarComboList(el).init()); + + // hook up the project column picker (must run after combo list init) + initProjectColumnPicker(); } From 0b1b962ab01c6ff8a5d96d1528f945ceff194d31 Mon Sep 17 00:00:00 2001 From: Myers Carpenter Date: Sun, 29 Mar 2026 23:38:06 +0000 Subject: [PATCH 02/18] Refactor column picker to use generic combo list rerender Address review feedback from bircni and wxiaoguang: - Add rerender(items, selectedValue) method to IssueSidebarComboList so any picker can dynamically rebuild its menu. This replaces the one-off renderColumnPicker function with a framework-level capability. - Convert the column picker template to use the standard issue-sidebar-combo structure, so it is initialized by the existing queryElems loop alongside milestone/label/assignee pickers. - Move projectColumnInfo struct to package level (bircni) - Handle ProjectColumnID and MustDefaultColumn errors (bircni) - Remove custom Fomantic dropdown setup and DOM manipulation The rerender method is designed to support future multi-project use: each project's column picker can independently rerender when its parent project selection changes. --- routers/web/repo/projects.go | 29 ++++--- .../repo/issue/sidebar/project_column.tmpl | 44 +++++----- .../features/repo-issue-sidebar-combolist.ts | 23 +++++ .../js/features/repo-issue-sidebar-project.ts | 83 ++++--------------- 4 files changed, 80 insertions(+), 99 deletions(-) diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go index 079cc9521d..483598d558 100644 --- a/routers/web/repo/projects.go +++ b/routers/web/repo/projects.go @@ -36,6 +36,11 @@ const ( tplProjectsView templates.TplName = "repo/projects/view" ) +type projectColumnInfo struct { + ID int64 `json:"id"` + Title string `json:"title"` +} + // MustEnableRepoProjects check if repo projects are enabled in settings func MustEnableRepoProjects(ctx *context.Context) { if unit.TypeProjects.UnitGlobalDisabled() { @@ -461,12 +466,6 @@ func UpdateIssueProject(ctx *context.Context) { } } - // Return columns for the new project so the sidebar column picker - // can update without a page reload. - type columnInfo struct { - ID int64 `json:"id"` - Title string `json:"title"` - } result := map[string]any{"ok": true} if projectID > 0 { project, err := project_model.GetProjectByID(ctx, projectID) @@ -479,25 +478,31 @@ func UpdateIssueProject(ctx *context.Context) { ctx.ServerError("GetProjectColumns", err) return } - cols := make([]columnInfo, 0, len(columns)) + cols := make([]projectColumnInfo, 0, len(columns)) for _, c := range columns { - cols = append(cols, columnInfo{ID: c.ID, Title: c.Title}) + cols = append(cols, projectColumnInfo{ID: c.ID, Title: c.Title}) } // The issue was assigned to the default column var selectedColumnID int64 if len(issues) > 0 { - selectedColumnID, _ = issues[0].ProjectColumnID(ctx) + selectedColumnID, err = issues[0].ProjectColumnID(ctx) + if err != nil { + ctx.ServerError("ProjectColumnID", err) + return + } if selectedColumnID == 0 { defaultColumn, err := project.MustDefaultColumn(ctx) - if err == nil { - selectedColumnID = defaultColumn.ID + if err != nil { + ctx.ServerError("MustDefaultColumn", err) + return } + selectedColumnID = defaultColumn.ID } } result["columns"] = cols result["selected_column_id"] = selectedColumnID } else { - result["columns"] = []columnInfo{} + result["columns"] = []projectColumnInfo{} result["selected_column_id"] = 0 } ctx.JSON(http.StatusOK, result) diff --git a/templates/repo/issue/sidebar/project_column.tmpl b/templates/repo/issue/sidebar/project_column.tmpl index fc9f294c9a..ea08d47d6c 100644 --- a/templates/repo/issue/sidebar/project_column.tmpl +++ b/templates/repo/issue/sidebar/project_column.tmpl @@ -1,29 +1,35 @@ {{$pageMeta := .}} {{$data := .ProjectsData}} -