0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-04-04 21:06:20 +02:00

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.
This commit is contained in:
Myers Carpenter 2026-03-28 01:19:20 +00:00
parent 755d200371
commit 537174c104
8 changed files with 259 additions and 2 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,29 @@
{{$pageMeta := .}}
{{$data := .ProjectsData}}
<div id="sidebar-project-column"
data-issue-id="{{if $pageMeta.Issue}}{{$pageMeta.Issue.ID}}{{end}}"
data-update-url="{{if $pageMeta.Issue}}{{$pageMeta.RepoLink}}/issues/projects/column?issue_id={{$pageMeta.Issue.ID}}{{end}}">
{{if and $pageMeta.Issue $pageMeta.Issue.Project $data.ProjectColumns (gt (len $data.ProjectColumns) 1)}}
{{if $pageMeta.CanModifyIssueOrPull}}
<div class="ui dropdown selection fluid column-selector-dropdown"
data-update-url="{{$pageMeta.RepoLink}}/issues/projects/column?issue_id={{$pageMeta.Issue.ID}}">
<input type="hidden" name="column_id" value="{{$data.SelectedColumnID}}">
<div class="default text">{{range $data.ProjectColumns}}{{if eq .ID $data.SelectedColumnID}}{{.Title}}{{end}}{{end}}</div>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
{{range $data.ProjectColumns}}
<div class="item {{if eq .ID $data.SelectedColumnID}}active selected{{end}}" data-value="{{.ID}}">{{.Title}}</div>
{{end}}
</div>
</div>
{{else}}
{{range $data.ProjectColumns}}
{{if eq .ID $data.SelectedColumnID}}
<div class="tw-mt-1">
<span class="muted">{{svg "octicon-columns" 16 "tw-mr-1"}}{{.Title}}</span>
</div>
{{end}}
{{end}}
{{end}}
{{end}}
</div>

View File

@ -49,3 +49,4 @@
{{end}}
</div>
</div>
{{template "repo/issue/sidebar/project_column" $pageMeta}}

View File

@ -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<boolean>;
constructor(container: HTMLElement) {
this.container = container;
@ -63,6 +66,7 @@ export class IssueSidebarComboList {
}
async updateToBackend(changedValues: Array<string>) {
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(',');

View File

@ -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<HTMLElement>('#sidebar-project-column');
if (!columnSection) return;
initColumnDropdown(columnSection);
const projectCombo = document.querySelector<HTMLElement>('.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<boolean> => {
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<HTMLElement>('.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`<div class="item${htmlRaw(selectedClass)}" data-value="${col.id}">${col.title}</div>`;
}
const dropdown = createElementFromHTML(html`
<div class="ui dropdown selection fluid column-selector-dropdown"
data-update-url="${baseUpdateUrl}">
<input type="hidden" name="column_id" value="${selectedColumnID}">
<div class="default text">${selectedTitle}</div>
${htmlRaw(triangleHtml)}
<div class="menu">${htmlRaw(menuItemsHtml)}</div>
</div>
`);
section.append(dropdown);
setupFomanticDropdown(dropdown);
}

View File

@ -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<HTMLElement>(document, '.issue-sidebar-combo', (el) => new IssueSidebarComboList(el).init());
// hook up the project column picker (must run after combo list init)
initProjectColumnPicker();
}