Files
hearth/package/gitea/source/web_src/js/features/repo-projects.ts
T
yukkop c93dee27b3
runner nix smoke / nix label and flake smoke (push) Failing after 1m16s
feat: gitea: queries
2026-09-29 17:23:48 +00:00

519 lines
22 KiB
TypeScript

import {contrastColor} from '../utils/color.ts';
import {createSortable} from '../modules/sortable.ts';
import {POST, request} from '../modules/fetch.ts';
import {hideFomanticModal} from '../modules/fomantic/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';
type ProjectFilterField = 'assignee' | 'author' | 'label' | 'milestone' | 'repo' | 'state' | 'status' | 'title' | 'type';
export type ProjectGroupBy = 'none' | 'assignee' | 'label' | 'milestone' | 'repository' | 'state' | 'status' | 'type';
export type ProjectFilterParseError = {
kind: 'malformed' | 'unclosed' | 'unknown';
value?: string;
};
export type ProjectFilterTerm = {
field: ProjectFilterField;
negated: boolean;
values: string[];
};
export type ProjectFilterParseResult = {
error?: ProjectFilterParseError;
terms: ProjectFilterTerm[];
};
export type ProjectCardMetadata = {
assignees: string[];
author: string;
labels: string[];
milestone: string;
repository: string;
state: string;
status: string;
title: string;
type: string;
};
type ProjectCard = {
element: HTMLElement;
list: HTMLElement;
metadata: ProjectCardMetadata;
};
const projectFilterAliases: Readonly<Record<string, ProjectFilterField | 'is'>> = {
assignee: 'assignee', author: 'author', column: 'status', is: 'is', label: 'label', milestone: 'milestone',
repo: 'repo', repository: 'repo', state: 'state', status: 'status', type: 'type',
};
function tokenizeProjectFilter(query: string): {error?: ProjectFilterParseError; tokens: string[]} {
const tokens: string[] = [];
let current = '';
let quoted = false;
for (const char of query) {
if (char === '"') {
quoted = !quoted;
current += char;
} else if (/\s/.test(char) && !quoted) {
if (current) tokens.push(current);
current = '';
} else {
current += char;
}
}
if (quoted) return {error: {kind: 'unclosed'}, tokens: []};
if (current) tokens.push(current);
return {tokens};
}
function parseProjectFilterValues(value: string): {error?: ProjectFilterParseError; values: string[]} {
if (!value || value.startsWith(',') || value.endsWith(',')) return {error: {kind: 'malformed', value}, values: []};
const values: string[] = [];
let offset = 0;
while (offset < value.length) {
if (value[offset] === ',') return {error: {kind: 'malformed', value}, values: []};
if (value[offset] === '"') {
const endQuote = value.indexOf('"', offset + 1);
if (endQuote === -1 || endQuote === offset + 1) return {error: {kind: 'malformed', value}, values: []};
values.push(value.slice(offset + 1, endQuote).toLowerCase());
offset = endQuote + 1;
if (offset < value.length && value[offset] !== ',') return {error: {kind: 'malformed', value}, values: []};
} else {
const comma = value.indexOf(',', offset);
const end = comma === -1 ? value.length : comma;
const current = value.slice(offset, end).trim();
if (!current || current.includes('"')) return {error: {kind: 'malformed', value}, values: []};
values.push(current.toLowerCase());
offset = end;
}
if (offset < value.length) offset++;
}
return {values};
}
function normalizeProjectFilter(field: ProjectFilterField | 'is', values: string[]): {error?: ProjectFilterParseError; field?: ProjectFilterField; values?: string[]} {
if (field === 'is') {
const fields = values.map((value) => value === 'open' || value === 'closed' ? 'state' : value === 'issue' || value === 'pr' || value === 'pull' ? 'type' : undefined);
const normalizedField = fields[0];
if (!normalizedField || fields.some((value) => value !== normalizedField)) return {error: {kind: 'malformed', value: values.join(',')}};
return {field: normalizedField, values: values.map((value) => value === 'pr' ? 'pull' : value)};
}
if (field === 'state' && values.some((value) => value !== 'open' && value !== 'closed')) return {error: {kind: 'malformed', value: values.join(',')}};
if (field === 'type' && values.some((value) => value !== 'issue' && value !== 'pr' && value !== 'pull')) return {error: {kind: 'malformed', value: values.join(',')}};
return {field, values: values.map((value) => value === 'pr' ? 'pull' : value)};
}
export function parseProjectFilter(query: string): ProjectFilterParseResult {
const tokenized = tokenizeProjectFilter(query.trim());
if (tokenized.error) return {error: tokenized.error, terms: []};
const terms: ProjectFilterTerm[] = [];
for (const rawToken of tokenized.tokens) {
const negated = rawToken.startsWith('-');
const token = negated ? rawToken.slice(1) : rawToken;
if (!token) return {error: {kind: 'malformed', value: rawToken}, terms: []};
const separator = token.indexOf(':');
if (separator === -1) {
const values = parseProjectFilterValues(token);
if (values.error) return {error: values.error, terms: []};
terms.push({field: 'title', negated, values: values.values});
continue;
}
const qualifier = token.slice(0, separator).toLowerCase();
const field = projectFilterAliases[qualifier];
if (!field) return {error: {kind: 'unknown', value: qualifier}, terms: []};
const values = parseProjectFilterValues(token.slice(separator + 1));
if (values.error) return {error: values.error, terms: []};
const normalized = normalizeProjectFilter(field, values.values);
if (normalized.error) return {error: normalized.error, terms: []};
terms.push({field: normalized.field!, negated, values: normalized.values!});
}
return {terms};
}
function parseProjectCardValues(value: string | null): string[] {
if (!value) return [];
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed) && parsed.every((item) => typeof item === 'string') ? parsed : [];
} catch (error) {
console.warn('Invalid project card metadata', error);
return [];
}
}
function projectCardMetadata(element: HTMLElement): ProjectCardMetadata {
return {
assignees: parseProjectCardValues(element.getAttribute('data-project-card-assignees')),
author: element.getAttribute('data-project-card-author') || '', labels: parseProjectCardValues(element.getAttribute('data-project-card-labels')),
milestone: element.getAttribute('data-project-card-milestone') || '', repository: element.getAttribute('data-project-card-repository') || '',
state: element.getAttribute('data-project-card-state') || '', status: element.getAttribute('data-project-card-status') || '',
title: element.getAttribute('data-project-card-title') || '', type: element.getAttribute('data-project-card-type') || '',
};
}
function projectCardValues(metadata: ProjectCardMetadata, field: ProjectFilterField): string[] {
switch (field) {
case 'assignee': return metadata.assignees;
case 'author': return [metadata.author];
case 'label': return metadata.labels;
case 'milestone': return [metadata.milestone];
case 'repo': return [metadata.repository];
case 'state': return [metadata.state];
case 'status': return [metadata.status];
case 'title': return [metadata.title];
case 'type': return [metadata.type];
}
}
export function matchesProjectFilter(metadata: ProjectCardMetadata, terms: ProjectFilterTerm[]): boolean {
return terms.every((term) => {
const matches = term.values.some((value) => projectCardValues(metadata, term.field).some((cardValue) => {
const normalizedCardValue = cardValue.toLowerCase();
if (term.field === 'title') return normalizedCardValue.includes(value);
if (term.field === 'repo') return normalizedCardValue === value || normalizedCardValue.endsWith(`/${value}`);
return normalizedCardValue === value;
}));
return term.negated ? !matches : matches;
});
}
function normalizeProjectGroupBy(groupBy: string): ProjectGroupBy {
if (groupBy === 'column') return 'status';
if (groupBy === 'repo') return 'repository';
if (groupBy === 'assignee' || groupBy === 'label' || groupBy === 'milestone' || groupBy === 'repository' || groupBy === 'state' || groupBy === 'status' || groupBy === 'type') return groupBy;
return 'none';
}
export function projectCardGroupValue(metadata: ProjectCardMetadata, groupBy: ProjectGroupBy, noValue: string): string {
// Multi-valued labels and assignees use their first loaded value so one movable card is never duplicated.
switch (groupBy) {
case 'assignee': return metadata.assignees[0] || noValue;
case 'label': return metadata.labels[0] || noValue;
case 'milestone': return metadata.milestone || noValue;
case 'repository': return metadata.repository || noValue;
case 'state': return metadata.state || noValue;
case 'type': return metadata.type || noValue;
default: return noValue;
}
}
function createProjectCardGroup(title: string, cards: ProjectCard[]): HTMLElement {
const group = document.createElement('section');
group.classList.add('project-card-group');
const header = document.createElement('h3');
header.classList.add('project-card-group-header');
header.textContent = title;
const groupCards = document.createElement('div');
groupCards.classList.add('project-card-group-cards');
groupCards.append(...cards.map((card) => card.element));
group.append(header, groupCards);
return group;
}
function setProjectIssueCount(list: HTMLElement, count: number): void {
list.closest<HTMLElement>('.project-column')!.querySelector<HTMLElement>('.project-column-issue-count')!.textContent = String(count);
}
function initRepoProjectFilters(elProjectsView: HTMLElement): void {
const board = elProjectsView.querySelector<HTMLElement>('#project-board')!;
const input = elProjectsView.querySelector<HTMLInputElement>('[data-project-filter-input]')!;
const clear = elProjectsView.querySelector<HTMLButtonElement>('[data-project-filter-clear]')!;
const select = elProjectsView.querySelector<HTMLSelectElement>('[data-project-group-by]')!;
const errorElement = elProjectsView.querySelector<HTMLElement>('[data-project-filter-error]')!;
const emptyElement = elProjectsView.querySelector<HTMLElement>('[data-project-filter-empty]')!;
const lists = Array.from(board.querySelectorAll<HTMLElement>('.project-column > .cards'));
const cardsByList = new Map<HTMLElement, ProjectCard[]>();
let viewWasModified = false;
const snapshotCards = () => {
for (const list of lists) {
cardsByList.set(list, Array.from(list.querySelectorAll<HTMLElement>('.issue-card'), (element) => ({element, list, metadata: projectCardMetadata(element)})));
}
};
const initializeFromUrl = () => {
const url = new URL(window.location.href);
input.value = url.searchParams.get('filter') || '';
select.value = normalizeProjectGroupBy(url.searchParams.get('group_by') || select.value);
};
const apply = (replaceUrl: boolean) => {
const parsed = parseProjectFilter(input.value);
const groupBy = normalizeProjectGroupBy(select.value);
select.value = groupBy;
if (!viewWasModified) snapshotCards();
board.setAttribute('data-project-board-view-modified', String(Boolean(input.value.trim()) || (groupBy !== 'none' && groupBy !== 'status')));
errorElement.hidden = !parsed.error;
errorElement.textContent = parsed.error ? (errorElement.getAttribute(`data-project-filter-error-${parsed.error.kind}`)!.replace('%s', parsed.error.value || '')) : '';
let matchingCount = 0;
for (const list of lists) {
const cards = cardsByList.get(list)!;
for (const card of cards) {
card.metadata = projectCardMetadata(card.element);
}
const matchingCards = parsed.error ? cards : cards.filter((card) => matchesProjectFilter(card.metadata, parsed.terms));
matchingCount += matchingCards.length;
setProjectIssueCount(list, matchingCards.length);
if (groupBy === 'none' || groupBy === 'status') {
list.replaceChildren(...matchingCards.map((card) => card.element));
continue;
}
const groups = new Map<string, ProjectCard[]>();
for (const card of matchingCards) {
const title = projectCardGroupValue(card.metadata, groupBy, board.getAttribute('data-project-group-no-value')!);
const groupedCards = groups.get(title);
if (groupedCards) groupedCards.push(card);
else groups.set(title, [card]);
}
list.replaceChildren(...Array.from(groups, ([title, groupedCards]) => createProjectCardGroup(title, groupedCards)));
}
emptyElement.hidden = matchingCount !== 0 || Boolean(parsed.error);
viewWasModified = Boolean(input.value.trim()) || (groupBy !== 'none' && groupBy !== 'status');
if (!replaceUrl) return;
const url = new URL(window.location.href);
if (input.value.trim()) url.searchParams.set('filter', input.value);
else url.searchParams.delete('filter');
if (groupBy === 'none') url.searchParams.delete('group_by');
else url.searchParams.set('group_by', groupBy);
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`);
};
initializeFromUrl();
apply(false);
input.addEventListener('input', () => apply(true));
select.addEventListener('change', () => apply(true));
clear.addEventListener('click', () => {
input.value = '';
input.focus();
apply(true);
});
window.addEventListener('popstate', () => {
initializeFromUrl();
apply(false);
});
}
function isProjectBoardViewModified(): boolean {
return document.querySelector<HTMLElement>('#project-board')?.getAttribute('data-project-board-view-modified') === 'true';
}
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> {
if (isProjectBoardViewModified()) {
if (oldIndex !== undefined) {
const siblings = Array.from(from.children).filter((child) => child !== item);
from.insertBefore(item, siblings[oldIndex] || null);
}
return;
}
const columnCards = to.querySelectorAll('.issue-card');
updateIssueCount(from);
updateIssueCount(to);
const columnSorting = {
issues: Array.from(columnCards, (card, i) => ({
issueID: parseInt(card.getAttribute('data-issue')!),
sorting: i,
})),
};
try {
await POST(`${to.getAttribute('data-url')}/move`, {
data: columnSorting,
});
} catch (error) {
console.error(error);
if (oldIndex !== undefined) {
from.insertBefore(item, from.children[oldIndex]);
}
}
}
async function initRepoProjectSortable(): Promise<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,
onMove: () => !isProjectBoardViewModified(),
onSort: async () => { // eslint-disable-line @typescript-eslint/no-misused-promises
if (isProjectBoardViewModified()) return;
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, // eslint-disable-line @typescript-eslint/no-misused-promises
onUpdate: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises
delayOnTouchOnly: true,
delay: 500,
onMove: () => !isProjectBoardViewModified(),
});
}
}
function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
const elModal = document.querySelector<HTMLElement>('.ui.modal#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');
await request(formLink, {method: formMethod, data: formData});
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;
for (const card of elBoardColumn.querySelectorAll<HTMLElement>('.issue-card')) {
card.setAttribute('data-project-card-status', 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'));
}
hideFomanticModal(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);
}
}
function initRepoProjectRefresh(): void {
let wasHidden = document.hidden;
let isReloading = false;
const reloadProject = () => {
if (isReloading) return;
isReloading = true;
window.location.reload();
};
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
wasHidden = true;
} else if (wasHidden) {
reloadProject();
}
});
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
reloadProject();
}
});
}
export function initRepoProjectsView(): void {
registerGlobalInitFunc('initRepoProjectsView', (elProjectsView) => {
initRepoProjectRefresh();
initRepoProjectToggleFullScreen(elProjectsView);
initRepoProjectFilters(elProjectsView);
const writableProjectBoard = document.querySelector('#project-board[data-project-board-writable="true"]');
if (!writableProjectBoard) return;
initRepoProjectSortable(); // no await
initRepoProjectColumnEdit(writableProjectBoard);
});
}