mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-12 22:10:25 +00:00
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:
parent
e9710af24f
commit
555d88070d
30 changed files with 777 additions and 711 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
54
web_src/js/features/show-modal.ts
Normal file
54
web_src/js/features/show-modal.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
6
web_src/js/types.d.ts
vendored
6
web_src/js/types.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue