feat: improve label filtering exclusion (#10702)

Adds a new button on the right side of the label's filter menu items to explicitly exclude labels.

The new button is reachable with the keyboard by using the vertical arrow keys to reach the label you want to exclude and then the horizontal arrow keys to select the exclusion button.

The new button will only be visible when hovering the menu item or reaching it with the keyboard.

Adjusted the alignment of labels when at least one label is selected so that users can clearly discern which labels are selected or not.

Resolves #3302

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10702
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: Luis <luis@adame.dev>
Co-committed-by: Luis <luis@adame.dev>
This commit is contained in:
Luis 2026-02-08 00:31:31 +01:00 committed by Gusted
parent 239d7168e1
commit 3f7859f52d
16 changed files with 372 additions and 106 deletions

View file

@ -84,6 +84,7 @@
@import "./review.css";
@import "./actions.css";
@import "./migrate.css";
@import "./issues.css";
@tailwind utilities;
@import "./helpers.css";

75
web_src/css/issues.css Normal file
View file

@ -0,0 +1,75 @@
/* Copyright 2025 The Forgejo Authors. All rights reserved.
* SPDX-License-Identifier: GPL-3.0-or-later */
.archived-label-filter:has(#archived-label-filter:not(:checked))
[data-is-archived] {
display: none !important;
}
.menu .item:has(> .label-filter-item) .label-exclude-item-btn {
opacity: 0;
position: absolute;
right: 0;
height: 100%;
width: 40px;
background: none;
color: var(--color-secondary-dark-5);
@media (any-hover: none) {
opacity: 1;
}
&.active {
color: var(--color-secondary-dark-10);
}
&:is(:hover, :focus-visible),
&.selected {
background: var(--color-secondary);
color: var(--color-secondary-dark-7);
}
}
.ui.menu .ui.dropdown .menu > .item:has(> .label-filter-item) {
max-width: 288px;
@media (any-hover: none) {
padding-right: 50px !important;
}
&:hover,
&.selected,
&:has(.label-exclude-item-btn.active) {
padding-right: 50px !important;
.label-exclude-item-btn {
opacity: 1;
}
}
}
.menu:has(> .item[data-selected])
.item:not([data-selected])
> .label-filter-item {
padding-left: 27px;
}
.menu .item .label-filter-item {
width: 100%;
min-width: 0;
}
.menu .item .label-filter-item > .label {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
display: block;
}
.menu .item .archived-label-indicator {
margin: 0 0 0 10px !important;
@media (prefers-reduced-motion: no-preference) {
transition: transform 200ms ease;
}
}

View file

@ -220,13 +220,13 @@ function initArchivedLabelFilter() {
const archivedElToggle = () => {
for (const label of archivedLabels) {
const id = label.getAttribute('data-label-id');
toggleElem(label, archivedLabelEl.checked || selectedLabels.includes(id));
toggleElem(label.closest('.item'), archivedLabelEl.checked || selectedLabels.includes(id));
}
};
archivedElToggle();
archivedLabelEl.addEventListener('change', () => {
archivedElToggle();
if (archivedLabelEl.checked) {
url.searchParams.set('archived', 'true');
} else {

View file

@ -0,0 +1,186 @@
import $ from 'jquery';
import {htmlEscape} from 'escape-goat';
import {emojiHTML} from './emoji.js';
const {appSubUrl} = window.config;
export function initRepoIssueSidebarList() {
const repolink = $('#repolink').val();
const repoId = $('#repoId').val();
const crossRepoSearch = $('#crossRepoSearch').val() === 'true';
const tp = $('#type').val();
$('#new-dependency-drop-list')
.dropdown({
apiSettings: {
beforeSend(settings) {
if (!settings.urlData.query.trim()) {
settings.url = `${appSubUrl}/${repolink}/issues/search?q={query}&type=${tp}&sort=updated`;
} else if (crossRepoSearch) {
settings.url = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${repoId}&type=${tp}&sort=relevance`;
} else {
settings.url = `${appSubUrl}/${repolink}/issues/search?q={query}&type=${tp}&sort=relevance`;
}
return settings;
},
onResponse(response: Record<string, {
id: string,
number: number,
title: string,
repository: {
full_name: string
}
}>) {
const filteredResponse = {success: true, results: []};
const currIssueId = $('#new-dependency-drop-list').data('issue-id');
// Parse the response from the api to work with our dropdown
for (const [_, issue] of Object.entries(response)) {
// Don't list current issue in the dependency list.
if (issue.id === currIssueId) {
continue;
}
filteredResponse.results.push({
name: `#${issue.number} ${issueTitleHTML(htmlEscape(issue.title))
}<div class="text small tw-break-anywhere">${htmlEscape(issue.repository.full_name)}</div>`,
value: issue.id,
});
}
return filteredResponse;
},
cache: false,
},
fullTextSearch: true,
});
$('.menu button.label-exclude-item-btn').each(function () {
$(this).on('click', function () {
const label = this.closest('.item').querySelector('a.label-filter-item');
if (!label) {
return;
}
excludeLabel(label);
});
});
// Increase surface area to include a label in the filters
for (const labelFilterItem of document.querySelectorAll<HTMLAnchorElement>('.menu a.label-filter-item')) {
const menuItem = labelFilterItem.closest('.item');
menuItem.addEventListener('click', (event: MouseEvent) => {
if (labelFilterItem === event.target || (event.target as HTMLElement).closest('.label-exclude-item-btn')) {
return;
}
labelFilterItem.click();
});
}
$('.menu .ui.dropdown.label-filter').on('keydown', (e: KeyboardEvent) => {
const selectedItem = document.querySelector('.menu .ui.dropdown.label-filter .menu .item.selected');
if (!selectedItem) {
return;
}
const selectedItemExcludeButton = selectedItem.querySelector('.label-exclude-item-btn');
const selectExcludeButton = () => selectedItemExcludeButton?.classList.add('selected');
const deselectExcludeButton = () => selectedItemExcludeButton?.classList.remove('selected');
const isExcludeButtonSelected = () => selectedItemExcludeButton?.classList.contains('selected');
if (e.key === 'Enter') {
const labelElement = selectedItem.querySelector<HTMLAnchorElement>('a.label-filter-item');
if (!labelElement) {
return;
}
if (isExcludeButtonSelected()) {
excludeLabel(labelElement);
} else {
labelElement.click();
}
}
// the menu can be navigated with or without the search input being focused
// therefore we check if the input is currently focused and the caret is
// at the end to make sure the moving the caret within the input works
const isOnInput = (e.target as HTMLElement).matches('input');
const input = e.target as HTMLInputElement;
if (e.key === 'ArrowRight' && (!isOnInput || isCaretAtEnd(input))) {
selectExcludeButton();
}
if (e.key === 'ArrowLeft') {
// it will deselect the exclude button before letting the user move along the focused input text
// so the user has to press once the left key to deselect and then another time to
// move the caret to the left side
if (isOnInput && isCaretAtEnd(input) && selectedItemExcludeButton.classList.contains('selected')) {
e.preventDefault();
}
deselectExcludeButton();
}
// when a exclude button is selected moving to the prev or next item in the menu
// is still possible, but the exclude button can remain selected, this makes
// sure to clear the selection class from the exclude buttons that are not
// within the currently selected menu item
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
for (const excludeButtonSelected of document.querySelectorAll('.label-exclude-item-btn.selected')) {
if (!selectedItem.contains(excludeButtonSelected)) {
excludeButtonSelected.classList.remove('selected');
}
}
}
});
$('.ui.dropdown.label-filter, .ui.dropdown.select-label').dropdown('setting', {'hideDividers': 'empty'}).dropdown('refreshItems');
}
/**
* Render the issue's title.
* It converts emojis and code blocks syntax into their respective HTML equivalent.
*/
export function issueTitleHTML(title: string) {
return title.replaceAll(/:[-+\w]+:/g, (emoji) => emojiHTML(emoji.substring(1, emoji.length - 1)))
.replaceAll(/`[^`]+`/g, (code) => `<code class="inline-code-block">${code.substring(1, code.length - 1)}</code>`);
}
/**
* Excludes a label from filters provided by the data-label-id attribute of an element.
*
* If the label is included it will be converted to an exclusion, if its already excluded it will get removed, otherwise, if not present at all it will get excluded.
*/
export function excludeLabel(item: HTMLElement) {
const id = item.getAttribute('data-label-id');
const excludedId = `-${id}`;
const params = new URLSearchParams(window.location.search);
const labelIds = new Set((params.get('labels') ?? '').split(',').filter((id) => id.length > 0));
if (labelIds.has(id)) {
labelIds.delete(id);
labelIds.add(excludedId);
} else if (labelIds.has(excludedId)) {
labelIds.delete(excludedId);
} else {
labelIds.add(excludedId);
}
params.set('labels', Array.from(labelIds).join(','));
window.location.search = params.toString();
}
/**
* Returns true if the caret is at the end of the input even if it has content
*/
function isCaretAtEnd(inputElement: HTMLInputElement) {
const value = inputElement.value;
return (
inputElement.selectionStart === inputElement.selectionEnd &&
inputElement.selectionEnd === value.length
);
}

View file

@ -8,7 +8,6 @@ import {toAbsoluteUrl} from '../utils.js';
import {initDropzone} from './common-global.js';
import {POST, GET} from '../modules/fetch.js';
import {showErrorToast} from '../modules/toast.js';
import {emojiHTML} from './emoji.js';
const {appSubUrl} = window.config;
@ -109,81 +108,6 @@ export function initRepoIssueDue() {
});
}
/**
* @param {HTMLElement} item
*/
function excludeLabel(item) {
const href = item.getAttribute('href');
const id = item.getAttribute('data-label-id');
const regStr = `labels=((?:-?[0-9]+%2c)*)(${id})((?:%2c-?[0-9]+)*)&`;
const newStr = 'labels=$1-$2$3&';
window.location.assign(href.replace(new RegExp(regStr), newStr));
}
export function initRepoIssueSidebarList() {
const repolink = $('#repolink').val();
const repoId = $('#repoId').val();
const crossRepoSearch = $('#crossRepoSearch').val() === 'true';
const tp = $('#type').val();
$('#new-dependency-drop-list')
.dropdown({
apiSettings: {
beforeSend(settings) {
if (!settings.urlData.query.trim()) {
settings.url = `${appSubUrl}/${repolink}/issues/search?q={query}&type=${tp}&sort=updated`;
} else if (crossRepoSearch) {
settings.url = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${repoId}&type=${tp}&sort=relevance`;
} else {
settings.url = `${appSubUrl}/${repolink}/issues/search?q={query}&type=${tp}&sort=relevance`;
}
return settings;
},
onResponse(response) {
const filteredResponse = {success: true, results: []};
const currIssueId = $('#new-dependency-drop-list').data('issue-id');
// Parse the response from the api to work with our dropdown
for (const [_, issue] of Object.entries(response)) {
// Don't list current issue in the dependency list.
if (issue.id === currIssueId) {
continue;
}
filteredResponse.results.push({
name: `#${issue.number} ${issueTitleHTML(htmlEscape(issue.title))
}<div class="text small tw-break-anywhere">${htmlEscape(issue.repository.full_name)}</div>`,
value: issue.id,
});
}
return filteredResponse;
},
cache: false,
},
fullTextSearch: true,
});
$('.menu a.label-filter-item').each(function () {
$(this).on('click', function (e) {
if (e.altKey) {
e.preventDefault();
excludeLabel(this);
}
});
});
// FIXME: this is broken, see discussion https://codeberg.org/forgejo/forgejo/pulls/8199
$('.menu .ui.dropdown.label-filter').on('keydown', (e) => {
if (e.altKey && e.keyCode === 13) {
const selectedItem = document.querySelector('.menu .ui.dropdown.label-filter .menu .item.selected');
if (selectedItem) {
excludeLabel(selectedItem);
}
}
});
$('.ui.dropdown.label-filter, .ui.dropdown.select-label').dropdown('setting', {'hideDividers': 'empty'}).dropdown('refreshItems');
}
export function initRepoIssueCommentDelete() {
// Delete comment
document.addEventListener('click', async (e) => {
@ -801,9 +725,3 @@ export function initArchivedLabelHandler() {
toggleElem(label, label.classList.contains('checked'));
}
}
// Render the issue's title. It converts emojis and code blocks syntax into their respective HTML equivalent.
export function issueTitleHTML(title) {
return title.replaceAll(/:[-+\w]+:/g, (emoji) => emojiHTML(emoji.substring(1, emoji.length - 1)))
.replaceAll(/`[^`]+`/g, (code) => `<code class="inline-code-block">${code.substring(1, code.length - 1)}</code>`);
}

View file

@ -1,6 +1,6 @@
import {vi} from 'vitest';
import {issueTitleHTML} from './repo-issue.js';
import {issueTitleHTML, excludeLabel} from './repo-issue-sidebar-list.ts';
vi.mock('./comp/ComboMarkdownEditor.js', () => ({}));
// jQuery is missing
@ -21,3 +21,26 @@ test('Convert issue title to html', () => {
expect(issueTitleHTML('issue title :+1: `code`')).toEqual(`issue title ${expected_thumbs_up} ${expected_code_block}`);
});
const getLabelsParam = () => new URLSearchParams(window.location.search).get('labels');
test('Toggles label exclusion from filters', () => {
expect(getLabelsParam()).toEqual(null);
const element = document.createElement('div');
element.dataset['label-id'] = '1';
// excludes it
excludeLabel(element);
expect(getLabelsParam()).toEqual('-1');
// since it was excluded above, now it should delete it
excludeLabel(element);
expect(getLabelsParam()).toEqual('');
// if we add it manually it should swap it to an exclusion
window.location.search = '?labels=1';
expect(getLabelsParam()).toEqual('1');
excludeLabel(element);
expect(getLabelsParam()).toEqual('-1');
});

View file

@ -30,8 +30,10 @@ import {
initRepoIssueTimeTracking,
initRepoIssueWipTitle,
initRepoPullRequestAllowMaintainerEdit,
initRepoPullRequestReview, initRepoIssueSidebarList, initArchivedLabelHandler,
initRepoPullRequestReview,
initArchivedLabelHandler,
} from './features/repo-issue.js';
import {initRepoIssueSidebarList} from './features/repo-issue-sidebar-list.ts';
import {initRepoEllipsisButton, initCommitStatuses, initCommitNotes} from './features/repo-commit.js';
import {
initFootLanguageMenu,

View file

@ -1,6 +1,7 @@
interface Window {
config?: {
appUrl: string;
appSubUrl: string
}
}