diff --git a/docs/README.md b/docs/README.md index 0c3e58a7..913e8afc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,6 +3,7 @@ - [Using the `hectic` Attic Cache](./attic-cache.md) - [Minecraft incident log](./minecraft-incidents.md) - [Project Zomboid backups](./project-zomboid-backups.md) +- [Gitea Kanban CLI](./gitea-kanban-cli.md) ## Gitea runner labels diff --git a/docs/gitea-kanban-cli.md b/docs/gitea-kanban-cli.md new file mode 100644 index 00000000..3d6077df --- /dev/null +++ b/docs/gitea-kanban-cli.md @@ -0,0 +1,126 @@ +# Gitea Kanban CLI + +`gitea-kanban` is the non-interactive interface for native Gitea Projects. +It shares its configuration, API client, models, permissions, and operations +with `gitea-kanban-tui`. + +## Installation + +The Nix package installs both binaries: + +```sh +nix build .#gitea-kanban-tui +./result/bin/gitea-kanban --help +./result/bin/gitea-kanban-tui --help +``` + +For development: + +```sh +cargo run --manifest-path package/gitea-kanban-tui/Cargo.toml \ + --bin gitea-kanban -- --help +``` + +## Configuration + +Configuration can be supplied with flags or environment variables. A project +selector is required, and exactly one selector must be supplied. + +| Flag | Environment | Description | +| --- | --- | --- | +| `--url` | `GITEA_URL` | Gitea base URL | +| `--token-file PATH` | `GITEA_TOKEN_FILE` | File containing API token | +| — | `GITEA_TOKEN` | Token fallback when no token file is configured | +| `--project NAME` | `GITEA_PROJECT` | Exact, case-sensitive project name | +| `--project-id ID` | `GITEA_PROJECT_ID` | Project ID; useful for duplicate names | +| `OWNER` | `GITEA_OWNER` | Repository owner | +| `REPO` | `GITEA_REPO` | Repository name | + +Precedence: + +1. Command-line flags and positional arguments. +2. Token file from `--token-file` or `GITEA_TOKEN_FILE`. +3. `GITEA_TOKEN` when no token file is configured. + +Token files must be regular files and must not be group- or world-readable on +Unix. Remote URLs must use HTTPS. Plain HTTP is accepted only for literal +loopback IP addresses (`127.0.0.1` or `::1`) during local development. + +Example: + +```sh +export GITEA_URL=https://gitea.example +export GITEA_TOKEN_FILE="$HOME/.config/gitea/token" +gitea-kanban --project Kanban owner repo board +``` + +## Commands + +### `board` + +Print selected project, columns, and issues: + +```sh +gitea-kanban --project Kanban owner repo board +``` + +Output includes issue numbers and titles. Server-provided terminal control +characters are removed before output. + +### `create` + +Create an issue and assign it to selected project. The issue starts in the +project's default column. + +```sh +gitea-kanban --project Kanban owner repo create \ + --title "Fix deployment" \ + --body "Investigate failed rollout" +``` + +### `edit` + +Replace issue title and body. Issue must be assigned to selected project. + +```sh +gitea-kanban --project Kanban owner repo edit 42 \ + --title "Updated title" \ + --body "Updated description" +``` + +### `move` + +Move an issue to a project column. `ISSUE_ID` is the global Gitea issue ID, +not the repository issue number. `--sorting` is optional. + +```sh +gitea-kanban --project Kanban owner repo move 123 --column-id 7 +gitea-kanban --project Kanban owner repo move 123 --column-id 7 --sorting 2 +``` + +### `delete` + +Delete an issue assigned to selected project. This is irreversible and requires +explicit confirmation with `--yes`: + +```sh +gitea-kanban --project Kanban owner repo delete 42 --yes +``` + +Deletion also requires repository administrator permission in this Gitea fork. + +## Permissions and server behavior + +- The repository Projects unit must be enabled. +- Project operations require repository Projects write permission. +- Issue create/edit follows Gitea issue permissions. +- Delete requires repository administrator permission. +- Closed projects, archived repositories, disabled Projects units, foreign + project IDs, and issues outside selected project are rejected. +- API failures return a non-zero exit status. Authentication and HTTP status + guidance is shown without printing server response bodies or tokens. + +## Related interface + +Use `gitea-kanban-tui` for keyboard navigation over the same native Projects +API. Both binaries use the same configuration and server-side authorization. diff --git a/package/gitea/source/options/locale/locale_en-US.json b/package/gitea/source/options/locale/locale_en-US.json index 73ebeb71..fb84c9c8 100644 --- a/package/gitea/source/options/locale/locale_en-US.json +++ b/package/gitea/source/options/locale/locale_en-US.json @@ -1396,6 +1396,16 @@ "repo.projects.card_type.desc": "Card Previews", "repo.projects.card_type.images_and_text": "Images and Text", "repo.projects.card_type.text_only": "Text Only", + "repo.projects.filter": "Filter cards", + "repo.projects.filter_placeholder": "Filter cards (for example, assignee:alice label:bug)", + "repo.projects.filter_clear": "Clear", + "repo.projects.filter.no_results": "No project cards match this filter.", + "repo.projects.filter.error.unknown": "Unknown qualifier: %s", + "repo.projects.filter.error.malformed": "Malformed filter: %s", + "repo.projects.filter.error.unclosed": "Unclosed quote in filter.", + "repo.projects.group_by": "Group by", + "repo.projects.group_by.none": "None", + "repo.projects.group_by.no_value": "No value", "repo.issues.desc": "Organize bug reports, tasks and milestones.", "repo.issues.filter_assignees": "Filter Assignee", "repo.issues.filter_milestones": "Filter Milestone", diff --git a/package/gitea/source/routers/web/org/projects.go b/package/gitea/source/routers/web/org/projects.go index 3c0048b2..c5862e09 100644 --- a/package/gitea/source/routers/web/org/projects.go +++ b/package/gitea/source/routers/web/org/projects.go @@ -457,6 +457,8 @@ func ViewProject(ctx *context.Context) { } ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers) ctx.Data["AssigneeID"] = assigneeID + ctx.Data["ProjectFilter"] = ctx.FormTrim("filter") + ctx.Data["ProjectGroupBy"] = ctx.FormTrim("group_by") project.RenderedContent = templates.NewRenderUtils(ctx).MarkdownToHtml(project.Description) ctx.Data["LinkedPRs"] = linkedPrsMap diff --git a/package/gitea/source/routers/web/repo/projects.go b/package/gitea/source/routers/web/repo/projects.go index 7abcd372..bd8f16dd 100644 --- a/package/gitea/source/routers/web/repo/projects.go +++ b/package/gitea/source/routers/web/repo/projects.go @@ -408,6 +408,8 @@ func ViewProject(ctx *context.Context) { } ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers) ctx.Data["AssigneeID"] = assigneeID + ctx.Data["ProjectFilter"] = ctx.FormTrim("filter") + ctx.Data["ProjectGroupBy"] = ctx.FormTrim("group_by") renderMilestones(ctx) if ctx.Written() { diff --git a/package/gitea/source/templates/projects/view.tmpl b/package/gitea/source/templates/projects/view.tmpl index 8f4f1d60..10c1d92c 100644 --- a/package/gitea/source/templates/projects/view.tmpl +++ b/package/gitea/source/templates/projects/view.tmpl @@ -5,7 +5,7 @@

{{.Project.Title}}

+
+
+ + + +
+
+ + +
+ + +
+
{{$.Project.RenderedContent}} @@ -77,8 +104,9 @@
-
+
{{range .Columns}} + {{$columnTitle := .Title}}
@@ -122,7 +150,18 @@
{{range (index $.IssuesMap .ID)}} -
+
{{template "repo/issue/card" (dict "Issue" . "Page" $)}}
{{end}} diff --git a/package/gitea/source/tests/e2e/issue-project.test.ts b/package/gitea/source/tests/e2e/issue-project.test.ts index 10466cd8..544bf7b3 100644 --- a/package/gitea/source/tests/e2e/issue-project.test.ts +++ b/package/gitea/source/tests/e2e/issue-project.test.ts @@ -28,6 +28,85 @@ test('assign issue to project and change column', async ({page}) => { await apiDeleteRepo(page.request, user, repoName); }); +test('refreshes project board after issue project changes in another tab', async ({page, context}) => { + const repoName = `e2e-project-refresh-${randomString(8)}`; + const user = env.GITEA_TEST_E2E_USER; + await login(page); + await apiCreateRepo(page.request, {name: repoName, autoInit: false}); + + try { + const project = await createProject(page, { + owner: user, + repo: repoName, + title: 'Refresh Board', + }); + const issue = await apiCreateIssue(page.request, { + owner: user, + repo: repoName, + title: 'Refresh board issue', + projects: [project.id], + }); + + await page.goto(`/${user}/${repoName}/projects/${project.id}`); + await expect(page.locator('.issue-card')).toContainText('Refresh board issue'); + + await using issuePage = await context.newPage(); + await issuePage.goto(`/${user}/${repoName}/issues/${issue.index}`); + await issuePage.locator('.sidebar-project-combo > .ui.dropdown').click(); + await issuePage.locator(`.sidebar-project-combo > .ui.dropdown .item[data-value="${project.id}"]`).click(); + await issuePage.locator('.issue-content-left').click(); + await expect(issuePage.locator(`.item.sidebar-project-card:has-text("Refresh Board")`)).toBeHidden(); + + await page.bringToFront(); + await expect(page.locator('.issue-card')).not.toContainText('Refresh board issue'); + } finally { + await apiDeleteRepo(page.request, user, repoName); + } +}); + +test('filters and groups project cards without navigation', async ({page}) => { + const repoName = `e2e-project-filter-${randomString(8)}`; + const user = env.GITEA_TEST_E2E_USER; + await login(page); + await apiCreateRepo(page.request, {name: repoName, autoInit: false}); + + try { + const project = await createProject(page, { + owner: user, + repo: repoName, + title: 'Filter Board', + }); + await Promise.all([ + apiCreateIssue(page.request, {owner: user, repo: repoName, title: 'Alpha card', projects: [project.id]}), + apiCreateIssue(page.request, {owner: user, repo: repoName, title: 'Beta card', projects: [project.id]}), + ]); + + await page.goto(`/${user}/${repoName}/projects/${project.id}`); + const boardUrl = page.url(); + await page.locator('[data-project-filter-input]').fill('state:open title:Alpha'); + await expect(page.locator('.issue-card')).toHaveCount(1); + await expect(page.locator('.issue-card')).toContainText('Alpha card'); + await expect(page.locator('.issue-card')).not.toContainText('Beta card'); + await expect(page).toHaveURL(`${boardUrl}?filter=state%3Aopen+title%3AAlpha`); + + await page.locator('[data-project-filter-input]').fill('state:open title:Beta'); + await expect(page.locator('.issue-card')).toHaveCount(1); + await expect(page.locator('.issue-card')).toContainText('Beta card'); + await expect(page.locator('.issue-card')).not.toContainText('Alpha card'); + + await page.locator('[data-project-filter-clear]').click(); + await expect(page.locator('.issue-card')).toHaveCount(2); + await expect(page).toHaveURL(boardUrl); + + await page.locator('[data-project-group-by]').selectOption('assignee'); + await expect(page.locator('.project-card-group-header')).toHaveText('No value'); + await expect(page.locator('.project-card-group-cards .issue-card')).toHaveCount(2); + await expect(page).toHaveURL(`${boardUrl}?group_by=assignee`); + } finally { + await apiDeleteRepo(page.request, user, repoName); + } +}); + test('create a project', async ({page}) => { const repoName = `e2e-project-repo-${Date.now()}`; const projectTitle = 'Test Project'; diff --git a/package/gitea/source/web_src/css/features/projects.css b/package/gitea/source/web_src/css/features/projects.css index 25cb530f..3152c39d 100644 --- a/package/gitea/source/web_src/css/features/projects.css +++ b/package/gitea/source/web_src/css/features/projects.css @@ -19,6 +19,50 @@ margin: 0; } +.project-board-controls { + display: flex; + align-items: center; + gap: 0.5em; + flex-wrap: wrap; + margin-bottom: 0.5em; +} + +.project-filter-input { + flex: 1 1 18rem; +} + +.project-filter-input input { + min-width: 0; +} + +.project-group-select { + display: flex; + align-items: center; + gap: 0.5em; +} + +.project-group-select select { + min-width: 10rem; +} + +.project-card-group { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.project-card-group-header { + margin: 0.5rem 0 0.25rem; + padding-bottom: 0.25rem; + border-bottom: 1px solid var(--color-secondary); +} + +.project-card-group-cards { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + .project-column { flex: 0 0 auto; display: flex; diff --git a/package/gitea/source/web_src/js/features/repo-projects.test.ts b/package/gitea/source/web_src/js/features/repo-projects.test.ts new file mode 100644 index 00000000..5fafaa8c --- /dev/null +++ b/package/gitea/source/web_src/js/features/repo-projects.test.ts @@ -0,0 +1,48 @@ +import {matchesProjectFilter, parseProjectFilter, projectCardGroupValue} from './repo-projects.ts'; + +const projectCard = { + assignees: ['alice', 'bob'], + author: 'carol', + labels: ['bug', 'needs review'], + milestone: 'Release 1', + repository: 'gitea/gitea', + state: 'open', + status: 'In Progress', + title: 'Fix project board', + type: 'pull', +}; + +describe('project card filters', () => { + test('parses quoted OR values, aliases, repeated terms, and negation', () => { + const parsed = parseProjectFilter('label:bug,"needs review" assignee:alice -author:dave is:pr'); + + expect(parsed.error).toBeUndefined(); + expect(parsed.terms).toEqual([ + {field: 'label', negated: false, values: ['bug', 'needs review']}, + {field: 'assignee', negated: false, values: ['alice']}, + {field: 'author', negated: true, values: ['dave']}, + {field: 'type', negated: false, values: ['pull']}, + ]); + expect(matchesProjectFilter(projectCard, parsed.terms)).toBe(true); + expect(matchesProjectFilter(projectCard, parseProjectFilter('assignee:alice assignee:bob').terms)).toBe(true); + }); + + test('reports malformed and unknown qualifiers without filtering cards', () => { + expect(parseProjectFilter('unknown:value').error).toEqual({kind: 'unknown', value: 'unknown'}); + expect(parseProjectFilter('label:"unfinished').error).toEqual({kind: 'unclosed'}); + expect(parseProjectFilter('label:').error).toEqual({kind: 'malformed', value: ''}); + expect(parseProjectFilter('label:bug,').error).toEqual({kind: 'malformed', value: 'bug,'}); + expect(parseProjectFilter('state:pending').error).toEqual({kind: 'malformed', value: 'pending'}); + }); + + test('matches repository aliases, status, state, type, and unqualified titles', () => { + expect(matchesProjectFilter(projectCard, parseProjectFilter('repository:gitea/gitea status:"In Progress" state:open type:pull board').terms)).toBe(true); + expect(matchesProjectFilter(projectCard, parseProjectFilter('-label:bug').terms)).toBe(false); + }); + + test('uses first multi-valued attribute for a non-duplicated group', () => { + expect(projectCardGroupValue(projectCard, 'assignee', 'No value')).toBe('alice'); + expect(projectCardGroupValue(projectCard, 'label', 'No value')).toBe('bug'); + expect(projectCardGroupValue({...projectCard, labels: []}, 'label', 'No value')).toBe('No value'); + }); +}); diff --git a/package/gitea/source/web_src/js/features/repo-projects.ts b/package/gitea/source/web_src/js/features/repo-projects.ts index 25846f67..b54776d2 100644 --- a/package/gitea/source/web_src/js/features/repo-projects.ts +++ b/package/gitea/source/web_src/js/features/repo-projects.ts @@ -8,6 +8,298 @@ 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; @@ -15,6 +307,14 @@ function updateIssueCount(card: HTMLElement): void { } 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); @@ -48,7 +348,9 @@ async function initRepoProjectSortable(): Promise { 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 = { @@ -76,6 +378,7 @@ async function initRepoProjectSortable(): Promise { onUpdate: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises delayOnTouchOnly: true, delay: 500, + onMove: () => !isProjectBoardViewModified(), }); } } @@ -127,6 +430,9 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void { 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'); @@ -173,9 +479,35 @@ function initRepoProjectToggleFullScreen(elProjectsView: HTMLElement): void { } } +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;