mirror of
https://github.com/go-gitea/gitea.git
synced 2026-11-09 17:37:20 -06:00
The board reported every card of the target column on each drag, and the server wrote all of them into that column. A stale board in another browser therefore pulled cards moved by someone else back to their old column. The client now sends only the dragged issue and its predecessor, and the server computes the position in one transaction: - Only the dragged issue changes column, the others are just renumbered. - A predecessor that is no longer in the column puts the card at the end. Added an e2e test. Fixes https://github.com/go-gitea/gitea/issues/21243 Fixes https://github.com/go-gitea/gitea/issues/32070 Fixes https://github.com/go-gitea/gitea/issues/13498 --- Assisted-by: Codet:claude --------- Co-authored-by: silverwind <me@silverwind.io>
181 lines
7.4 KiB
TypeScript
181 lines
7.4 KiB
TypeScript
import {contrastColor} from '../utils/color.ts';
|
|
import {createSortable} from '../modules/sortable.ts';
|
|
import {POST} from '../modules/fetch.ts';
|
|
import {performFetchActionRequest} from '../modules/fetch-action.ts';
|
|
import {hideModal} from '../modules/modal.ts';
|
|
import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
|
|
import type {SortableEvent} from 'sortablejs';
|
|
import {toggleFullScreen} from '../utils.ts';
|
|
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
|
import {localUserSettings} from '../modules/user-settings.ts';
|
|
|
|
function updateIssueCount(card: HTMLElement): void {
|
|
const parent = card.parentElement!;
|
|
const count = parent.querySelectorAll('.issue-card').length;
|
|
parent.querySelector('.project-column-issue-count')!.textContent = String(count);
|
|
}
|
|
|
|
async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise<void> {
|
|
updateIssueCount(from);
|
|
updateIssueCount(to);
|
|
|
|
const prevCard = item.previousElementSibling;
|
|
const resp = await performFetchActionRequest(item, {
|
|
method: 'POST',
|
|
url: `${to.getAttribute('data-url')}/move`,
|
|
data: {
|
|
issueID: parseInt(item.getAttribute('data-issue')!),
|
|
prevIssueID: prevCard ? parseInt(prevCard.getAttribute('data-issue')!) : 0,
|
|
},
|
|
});
|
|
if (!resp && oldIndex !== undefined) from.insertBefore(item, from.children[oldIndex]);
|
|
}
|
|
|
|
function initRepoProjectSortable(): void {
|
|
// the HTML layout is: #project-board.board > .project-column .cards > .issue-card
|
|
const mainBoard = document.querySelector<HTMLElement>('#project-board')!;
|
|
let boardColumns = mainBoard.querySelectorAll<HTMLElement>('.project-column');
|
|
createSortable(mainBoard, {
|
|
group: 'project-column',
|
|
draggable: '.project-column',
|
|
handle: '.project-column-header',
|
|
delayOnTouchOnly: true,
|
|
delay: 500,
|
|
onSort: async () => {
|
|
boardColumns = mainBoard.querySelectorAll<HTMLElement>('.project-column');
|
|
|
|
const columnSorting = {
|
|
columns: Array.from(boardColumns, (column, i) => ({
|
|
columnID: parseInt(column.getAttribute('data-id')!),
|
|
sorting: i,
|
|
})),
|
|
};
|
|
|
|
try {
|
|
await POST(mainBoard.getAttribute('data-url')!, {
|
|
data: columnSorting,
|
|
});
|
|
} catch (error) {
|
|
console.error(error);
|
|
}
|
|
},
|
|
});
|
|
|
|
for (const boardColumn of boardColumns) {
|
|
const boardCardList = boardColumn.querySelector<HTMLElement>('.cards')!;
|
|
createSortable(boardCardList, {
|
|
group: 'shared',
|
|
onAdd: moveIssue,
|
|
onUpdate: moveIssue,
|
|
delayOnTouchOnly: true,
|
|
delay: 500,
|
|
});
|
|
}
|
|
}
|
|
|
|
function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
|
|
const elModal = document.querySelector<HTMLDialogElement>('#project-column-modal-edit')!;
|
|
const elForm = elModal.querySelector<HTMLFormElement>('form')!;
|
|
|
|
const elColumnId = elForm.querySelector<HTMLInputElement>('input[name="id"]')!;
|
|
const elColumnTitle = elForm.querySelector<HTMLInputElement>('input[name="title"]')!;
|
|
const elColumnColor = elForm.querySelector<HTMLInputElement>('input[name="color"]')!;
|
|
|
|
const attrDataColumnId = 'data-modal-project-column-id';
|
|
const attrDataColumnTitle = 'data-modal-project-column-title-input';
|
|
const attrDataColumnColor = 'data-modal-project-column-color-input';
|
|
|
|
// the "new" button is not in project board, so need to query from document
|
|
queryElems(document, '.show-project-column-modal-edit', (el) => {
|
|
el.addEventListener('click', () => {
|
|
elColumnId.value = el.getAttribute(attrDataColumnId)!;
|
|
elColumnTitle.value = el.getAttribute(attrDataColumnTitle)!;
|
|
elColumnColor.value = el.getAttribute(attrDataColumnColor)!;
|
|
elColumnColor.dispatchEvent(new Event('input', {bubbles: true})); // trigger the color picker
|
|
});
|
|
});
|
|
|
|
elForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const columnId = elColumnId.value;
|
|
const actionBaseLink = elForm.getAttribute('data-action-base-link');
|
|
|
|
const formData = new FormData(elForm);
|
|
const formLink = columnId ? `${actionBaseLink}/${columnId}` : `${actionBaseLink}/columns/new`;
|
|
const formMethod = columnId ? 'PUT' : 'POST';
|
|
|
|
try {
|
|
elForm.classList.add('is-loading');
|
|
const resp = await performFetchActionRequest(elForm, {url: formLink, method: formMethod, data: formData});
|
|
if (!resp) return;
|
|
if (!columnId) {
|
|
window.location.reload(); // newly added column, need to reload the page
|
|
return;
|
|
}
|
|
|
|
// update the newly saved column title and color in the project board (to avoid reload)
|
|
const elEditButton = writableProjectBoard.querySelector<HTMLButtonElement>(`.show-project-column-modal-edit[${CSS.escape(attrDataColumnId)}="${CSS.escape(columnId)}"]`)!;
|
|
elEditButton.setAttribute(attrDataColumnTitle, elColumnTitle.value);
|
|
elEditButton.setAttribute(attrDataColumnColor, elColumnColor.value);
|
|
|
|
const elBoardColumn = writableProjectBoard.querySelector<HTMLElement>(`.project-column[data-id="${CSS.escape(columnId)}"]`)!;
|
|
const elBoardColumnTitle = elBoardColumn.querySelector<HTMLElement>(`.project-column-title-text`)!;
|
|
elBoardColumnTitle.textContent = elColumnTitle.value;
|
|
if (elColumnColor.value) {
|
|
const textColor = contrastColor(elColumnColor.value);
|
|
elBoardColumn.style.setProperty('background', elColumnColor.value, 'important');
|
|
elBoardColumn.style.setProperty('color', textColor, 'important');
|
|
queryElemChildren(elBoardColumn, '.divider', (divider: HTMLElement) => divider.style.color = textColor);
|
|
} else {
|
|
elBoardColumn.style.removeProperty('background');
|
|
elBoardColumn.style.removeProperty('color');
|
|
queryElemChildren(elBoardColumn, '.divider', (divider: HTMLElement) => divider.style.removeProperty('color'));
|
|
}
|
|
|
|
hideModal(elModal);
|
|
} finally {
|
|
elForm.classList.remove('is-loading');
|
|
}
|
|
});
|
|
}
|
|
|
|
function initRepoProjectToggleFullScreen(elProjectsView: HTMLElement): void {
|
|
const enterFullscreenBtn = document.querySelector('.screen-full');
|
|
const exitFullscreenBtn = document.querySelector('.screen-normal');
|
|
if (!enterFullscreenBtn || !exitFullscreenBtn) return;
|
|
|
|
const settingKey = 'projects-view-options';
|
|
type ProjectsViewOptions = {
|
|
fullScreen: boolean;
|
|
};
|
|
const opts = localUserSettings.getJsonObject<ProjectsViewOptions>(settingKey, {fullScreen: false});
|
|
const toggleFullscreenState = (isFullScreen: boolean) => {
|
|
toggleFullScreen(elProjectsView, isFullScreen);
|
|
toggleElem(enterFullscreenBtn, !isFullScreen);
|
|
toggleElem(exitFullscreenBtn, isFullScreen);
|
|
|
|
opts.fullScreen = isFullScreen;
|
|
localUserSettings.setJsonObject(settingKey, opts);
|
|
};
|
|
|
|
enterFullscreenBtn.addEventListener('click', () => toggleFullscreenState(true));
|
|
exitFullscreenBtn.addEventListener('click', () => toggleFullscreenState(false));
|
|
if (opts.fullScreen) {
|
|
// a temporary solution to remember the full screen state, not perfect,
|
|
// just make UX better than before, especially for users who need to change the label filter frequently and want to keep full screen mode.
|
|
toggleFullscreenState(true);
|
|
}
|
|
}
|
|
|
|
export function initRepoProjectsView(): void {
|
|
registerGlobalInitFunc('initRepoProjectsView', (elProjectsView) => {
|
|
initRepoProjectToggleFullScreen(elProjectsView);
|
|
|
|
const writableProjectBoard = document.querySelector('#project-board[data-project-board-writable="true"]');
|
|
if (!writableProjectBoard) return;
|
|
|
|
initRepoProjectSortable();
|
|
initRepoProjectColumnEdit(writableProjectBoard);
|
|
});
|
|
}
|