+
+
+
{{end}}
{{end}}
diff --git a/templates/repo/settings/push_mirror_sync_modal.tmpl b/templates/repo/settings/push_mirror_sync_modal.tmpl
index 441bed1abc..64e8440fff 100644
--- a/templates/repo/settings/push_mirror_sync_modal.tmpl
+++ b/templates/repo/settings/push_mirror_sync_modal.tmpl
@@ -1,29 +1,29 @@
-
-
-
+
{{/** Edit variable dialog */}}
-
-
-
-
-
+
diff --git a/tests/e2e/admin-ui.test.e2e.ts b/tests/e2e/admin-ui.test.e2e.ts
index 8fcd3456a3..84145dd0cf 100644
--- a/tests/e2e/admin-ui.test.e2e.ts
+++ b/tests/e2e/admin-ui.test.e2e.ts
@@ -56,3 +56,29 @@ test('Admin email list', async ({page}) => {
await expect(page.locator('[data-uid="9"] svg')).toHaveClass(/octicon-check/);
}
});
+
+test('Admin: delete a user', async ({page}) => {
+ const response = await page.goto('/admin/users/1/edit');
+ expect(response?.status()).toBe(200);
+
+ const modal = page.locator('#delete-user-modal');
+ const okButton = page.locator('#delete-user-modal .primary.button');
+
+ // Check that modal appears after clicking
+ await expect(modal).toBeHidden();
+ await expect(okButton).toBeHidden();
+ await page.locator('[data-modal="#delete-user-modal"]').click();
+ await expect(modal).toBeVisible();
+ await expect(okButton).toBeVisible();
+
+ // Agree with deletion
+ await okButton.click();
+
+ // Should have been redirected to /admin/users/1
+ await expect(page).toHaveURL(/\/admin\/users\/1$/);
+
+ // This test doesn't actually delete a user as it attempts to delete the doer and
+ // receives an error. This is enough to test that the request reaches the correct
+ // endpoint without causing e2e retry headache
+ await expect(page.locator('#flash-message')).toBeVisible();
+});
diff --git a/tests/e2e/clipboard-copy.test.e2e.ts b/tests/e2e/clipboard-copy.test.e2e.ts
index fd1118da2b..6cd9836316 100644
--- a/tests/e2e/clipboard-copy.test.e2e.ts
+++ b/tests/e2e/clipboard-copy.test.e2e.ts
@@ -16,8 +16,12 @@ test('copy src file path to clipboard', async ({page}) => {
expect(response?.status()).toBe(200);
await page.click('[data-clipboard-text]');
- const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
- expect(clipboardText).toContain('README.md');
+
+ await expect(async () => {
+ const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
+ expect(clipboardText).toContain('README.md');
+ }).toPass();
+
await expect(page.getByText('Copied')).toBeVisible();
await screenshot(page, page.getByText('Copied'), 50);
});
@@ -27,8 +31,12 @@ test('copy diff file path to clipboard', async ({page}) => {
expect(response?.status()).toBe(200);
await page.click('[data-clipboard-text]');
- const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
- expect(clipboardText).toContain('README.md');
+
+ await expect(async () => {
+ const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
+ expect(clipboardText).toContain('README.md');
+ }).toPass();
+
await expect(page.getByText('Copied')).toBeVisible();
await screenshot(page, page.getByText('Copied'), 50);
});
diff --git a/tests/e2e/issue-comment-dropzone.test.e2e.ts b/tests/e2e/issue-comment-dropzone.test.e2e.ts
index 40bd238dc1..739490f6ad 100644
--- a/tests/e2e/issue-comment-dropzone.test.e2e.ts
+++ b/tests/e2e/issue-comment-dropzone.test.e2e.ts
@@ -48,8 +48,10 @@ async function assertCopy(page: Page, startWith: string) {
const copyLink = preview.locator('.octicon-copy').locator('..');
await copyLink.click();
- const clipboardContent = await page.evaluate(() => navigator.clipboard.readText());
- expect(clipboardContent).toContain(startWith);
+ await expect(async () => {
+ const clipboardContent = await page.evaluate(() => navigator.clipboard.readText());
+ expect(clipboardContent).toContain(startWith);
+ }).toPass();
}
test('Paste image in new comment', async ({page}) => {
diff --git a/tests/e2e/issue-sidebar.test.e2e.ts b/tests/e2e/issue-sidebar.test.e2e.ts
index d785a19e18..3324595438 100644
--- a/tests/e2e/issue-sidebar.test.e2e.ts
+++ b/tests/e2e/issue-sidebar.test.e2e.ts
@@ -379,6 +379,9 @@ test('Issue: Reference', async ({page}) => {
);
await page.getByRole('button', {name: 'Copy'}).click();
- const reference = await page.evaluate(() => navigator.clipboard.readText());
- expect(reference).toBe('user2/repo1#1');
+
+ await expect(async () => {
+ const reference = await page.evaluate(() => navigator.clipboard.readText());
+ expect(reference).toBe('user2/repo1#1');
+ }).toPass();
});
diff --git a/tests/e2e/markdown-editor.test.e2e.ts b/tests/e2e/markdown-editor.test.e2e.ts
index 5274b25a73..acc7a00a24 100644
--- a/tests/e2e/markdown-editor.test.e2e.ts
+++ b/tests/e2e/markdown-editor.test.e2e.ts
@@ -367,7 +367,7 @@ test('Markdown insert table', async ({page}) => {
const newTableButton = area.locator('button[data-md-action="new-table"]');
await newTableButton.click();
- const newTableModal = page.locator('[data-modal-name="new-markdown-table"].active');
+ const newTableModal = page.locator('[data-modal-name="new-markdown-table"][open]');
await expect(newTableModal).toBeVisible();
await screenshot(page);
@@ -417,9 +417,9 @@ test('Markdown insert link', async ({page}) => {
const newLinkButton = area.locator('button[data-md-action="new-link"]');
await newLinkButton.click();
- const newLinkModal = page.locator('[data-modal-name="new-markdown-link"].active');
+ const newLinkModal = page.locator('[data-modal-name="new-markdown-link"][open]');
await expect(newLinkModal).toBeVisible();
- await accessibilityCheck({page}, ['[data-modal-name="new-markdown-link"].active'], [], []);
+ await accessibilityCheck({page}, ['[data-modal-name="new-markdown-link"][open]'], [], []);
await screenshot(page);
const urlInput = newLinkModal.locator('input[name="link-url"]');
@@ -455,9 +455,9 @@ test('Markdown insert link', async ({page}) => {
await textarea.press('ControlOrMeta+KeyK');
- const newLinkModal = page.locator('[data-modal-name="new-markdown-link"].active');
+ const newLinkModal = page.locator('[data-modal-name="new-markdown-link"][open]');
await expect(newLinkModal).toBeVisible();
- await accessibilityCheck({page}, ['[data-modal-name="new-markdown-link"].active'], [], []);
+ await accessibilityCheck({page}, ['[data-modal-name="new-markdown-link"][open]'], [], []);
await screenshot(page);
const urlInput = newLinkModal.locator('input[name="link-url"]');
@@ -579,7 +579,7 @@ test('Multiple combo markdown: insert table', async ({page}) => {
const newTableButtonOne = page.locator('[for="_combo_markdown_editor_0"] button[data-md-action="new-table"]');
await newTableButtonOne.click();
- const newTableModalOne = page.locator('div[data-markdown-table-modal-id="0"]');
+ const newTableModalOne = page.locator('dialog[data-markdown-table-modal-id="0"]');
await expect(newTableModalOne).toBeVisible();
await newTableModalOne.locator('input[name="table-rows"]').fill('3');
@@ -601,7 +601,7 @@ test('Multiple combo markdown: insert table', async ({page}) => {
const newTableButtonTwo = page.locator('[for="_combo_markdown_editor_1"] button[data-md-action="new-table"]');
await newTableButtonTwo.click();
- const newTableModalTwo = page.locator('div[data-markdown-table-modal-id="1"]');
+ const newTableModalTwo = page.locator('dialog[data-markdown-table-modal-id="1"]');
await expect(newTableModalTwo).toBeVisible();
await newTableModalTwo.locator('input[name="table-rows"]').fill('2');
diff --git a/tests/e2e/modal.test.e2e.ts b/tests/e2e/modal.test.e2e.ts
index 40fe33e9a6..28161a2012 100644
--- a/tests/e2e/modal.test.e2e.ts
+++ b/tests/e2e/modal.test.e2e.ts
@@ -103,27 +103,3 @@ test('Dialog modal: width', async ({page, isMobile}) => {
expect(width).toBe(800);
}
});
-
-test('Dialog modal: short viewport', async ({page, isMobile}) => {
- test.skip(isMobile);
-
- // Small height for viewport.
- await page.setViewportSize({
- width: 1000,
- height: 200,
- });
-
- await page.goto('/user2/repo1/settings');
-
- // Open modal with long content
- const deleteModal = page.locator('#delete-repo-modal');
- await expect(deleteModal).toBeHidden();
- await page.getByRole('button', {name: 'Delete this repository'}).click();
- await expect(deleteModal).toBeVisible();
-
- // Scroll to the bottom.
- const scrollY = await page.evaluate(() => document.querySelector('.ui.dimmer').scrollHeight);
- await page.mouse.wheel(0, scrollY);
- const scrollTop = await page.evaluate(() => document.querySelector('.ui.dimmer').scrollTop);
- expect(scrollTop).toBeGreaterThan(0);
-});
diff --git a/tests/e2e/repo-code.test.e2e.ts b/tests/e2e/repo-code.test.e2e.ts
index 7f9ca147ea..d84fb58b1a 100644
--- a/tests/e2e/repo-code.test.e2e.ts
+++ b/tests/e2e/repo-code.test.e2e.ts
@@ -148,11 +148,13 @@ test('Copy line permalink', async ({page}) => {
const response = await page.goto('/user2/repo1/src/branch/master/README.md?display=source#L1');
expect(response?.status()).toBe(200);
- await page.locator('.code-line-button').click();
- // eslint-disable-next-line playwright/no-force-option
- await page.locator('.tippy-box .copy-line-permalink').click({force: true});
- const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
- expect(clipboardText).toContain('README.md?display=source#L1');
+ await expect(async () => {
+ await page.locator('.code-line-button').click();
+ // eslint-disable-next-line playwright/no-force-option
+ await page.locator('.tippy-box .copy-line-permalink').click({force: true});
+ const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
+ expect(clipboardText).toContain('README.md?display=source#L1');
+ }).toPass();
});
test('Line menu styles', async ({page}) => {
diff --git a/tests/e2e/runner-management.test.e2e.ts b/tests/e2e/runner-management.test.e2e.ts
index c7211dbe05..02edbd6e87 100644
--- a/tests/e2e/runner-management.test.e2e.ts
+++ b/tests/e2e/runner-management.test.e2e.ts
@@ -121,9 +121,12 @@ test.describe('Runners of user2', () => {
await expect(page).toHaveTitle(/^Set up runner runner-991301 .*/);
await expect(page.getByRole('heading', {name: 'Set up runner runner-991301'})).toBeVisible();
- await page.getByRole('button', {name: 'Copy runner UUID'}).click();
- const runnerUUID = await page.evaluate(() => navigator.clipboard.readText());
- expect(runnerUUID).toMatch(uuidPattern);
+ let runnerUUID;
+ await expect(async () => {
+ await page.getByRole('button', {name: 'Copy runner UUID'}).click();
+ runnerUUID = await page.evaluate(() => navigator.clipboard.readText());
+ expect(runnerUUID).toMatch(uuidPattern);
+ }).toPass();
let runnerToken;
await expect(async () => {
@@ -230,14 +233,20 @@ test.describe('Runners of user2', () => {
await expect(page).toHaveTitle(/^Set up runner runner-2 .*/);
await expect(page.getByRole('heading', {name: 'Set up runner runner-2'})).toBeVisible();
- await page.getByRole('button', {name: 'Copy runner UUID'}).click();
- const runnerUUID = await page.evaluate(() => navigator.clipboard.readText());
- expect(runnerUUID).toEqual('3a20ad8d-d5d6-4b7b-ba55-841ac8264c17');
+ let runnerUUID;
+ await expect(async () => {
+ await page.getByRole('button', {name: 'Copy runner UUID'}).click();
+ runnerUUID = await page.evaluate(() => navigator.clipboard.readText());
+ expect(runnerUUID).toEqual('3a20ad8d-d5d6-4b7b-ba55-841ac8264c17');
+ }).toPass();
- await page.getByRole('button', {name: 'Copy runner token'}).click();
- const runnerToken = await page.evaluate(() => navigator.clipboard.readText());
- expect(runnerToken).not.toEqual('9730f9d2c6c731f07582788d1a1fe72a6b999a17');
- expect(runnerToken).toMatch(tokenPattern);
+ let runnerToken;
+ await expect(async () => {
+ await page.getByRole('button', {name: 'Copy runner token'}).click();
+ runnerToken = await page.evaluate(() => navigator.clipboard.readText());
+ expect(runnerToken).not.toEqual('9730f9d2c6c731f07582788d1a1fe72a6b999a17');
+ expect(runnerToken).toMatch(tokenPattern);
+ }).toPass();
await expect(page.getByRole('term')).toHaveText(['UUID', 'Token']);
await expect(page.getByRole('definition')).toContainText([runnerUUID, runnerToken]);
@@ -427,13 +436,19 @@ test.describe('Global runners', () => {
await expect(page).toHaveTitle(/^Set up runner runner-473465 .*/);
await expect(page.getByRole('heading', {name: 'Set up runner runner-473465'})).toBeVisible();
- await page.getByRole('button', {name: 'Copy runner UUID'}).click();
- const runnerUUID = await page.evaluate(() => navigator.clipboard.readText());
- expect(runnerUUID).toMatch(uuidPattern);
+ let runnerUUID;
+ await expect(async () => {
+ await page.getByRole('button', {name: 'Copy runner UUID'}).click();
+ runnerUUID = await page.evaluate(() => navigator.clipboard.readText());
+ expect(runnerUUID).toMatch(uuidPattern);
+ }).toPass();
- await page.getByRole('button', {name: 'Copy runner token'}).click();
- const runnerToken = await page.evaluate(() => navigator.clipboard.readText());
- expect(runnerToken).toMatch(tokenPattern);
+ let runnerToken;
+ await expect(async () => {
+ await page.getByRole('button', {name: 'Copy runner token'}).click();
+ runnerToken = await page.evaluate(() => navigator.clipboard.readText());
+ expect(runnerToken).toMatch(tokenPattern);
+ }).toPass();
await expect(page.getByRole('term')).toHaveText(['UUID', 'Token']);
await expect(page.getByRole('definition')).toContainText([runnerUUID, runnerToken]);
diff --git a/tests/integration/repo_archive_text_test.go b/tests/integration/repo_archive_text_test.go
index f0e88a8343..5a76e1e47b 100644
--- a/tests/integration/repo_archive_text_test.go
+++ b/tests/integration/repo_archive_text_test.go
@@ -63,7 +63,7 @@ func testRepoArchiveElements(t *testing.T, tr translation.Locale, doc *HTMLDoc,
// Test modal
modal := doc.Find("#archive-repo-modal")
- testRepoArchiveElement(t, tr, modal, ".header", opType+".header")
+ testRepoArchiveElement(t, tr, modal, "header", opType+".header")
testRepoArchiveElement(t, tr, modal, ".message", opType+".text")
testRepoArchiveElement(t, tr, modal, ".button.red", opType+".button")
}
diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js
index 93a970caa0..fe644e099e 100644
--- a/web_src/js/features/common-global.js
+++ b/web_src/js/features/common-global.js
@@ -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.
diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js
index b4bc443c0e..b42d054ae9 100644
--- a/web_src/js/features/comp/ComboMarkdownEditor.js
+++ b/web_src/js/features/comp/ComboMarkdownEditor.js
@@ -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);
diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js
index 523ce555c4..e0d4daebef 100644
--- a/web_src/js/features/repo-legacy.js
+++ b/web_src/js/features/repo-legacy.js
@@ -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);
diff --git a/web_src/js/features/show-modal.ts b/web_src/js/features/show-modal.ts
new file mode 100644
index 0000000000..9f58b83479
--- /dev/null
+++ b/web_src/js/features/show-modal.ts
@@ -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
(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);
+ });
+}
diff --git a/web_src/js/modules/modal.ts b/web_src/js/modules/modal.ts
index b6fef12b2b..f82239ed95 100644
--- a/web_src/js/modules/modal.ts
+++ b/web_src/js/modules/modal.ts
@@ -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 ``, 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();
}
diff --git a/web_src/js/types.d.ts b/web_src/js/types.d.ts
index d464f25fe9..3d3e83f465 100644
--- a/web_src/js/types.d.ts
+++ b/web_src/js/types.d.ts
@@ -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;
+ }
+}