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:
parent
755d200371
commit
537174c104
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
29
templates/repo/issue/sidebar/project_column.tmpl
Normal file
29
templates/repo/issue/sidebar/project_column.tmpl
Normal 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>
|
||||
@ -49,3 +49,4 @@
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{template "repo/issue/sidebar/project_column" $pageMeta}}
|
||||
|
||||
@ -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(',');
|
||||
|
||||
92
web_src/js/features/repo-issue-sidebar-project.ts
Normal file
92
web_src/js/features/repo-issue-sidebar-project.ts
Normal 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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user