feat: migrate show-modal to native dialogs (#10287)

Test coverage:

|Modal|Test|
|-|-|
|admin: adopt unadopted|missing, not needed|
|admin: delete unadopted|missing, not needed|
|admin: delete user|e2e added: `Admin: delete a user`|
|delete package|missing|
|new project|?|
|edit project col|?|
|default project col|?|
|delete project col|?|
|commit cherry-pick|?|
|commit delete note|?|
|fork redirect|?|
|lock/unlock issue|?|
|dismiss PR review|?|
|migration delete|?|
|migration cancel|?|
|lfs delete|?|
|convert mirror|?|
|convert fork|?|
|transfer repo|?|
|delete repo|?|
|archive repo|integration present, selectors adjusted|
|delete wiki|?|
|rename wiki branch|?|
|push mirror edit|?|
|mde: new table|e2e present, selectors adjusted|
|mde: new link|e2e present, selectors adjusted|
|actions: add secret|?|
|actions: edit variable|?|

Co-authored-by: 0ko <0ko@noreply.codeberg.org>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10287
Reviewed-by: 0ko <0ko@noreply.codeberg.org>
This commit is contained in:
Gusted 2026-05-03 06:42:14 +02:00 committed by 0ko
parent e9710af24f
commit 555d88070d
30 changed files with 777 additions and 711 deletions

View file

@ -13,6 +13,7 @@ import {showErrorToast} from '../modules/toast.js';
import {request, POST, GET} from '../modules/fetch.js';
import '../htmx.js';
import {initTab} from '../modules/tab.ts';
import {initGlobalShowModal} from './show-modal.ts';
const {appUrl, appSubUrl, i18n} = window.config;
@ -439,53 +440,6 @@ export function initGlobalLinkActions() {
});
}
export function initGlobalShowModal() {
// A ".show-modal" button will show a modal dialog defined by its "data-modal" attribute.
// Each "data-modal-{target}" attribute will be filled to target element's value or text-content.
// * First, try to query '#target'
// * Then, try to query '.target'
// * Then, try to query 'target' as HTML tag
// If there is a ".{attr}" part like "data-modal-form.action", then the form's "action" attribute will be set.
$('.show-modal').on('click', function (e) {
e.preventDefault();
const modalSelector = this.getAttribute('data-modal');
const $modal = $(modalSelector);
if (!$modal.length) {
throw new Error('no modal for this action');
}
const modalAttrPrefix = 'data-modal-';
for (const attrib of this.attributes) {
if (!attrib.name.startsWith(modalAttrPrefix)) {
continue;
}
const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length);
const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.');
// try to find target by: "#target" -> ".target" -> "target tag"
let $attrTarget = $modal.find(`#${attrTargetName}`);
if (!$attrTarget.length) $attrTarget = $modal.find(`.${attrTargetName}`);
if (!$attrTarget.length) $attrTarget = $modal.find(`${attrTargetName}`);
if (!$attrTarget.length) continue; // TODO: show errors in dev mode to remind developers that there is a bug
if (attrTargetAttr) {
$attrTarget[0][attrTargetAttr] = attrib.value;
} else if ($attrTarget[0].matches('input, textarea')) {
$attrTarget.val(attrib.value); // FIXME: add more supports like checkbox
} else {
$attrTarget.text(attrib.value); // FIXME: it should be more strict here, only handle div/span/p
}
}
$modal.modal('setting', {
onApprove: () => {
// "form-fetch-action" can handle network errors gracefully,
// so keep the modal dialog to make users can re-submit the form if anything wrong happens.
if ($modal.find('.form-fetch-action').length) return false;
},
}).modal('show');
});
}
export function initGlobalButtons() {
// There are many "cancel button" elements in modal dialogs, Fomantic UI expects they are button-like elements but never submit a form.
// However, Gitea misuses the modal dialog and put the cancel buttons inside forms, so we must prevent the form submission.

View file

@ -96,8 +96,8 @@ class ComboMarkdownEditor {
this.textareaMarkdownToolbar.querySelector('button[data-md-action="unindent"]')?.addEventListener('click', () => {
this.indentSelection(true, false);
});
this.textareaMarkdownToolbar.querySelector('button[data-md-action="new-table"]')?.setAttribute('data-modal', `div[data-markdown-table-modal-id="${this.elementIdSuffix}"]`);
this.textareaMarkdownToolbar.querySelector('button[data-md-action="new-link"]')?.setAttribute('data-modal', `div[data-markdown-link-modal-id="${this.elementIdSuffix}"]`);
this.textareaMarkdownToolbar.querySelector('button[data-md-action="new-table"]')?.setAttribute('data-modal', `dialog[data-markdown-table-modal-id="${this.elementIdSuffix}"]`);
this.textareaMarkdownToolbar.querySelector('button[data-md-action="new-link"]')?.setAttribute('data-modal', `dialog[data-markdown-link-modal-id="${this.elementIdSuffix}"]`);
// Find all data-md-ctrl-shortcut elements in the markdown toolbar.
const shortcutKeys = new Map();
@ -263,7 +263,7 @@ class ComboMarkdownEditor {
addNewTable(event) {
const elementId = event.target.getAttribute('data-element-id');
const newTableModal = document.querySelector(`div[data-markdown-table-modal-id="${elementId}"]`);
const newTableModal = document.querySelector(`dialog[data-markdown-table-modal-id="${elementId}"]`);
const form = newTableModal.querySelector('div[data-selector-name="form"]');
// Validate input fields
@ -295,8 +295,9 @@ class ComboMarkdownEditor {
}
setupTableInserter() {
const newTableModal = this.container.querySelector('div[data-modal-name="new-markdown-table"]');
const newTableModal = this.container.querySelector('dialog[data-modal-name="new-markdown-table"]');
newTableModal.setAttribute('data-markdown-table-modal-id', this.elementIdSuffix);
document.body.append(newTableModal); // Contains form elements, avoid conflict with form of comment editor.
const button = newTableModal.querySelector('button[data-selector-name="ok-button"]');
button.setAttribute('data-element-id', this.elementIdSuffix);
@ -305,7 +306,7 @@ class ComboMarkdownEditor {
addNewLink(event) {
const elementId = event.target.getAttribute('data-element-id');
const newLinkModal = document.querySelector(`div[data-markdown-link-modal-id="${elementId}"]`);
const newLinkModal = document.querySelector(`dialog[data-markdown-link-modal-id="${elementId}"]`);
const form = newLinkModal.querySelector('div[data-selector-name="form"]');
// Validate input fields
@ -330,25 +331,22 @@ class ComboMarkdownEditor {
}
setupLinkInserter() {
const newLinkModal = this.container.querySelector('div[data-modal-name="new-markdown-link"]');
const newLinkModal = this.container.querySelector('dialog[data-modal-name="new-markdown-link"]');
newLinkModal.setAttribute('data-markdown-link-modal-id', this.elementIdSuffix);
const textarea = document.getElementById(`_combo_markdown_editor_${this.elementIdSuffix}`);
document.body.append(newLinkModal); // Contains form elements, avoid conflict with form of comment editor.
$(newLinkModal).modal({
// Pre-fill the description field from the selection to create behavior similar
// to pasting an URL over selected text.
onShow: () => {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
newLinkModal.$modal = {onShow: () => {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
if (start !== end) {
const selection = textarea.value.slice(start ?? undefined, end ?? undefined);
newLinkModal.querySelector('input[name="link-description"]').value = selection;
} else {
newLinkModal.querySelector('input[name="link-description"]').value = '';
}
},
});
if (start !== end) {
const selection = textarea.value.slice(start ?? undefined, end ?? undefined);
newLinkModal.querySelector('input[name="link-description"]').value = selection;
} else {
newLinkModal.querySelector('input[name="link-description"]').value = '';
}
}};
const button = newLinkModal.querySelector('button[data-selector-name="ok-button"]');
button.setAttribute('data-element-id', this.elementIdSuffix);

View file

@ -27,7 +27,7 @@ import {attachRefIssueContextPopup} from './contextpopup.js';
import {POST} from '../modules/fetch.js';
import {MarkdownQuote} from '@github/quote-selection';
import {toAbsoluteUrl} from '../utils.js';
import {initDropzone, initGlobalShowModal, initDisabledInputs} from './common-global.js';
import {initDropzone, initDisabledInputs} from './common-global.js';
export function initRepoCommentForm() {
const $commentForm = $('.comment.form');
@ -386,8 +386,6 @@ async function onEditContent(event) {
tabEditor?.click();
}
initGlobalShowModal();
// Show write/preview tab and copy raw content as needed
showElem(editContentZone);
hideElem(renderContent);

View file

@ -0,0 +1,54 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
import {showModal} from '../modules/modal.ts';
// Initialize all elements that have the `show-modal` class. The modal ID that
// is specified in the `data-modal` attribute will be shown. The shown modal
// can be modified by adding more attributes:
// * `data-modal-$TARGET="$VALUE"`, If $TARGET contains a dot then its split
// as $TARGET and $ATTR. $TARGET will first be queried as an identifier, then as
// a classname and then as an element tag name in the modal element. If $ATTR
// exists then the target element will have attribute $ATTR set to value $VALUE,
// otherwise if the element is of type input or textarea then the value is set
// to $VALUE otherwise the textContent of that element is set to $VALUE.
export function initGlobalShowModal() {
document.addEventListener('click', (e) => {
if (!(e.target instanceof Element)) {
return;
}
const target = e.target.closest('.show-modal');
if (!target) {
return;
}
e.preventDefault();
const modal = document.querySelector<HTMLDialogElement>(target.getAttribute('data-modal'));
if (!modal) {
throw new Error('No modal found for this action');
}
const modalAttrPrefix = 'data-modal-';
for (const attrib of (target as HTMLElement).attributes) {
if (!attrib.name.startsWith(modalAttrPrefix)) {
continue;
}
const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length);
const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.');
// try to find target by: "#target" -> ".target" -> "target tag"
const attrTarget = modal.querySelector(`#${attrTargetName}, .${attrTargetName}, ${attrTargetName}`);
if (attrTargetAttr) {
attrTarget.setAttribute(attrTargetAttr, attrib.value);
} else if (attrTarget instanceof HTMLInputElement || attrTarget instanceof HTMLTextAreaElement) {
attrTarget.value = attrib.value; // FIXME: add more supports like checkbox
} else {
attrTarget.textContent = attrib.value; // FIXME: it should be more strict here, only handle div/span/p
}
}
showModal(modal, undefined);
});
}

View file

@ -3,8 +3,14 @@
// showModal will show the given modal and run `onApprove` if the approve/ok/yes
// button is pressed.
export function showModal(modalID: string, onApprove: () => void) {
const modal = document.getElementById(modalID) as HTMLDialogElement;
export function showModal(modalID: string | HTMLDialogElement, onApprove: () => void) {
let modal: HTMLDialogElement;
if (typeof modalID === 'string') {
modal = document.getElementById(modalID) as HTMLDialogElement;
} else {
modal = modalID;
}
// Move the modal to `<body>`, to avoid inheriting any bad CSS or if the
// parent becomes `display: hidden`.
document.body.append(modal);
@ -15,6 +21,9 @@ export function showModal(modalID: string, onApprove: () => void) {
}, {once: true, passive: true});
modal.querySelector('.ok')?.addEventListener('click', onApprove, {passive: true});
// Call a `onShow` callback if one is registered for this element.
modal?.$modal?.onShow();
// The modal is ready to be shown.
modal.showModal();
}

View file

@ -14,3 +14,9 @@ type CodeMirrorLanguage = typeof import('@codemirror/language');
type CodeMirrorSearch = typeof import('@codemirror/search');
type CodeMirrorState = typeof import('@codemirror/state');
type CodeMirrorView = typeof import('@codemirror/view');
interface HTMLDialogElement {
$modal?: {
onShow?: () => void;
}
}