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> = { 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('.project-column')!.querySelector('.project-column-issue-count')!.textContent = String(count); } function initRepoProjectFilters(elProjectsView: HTMLElement): void { const board = elProjectsView.querySelector('#project-board')!; const input = elProjectsView.querySelector('[data-project-filter-input]')!; const clear = elProjectsView.querySelector('[data-project-filter-clear]')!; const select = elProjectsView.querySelector('[data-project-group-by]')!; const errorElement = elProjectsView.querySelector('[data-project-filter-error]')!; const emptyElement = elProjectsView.querySelector('[data-project-filter-empty]')!; const lists = Array.from(board.querySelectorAll('.project-column > .cards')); const cardsByList = new Map(); let viewWasModified = false; const snapshotCards = () => { for (const list of lists) { cardsByList.set(list, Array.from(list.querySelectorAll('.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(); 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('#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 { 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 { // the HTML layout is: #project-board.board > .project-column .cards > .issue-card const mainBoard = document.querySelector('#project-board')!; let boardColumns = mainBoard.querySelectorAll('.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('.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('.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('.ui.modal#project-column-modal-edit')!; const elForm = elModal.querySelector('form')!; const elColumnId = elForm.querySelector('input[name="id"]')!; const elColumnTitle = elForm.querySelector('input[name="title"]')!; const elColumnColor = elForm.querySelector('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(`.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(`.project-column[data-id="${CSS.escape(columnId)}"]`)!; const elBoardColumnTitle = elBoardColumn.querySelector(`.project-column-title-text`)!; elBoardColumnTitle.textContent = elColumnTitle.value; for (const card of elBoardColumn.querySelectorAll('.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(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); }); }