`,
+ 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('.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('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.substring(1, code.length - 1)}`);
+}
+
+/**
+ * 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
+ );
+}
diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js
index 73131706cc..eb6af4d48b 100644
--- a/web_src/js/features/repo-issue.js
+++ b/web_src/js/features/repo-issue.js
@@ -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))
- }
${htmlEscape(issue.repository.full_name)}
`,
- 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.substring(1, code.length - 1)}`);
-}
diff --git a/web_src/js/features/repo-issue.test.js b/web_src/js/features/repo-issue.test.js
index f72eb5794b..acf4ea209e 100644
--- a/web_src/js/features/repo-issue.test.js
+++ b/web_src/js/features/repo-issue.test.js
@@ -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');
+});
diff --git a/web_src/js/index.js b/web_src/js/index.js
index 8701385e92..37848d2d90 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -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,
diff --git a/web_src/js/types.d.ts b/web_src/js/types.d.ts
index 5efc36762b..d464f25fe9 100644
--- a/web_src/js/types.d.ts
+++ b/web_src/js/types.d.ts
@@ -1,6 +1,7 @@
interface Window {
config?: {
appUrl: string;
+ appSubUrl: string
}
}