This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
- [Using the `hectic` Attic Cache](./attic-cache.md)
|
- [Using the `hectic` Attic Cache](./attic-cache.md)
|
||||||
- [Minecraft incident log](./minecraft-incidents.md)
|
- [Minecraft incident log](./minecraft-incidents.md)
|
||||||
- [Project Zomboid backups](./project-zomboid-backups.md)
|
- [Project Zomboid backups](./project-zomboid-backups.md)
|
||||||
|
- [Gitea Kanban CLI](./gitea-kanban-cli.md)
|
||||||
|
|
||||||
## Gitea runner labels
|
## Gitea runner labels
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1396,6 +1396,16 @@
|
|||||||
"repo.projects.card_type.desc": "Card Previews",
|
"repo.projects.card_type.desc": "Card Previews",
|
||||||
"repo.projects.card_type.images_and_text": "Images and Text",
|
"repo.projects.card_type.images_and_text": "Images and Text",
|
||||||
"repo.projects.card_type.text_only": "Text Only",
|
"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.desc": "Organize bug reports, tasks and milestones.",
|
||||||
"repo.issues.filter_assignees": "Filter Assignee",
|
"repo.issues.filter_assignees": "Filter Assignee",
|
||||||
"repo.issues.filter_milestones": "Filter Milestone",
|
"repo.issues.filter_milestones": "Filter Milestone",
|
||||||
|
|||||||
@@ -457,6 +457,8 @@ func ViewProject(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
|
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
|
||||||
ctx.Data["AssigneeID"] = assigneeID
|
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)
|
project.RenderedContent = templates.NewRenderUtils(ctx).MarkdownToHtml(project.Description)
|
||||||
ctx.Data["LinkedPRs"] = linkedPrsMap
|
ctx.Data["LinkedPRs"] = linkedPrsMap
|
||||||
|
|||||||
@@ -408,6 +408,8 @@ func ViewProject(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
|
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
|
||||||
ctx.Data["AssigneeID"] = assigneeID
|
ctx.Data["AssigneeID"] = assigneeID
|
||||||
|
ctx.Data["ProjectFilter"] = ctx.FormTrim("filter")
|
||||||
|
ctx.Data["ProjectGroupBy"] = ctx.FormTrim("group_by")
|
||||||
|
|
||||||
renderMilestones(ctx)
|
renderMilestones(ctx)
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<h2>{{.Project.Title}}</h2>
|
<h2>{{.Project.Title}}</h2>
|
||||||
<div class="tw-flex-1"></div>
|
<div class="tw-flex-1"></div>
|
||||||
<div class="list-header-filters ui secondary menu tw-m-0">
|
<div class="list-header-filters ui secondary menu tw-m-0">
|
||||||
{{$queryLink := QueryBuild "?" "labels" .SelectLabels "assignee" $.AssigneeID "milestone" $.MilestoneID "archived_labels" (Iif $.ShowArchivedLabels "true")}}
|
{{$queryLink := QueryBuild "?" "labels" .SelectLabels "assignee" $.AssigneeID "milestone" $.MilestoneID "archived_labels" (Iif $.ShowArchivedLabels "true") "filter" $.ProjectFilter "group_by" $.ProjectGroupBy}}
|
||||||
{{template "repo/issue/filter_item_label" dict "Labels" .Labels "QueryLink" $queryLink "SupportArchivedLabel" true}}
|
{{template "repo/issue/filter_item_label" dict "Labels" .Labels "QueryLink" $queryLink "SupportArchivedLabel" true}}
|
||||||
{{template "repo/issue/filter_item_user_assign" dict
|
{{template "repo/issue/filter_item_user_assign" dict
|
||||||
"QueryParamKey" "assignee"
|
"QueryParamKey" "assignee"
|
||||||
@@ -70,6 +70,33 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="ui container project-board-controls">
|
||||||
|
<div class="ui action input project-filter-input">
|
||||||
|
<label class="tw-sr-only" for="project-filter-input">{{ctx.Locale.Tr "repo.projects.filter"}}</label>
|
||||||
|
<input id="project-filter-input" type="search" data-project-filter-input value="{{.ProjectFilter}}" placeholder="{{ctx.Locale.Tr "repo.projects.filter_placeholder"}}" autocomplete="off" aria-describedby="project-filter-error project-filter-empty">
|
||||||
|
<button type="button" class="ui button" data-project-filter-clear>{{ctx.Locale.Tr "repo.projects.filter_clear"}}</button>
|
||||||
|
</div>
|
||||||
|
<div class="project-group-select">
|
||||||
|
<label for="project-group-by">{{ctx.Locale.Tr "repo.projects.group_by"}}</label>
|
||||||
|
<select id="project-group-by" data-project-group-by>
|
||||||
|
<option value="none">{{ctx.Locale.Tr "repo.projects.group_by.none"}}</option>
|
||||||
|
<option value="assignee">{{ctx.Locale.Tr "repo.issues.filter_assignee"}}</option>
|
||||||
|
<option value="label">{{ctx.Locale.Tr "repo.issues.filter_label"}}</option>
|
||||||
|
<option value="milestone">{{ctx.Locale.Tr "repo.issues.filter_milestone"}}</option>
|
||||||
|
<option value="repository">{{ctx.Locale.Tr "repo.repository"}}</option>
|
||||||
|
<option value="state">{{ctx.Locale.Tr "repo.issues.filter_state"}}</option>
|
||||||
|
<option value="type">{{ctx.Locale.Tr "repo.issues.filter_type"}}</option>
|
||||||
|
<option value="status">{{ctx.Locale.Tr "repo.projects.column"}}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p id="project-filter-error" class="tw-text-red" data-project-filter-error role="alert" hidden
|
||||||
|
data-project-filter-error-unknown="{{ctx.Locale.Tr "repo.projects.filter.error.unknown"}}"
|
||||||
|
data-project-filter-error-malformed="{{ctx.Locale.Tr "repo.projects.filter.error.malformed"}}"
|
||||||
|
data-project-filter-error-unclosed="{{ctx.Locale.Tr "repo.projects.filter.error.unclosed"}}"
|
||||||
|
></p>
|
||||||
|
<p id="project-filter-empty" data-project-filter-empty role="status" hidden>{{ctx.Locale.Tr "repo.projects.filter.no_results"}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ui container project-description">
|
<div class="ui container project-description">
|
||||||
<div class="render-content markup">
|
<div class="render-content markup">
|
||||||
{{$.Project.RenderedContent}}
|
{{$.Project.RenderedContent}}
|
||||||
@@ -77,8 +104,9 @@
|
|||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="project-board" class="board {{if $canWriteProject}}sortable{{end}}" data-project-board-writable="{{$canWriteProject}}" {{if $canWriteProject}}data-url="{{$.Link}}/move"{{end}}>
|
<div id="project-board" class="board {{if $canWriteProject}}sortable{{end}}" data-project-board-writable="{{$canWriteProject}}" data-project-group-no-value="{{ctx.Locale.Tr "repo.projects.group_by.no_value"}}" {{if $canWriteProject}}data-url="{{$.Link}}/move"{{end}}>
|
||||||
{{range .Columns}}
|
{{range .Columns}}
|
||||||
|
{{$columnTitle := .Title}}
|
||||||
<div class="project-column" {{if .Color}}style="background: {{.Color}} !important; color: {{ContrastColor .Color}} !important"{{end}} data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
|
<div class="project-column" {{if .Color}}style="background: {{.Color}} !important; color: {{ContrastColor .Color}} !important"{{end}} data-id="{{.ID}}" data-sorting="{{.Sorting}}" data-url="{{$.Link}}/{{.ID}}">
|
||||||
<div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}">
|
<div class="project-column-header{{if $canWriteProject}} tw-cursor-grab{{end}}">
|
||||||
<div class="ui circular label project-column-issue-count">
|
<div class="ui circular label project-column-issue-count">
|
||||||
@@ -122,7 +150,18 @@
|
|||||||
<div class="divider"{{if .Color}} style="color: {{ContrastColor .Color}} !important"{{end}}></div>
|
<div class="divider"{{if .Color}} style="color: {{ContrastColor .Color}} !important"{{end}}></div>
|
||||||
<div class="ui cards" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="board_{{.ID}}">
|
<div class="ui cards" data-url="{{$.Link}}/{{.ID}}" data-project="{{$.Project.ID}}" data-board="{{.ID}}" id="board_{{.ID}}">
|
||||||
{{range (index $.IssuesMap .ID)}}
|
{{range (index $.IssuesMap .ID)}}
|
||||||
<div class="issue-card tw-break-anywhere {{if $canWriteProject}}tw-cursor-grab{{end}}" data-issue="{{.ID}}">
|
<div class="issue-card tw-break-anywhere {{if $canWriteProject}}tw-cursor-grab{{end}}" data-issue="{{.ID}}"
|
||||||
|
data-project-card-id="{{.ID}}"
|
||||||
|
data-project-card-title="{{.Title}}"
|
||||||
|
data-project-card-assignees="[{{range $index, $assignee := .Assignees}}{{if $index}},{{end}}{{printf "%q" $assignee.Name}}{{end}}]"
|
||||||
|
data-project-card-author="{{if .OriginalAuthor}}{{.OriginalAuthor}}{{else}}{{.Poster.Name}}{{end}}"
|
||||||
|
data-project-card-labels="[{{range $index, $label := .Labels}}{{if $index}},{{end}}{{printf "%q" $label.Name}}{{end}}]"
|
||||||
|
data-project-card-milestone="{{if .MilestoneID}}{{.Milestone.Name}}{{end}}"
|
||||||
|
data-project-card-repository="{{.Repo.FullName}}"
|
||||||
|
data-project-card-state="{{if .IsClosed}}closed{{else}}open{{end}}"
|
||||||
|
data-project-card-type="{{if .IsPull}}pull{{else}}issue{{end}}"
|
||||||
|
data-project-card-status="{{$columnTitle}}"
|
||||||
|
>
|
||||||
{{template "repo/issue/card" (dict "Issue" . "Page" $)}}
|
{{template "repo/issue/card" (dict "Issue" . "Page" $)}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -28,6 +28,85 @@ test('assign issue to project and change column', async ({page}) => {
|
|||||||
await apiDeleteRepo(page.request, user, repoName);
|
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}) => {
|
test('create a project', async ({page}) => {
|
||||||
const repoName = `e2e-project-repo-${Date.now()}`;
|
const repoName = `e2e-project-repo-${Date.now()}`;
|
||||||
const projectTitle = 'Test Project';
|
const projectTitle = 'Test Project';
|
||||||
|
|||||||
@@ -19,6 +19,50 @@
|
|||||||
margin: 0;
|
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 {
|
.project-column {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,298 @@ import {toggleFullScreen} from '../utils.ts';
|
|||||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||||
import {localUserSettings} from '../modules/user-settings.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 {
|
function updateIssueCount(card: HTMLElement): void {
|
||||||
const parent = card.parentElement!;
|
const parent = card.parentElement!;
|
||||||
const count = parent.querySelectorAll('.issue-card').length;
|
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<void> {
|
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');
|
const columnCards = to.querySelectorAll('.issue-card');
|
||||||
updateIssueCount(from);
|
updateIssueCount(from);
|
||||||
updateIssueCount(to);
|
updateIssueCount(to);
|
||||||
@@ -48,7 +348,9 @@ async function initRepoProjectSortable(): Promise<void> {
|
|||||||
handle: '.project-column-header',
|
handle: '.project-column-header',
|
||||||
delayOnTouchOnly: true,
|
delayOnTouchOnly: true,
|
||||||
delay: 500,
|
delay: 500,
|
||||||
|
onMove: () => !isProjectBoardViewModified(),
|
||||||
onSort: async () => { // eslint-disable-line @typescript-eslint/no-misused-promises
|
onSort: async () => { // eslint-disable-line @typescript-eslint/no-misused-promises
|
||||||
|
if (isProjectBoardViewModified()) return;
|
||||||
boardColumns = mainBoard.querySelectorAll<HTMLElement>('.project-column');
|
boardColumns = mainBoard.querySelectorAll<HTMLElement>('.project-column');
|
||||||
|
|
||||||
const columnSorting = {
|
const columnSorting = {
|
||||||
@@ -76,6 +378,7 @@ async function initRepoProjectSortable(): Promise<void> {
|
|||||||
onUpdate: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises
|
onUpdate: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises
|
||||||
delayOnTouchOnly: true,
|
delayOnTouchOnly: true,
|
||||||
delay: 500,
|
delay: 500,
|
||||||
|
onMove: () => !isProjectBoardViewModified(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +430,9 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
|
|||||||
const elBoardColumn = writableProjectBoard.querySelector<HTMLElement>(`.project-column[data-id="${CSS.escape(columnId)}"]`)!;
|
const elBoardColumn = writableProjectBoard.querySelector<HTMLElement>(`.project-column[data-id="${CSS.escape(columnId)}"]`)!;
|
||||||
const elBoardColumnTitle = elBoardColumn.querySelector<HTMLElement>(`.project-column-title-text`)!;
|
const elBoardColumnTitle = elBoardColumn.querySelector<HTMLElement>(`.project-column-title-text`)!;
|
||||||
elBoardColumnTitle.textContent = elColumnTitle.value;
|
elBoardColumnTitle.textContent = elColumnTitle.value;
|
||||||
|
for (const card of elBoardColumn.querySelectorAll<HTMLElement>('.issue-card')) {
|
||||||
|
card.setAttribute('data-project-card-status', elColumnTitle.value);
|
||||||
|
}
|
||||||
if (elColumnColor.value) {
|
if (elColumnColor.value) {
|
||||||
const textColor = contrastColor(elColumnColor.value);
|
const textColor = contrastColor(elColumnColor.value);
|
||||||
elBoardColumn.style.setProperty('background', elColumnColor.value, 'important');
|
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 {
|
export function initRepoProjectsView(): void {
|
||||||
registerGlobalInitFunc('initRepoProjectsView', (elProjectsView) => {
|
registerGlobalInitFunc('initRepoProjectsView', (elProjectsView) => {
|
||||||
|
initRepoProjectRefresh();
|
||||||
initRepoProjectToggleFullScreen(elProjectsView);
|
initRepoProjectToggleFullScreen(elProjectsView);
|
||||||
|
initRepoProjectFilters(elProjectsView);
|
||||||
|
|
||||||
const writableProjectBoard = document.querySelector('#project-board[data-project-board-writable="true"]');
|
const writableProjectBoard = document.querySelector('#project-board[data-project-board-writable="true"]');
|
||||||
if (!writableProjectBoard) return;
|
if (!writableProjectBoard) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user