mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-12 22:10:25 +00:00
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:
parent
239d7168e1
commit
3f7859f52d
16 changed files with 372 additions and 106 deletions
|
|
@ -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
75
web_src/css/issues.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
186
web_src/js/features/repo-issue-sidebar-list.ts
Normal file
186
web_src/js/features/repo-issue-sidebar-list.ts
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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>`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
1
web_src/js/types.d.ts
vendored
1
web_src/js/types.d.ts
vendored
|
|
@ -1,6 +1,7 @@
|
|||
interface Window {
|
||||
config?: {
|
||||
appUrl: string;
|
||||
appSubUrl: string
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue