feat: move more modals to native dialogs (#9636)

Follow up of forgejo/forgejo#8859

Move the following modals to native dialogs:
- Admin notice.
- Edit label.
- New label.
- Update email in admin's email list.

Each has a E2E test to screenshot the modal and test functionality.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9636
Reviewed-by: 0ko <0ko@noreply.codeberg.org>
Co-authored-by: Gusted <postmaster@gusted.xyz>
Co-committed-by: Gusted <postmaster@gusted.xyz>
This commit is contained in:
Gusted 2025-10-13 17:48:49 +02:00 committed by 0ko
commit 8eb8f49581
13 changed files with 242 additions and 149 deletions

View file

@ -75,13 +75,12 @@
{{template "base/paginate" .}} {{template "base/paginate" .}}
<div class="ui g-modal-confirm modal" id="change-email-modal"> <dialog id="change-email-modal">
<div class="header"> <article>
{{ctx.Locale.Tr "admin.emails.change_email_header"}} <header>{{ctx.Locale.Tr "admin.emails.change_email_header"}}</header>
</div> <div class="content">
<div class="content"> <p class="center">{{ctx.Locale.Tr "admin.emails.change_email_text"}}</p>
<p class="center">{{ctx.Locale.Tr "admin.emails.change_email_text"}}</p> </div>
<form class="ui form" id="email-action-form" action="{{AppSubUrl}}/admin/emails/activate" method="post"> <form class="ui form" id="email-action-form" action="{{AppSubUrl}}/admin/emails/activate" method="post">
{{$.CsrfTokenHtml}} {{$.CsrfTokenHtml}}
@ -99,9 +98,8 @@
{{template "base/modal_actions_confirm" .}} {{template "base/modal_actions_confirm" .}}
</div> </div>
</form> </form>
</div> </article>
</div> </dialog>
</div> </div>
<div class="ui g-modal-confirm delete modal" id="delete-email"> <div class="ui g-modal-confirm delete modal" id="delete-email">

View file

@ -62,9 +62,14 @@
{{template "base/paginate" .}} {{template "base/paginate" .}}
</div> </div>
<div class="ui modal admin" id="detail-modal"> <dialog id="detail-modal">
<div class="header">{{ctx.Locale.Tr "admin.notices.view_detail_header"}}</div> <article>
<div class="content"><pre></pre></div> <header>{{ctx.Locale.Tr "admin.notices.view_detail_header"}}</header>
</div> <div class="content"><pre></pre></div>
<div class="actions">
<button class="ui cancel button">{{ctx.Locale.Tr "settings.cancel"}}</button>
</div>
</article>
</dialog>
{{template "admin/layout_footer" .}} {{template "admin/layout_footer" .}}

View file

@ -22,7 +22,7 @@ The ".ok.button" and ".cancel.button" selectors are also used by Fomantic Modal
{{if .ModalButtonCancelText}}{{$textNegitive = .ModalButtonCancelText}}{{end}} {{if .ModalButtonCancelText}}{{$textNegitive = .ModalButtonCancelText}}{{end}}
{{if .ModalButtonOkText}}{{$textPositive = .ModalButtonOkText}}{{end}} {{if .ModalButtonOkText}}{{$textPositive = .ModalButtonOkText}}{{end}}
<button class="ui cancel button">{{svg "octicon-x"}} {{$textNegitive}}</button> <button type="button" class="ui cancel button">{{svg "octicon-x"}} {{$textNegitive}}</button>
<button class="ui primary ok button">{{svg "octicon-check"}} {{$textPositive}}</button> <button class="ui primary ok button">{{svg "octicon-check"}} {{$textPositive}}</button>
{{end}} {{end}}
</div> </div>

View file

@ -9,64 +9,64 @@
{{template "base/modal_actions_confirm" .}} {{template "base/modal_actions_confirm" .}}
</div> </div>
<div class="ui small edit-label modal"> <dialog id="edit-label-modal">
<div class="header"> <article>
{{ctx.Locale.Tr "repo.issues.label_modify"}} <header>{{ctx.Locale.Tr "repo.issues.label_modify"}}</header>
</div> <div class="content">
<div class="content"> <form class="ui edit-label form ignore-dirty" action="{{$.Link}}/edit" method="post">
<form class="ui edit-label form ignore-dirty" action="{{$.Link}}/edit" method="post"> {{.CsrfTokenHtml}}
{{.CsrfTokenHtml}} <input id="label-modal-id" name="id" type="hidden">
<input id="label-modal-id" name="id" type="hidden"> <div class="required field">
<div class="required field"> <label for="name">{{ctx.Locale.Tr "repo.issues.label_title"}}</label>
<label for="name">{{ctx.Locale.Tr "repo.issues.label_title"}}</label> <div class="ui small input">
<div class="ui small input"> <input class="label-name-input" name="title" placeholder="{{ctx.Locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50">
<input class="label-name-input" name="title" placeholder="{{ctx.Locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50"> </div>
</div> </div>
</div> <div class="field label-exclusive-input-field">
<div class="field label-exclusive-input-field"> <div class="ui checkbox">
<div class="ui checkbox"> <input class="label-exclusive-input" name="exclusive" type="checkbox">
<input class="label-exclusive-input" name="exclusive" type="checkbox"> <label>{{ctx.Locale.Tr "repo.issues.label_exclusive"}}</label>
<label>{{ctx.Locale.Tr "repo.issues.label_exclusive"}}</label> </div>
<br>
<small class="desc">{{ctx.Locale.Tr "repo.issues.label_exclusive_desc"}}</small>
<div class="desc tw-ml-1 tw-mt-2 tw-hidden label-exclusive-warning">
{{svg "octicon-alert"}} {{ctx.Locale.Tr "repo.issues.label_exclusive_warning"}}
</div>
<br>
</div> </div>
<br> <div class="field label-is-archived-input-field">
<small class="desc">{{ctx.Locale.Tr "repo.issues.label_exclusive_desc"}}</small> <div class="ui checkbox">
<div class="desc tw-ml-1 tw-mt-2 tw-hidden label-exclusive-warning"> <input class="label-is-archived-input" name="is_archived" type="checkbox">
{{svg "octicon-alert"}} {{ctx.Locale.Tr "repo.issues.label_exclusive_warning"}} <label>{{ctx.Locale.Tr "repo.issues.label_archive"}}</label>
</div>
<i class="tw-ml-1" data-tooltip-content={{ctx.Locale.Tr "repo.issues.label_archive_tooltip"}} data-tooltip-appendto="parent">
{{svg "octicon-info"}}
</i>
</div> </div>
<br> <div class="field">
</div> <label for="description">{{ctx.Locale.Tr "repo.issues.label_description"}}</label>
<div class="field label-is-archived-input-field"> <div class="ui small fluid input">
<div class="ui checkbox"> <input class="label-desc-input" name="description" placeholder="{{ctx.Locale.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200">
<input class="label-is-archived-input" name="is_archived" type="checkbox"> </div>
<label>{{ctx.Locale.Tr "repo.issues.label_archive"}}</label>
</div> </div>
<i class="tw-ml-1" data-tooltip-content={{ctx.Locale.Tr "repo.issues.label_archive_tooltip"}}> <div class="field color-field">
{{svg "octicon-info"}} <label for="color">{{ctx.Locale.Tr "repo.issues.label_color"}}</label>
</i> <div class="column js-color-picker-input">
</div> <input name="color" value="#70c24a"placeholder="#c320f6" required maxlength="7">
<div class="field"> {{template "repo/issue/label_precolors"}}
<label for="description">{{ctx.Locale.Tr "repo.issues.label_description"}}</label> </div>
<div class="ui small fluid input">
<input class="label-desc-input" name="description" placeholder="{{ctx.Locale.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200">
</div> </div>
</div> </form>
<div class="field color-field"> </div>
<label for="color">{{ctx.Locale.Tr "repo.issues.label_color"}}</label> <div class="actions">
<div class="column js-color-picker-input"> <button class="ui small basic cancel button">
<input name="color" value="#70c24a"placeholder="#c320f6" required maxlength="7"> {{svg "octicon-x"}}
{{template "repo/issue/label_precolors"}} {{ctx.Locale.Tr "cancel"}}
</div> </button>
</div> <button class="ui primary small approve button">
</form> {{svg "fontawesome-save"}}
</div> {{ctx.Locale.Tr "save"}}
<div class="actions"> </button>
<button class="ui small basic cancel button"> </div>
{{svg "octicon-x"}} </article>
{{ctx.Locale.Tr "cancel"}} </dialog>
</button>
<button class="ui primary small approve button">
{{svg "fontawesome-save"}}
{{ctx.Locale.Tr "save"}}
</button>
</div>
</div>

View file

@ -1,48 +1,48 @@
<div class="ui small new-label modal"> <dialog id="new-label-modal">
<div class="header"> <article>
{{ctx.Locale.Tr "repo.issues.new_label"}} <header>{{ctx.Locale.Tr "repo.issues.new_label"}}</header>
</div> <div class="content">
<div class="content"> <form class="ui new-label form ignore-dirty" action="{{$.Link}}/new" method="post">
<form class="ui new-label form ignore-dirty" action="{{$.Link}}/new" method="post"> {{.CsrfTokenHtml}}
{{.CsrfTokenHtml}} <div class="required field">
<div class="required field"> <label for="name">{{ctx.Locale.Tr "repo.issues.label_title"}}</label>
<label for="name">{{ctx.Locale.Tr "repo.issues.label_title"}}</label> <div class="ui small input">
<div class="ui small input"> <input class="label-name-input" name="title" placeholder="{{ctx.Locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50">
<input class="label-name-input" name="title" placeholder="{{ctx.Locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50"> </div>
</div> </div>
</div> <div class="field label-exclusive-input-field">
<div class="field label-exclusive-input-field"> <div class="ui checkbox">
<div class="ui checkbox"> <input class="label-exclusive-input" name="exclusive" type="checkbox">
<input class="label-exclusive-input" name="exclusive" type="checkbox"> <label>{{ctx.Locale.Tr "repo.issues.label_exclusive"}}</label>
<label>{{ctx.Locale.Tr "repo.issues.label_exclusive"}}</label> </div>
<br>
<small class="desc">{{ctx.Locale.Tr "repo.issues.label_exclusive_desc"}}</small>
</div> </div>
<br> <div class="field">
<small class="desc">{{ctx.Locale.Tr "repo.issues.label_exclusive_desc"}}</small> <label for="description">{{ctx.Locale.Tr "repo.issues.label_description"}}</label>
</div> <div class="ui small fluid input">
<div class="field"> <input class="label-desc-input" name="description" placeholder="{{ctx.Locale.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200">
<label for="description">{{ctx.Locale.Tr "repo.issues.label_description"}}</label> </div>
<div class="ui small fluid input">
<input class="label-desc-input" name="description" placeholder="{{ctx.Locale.Tr "repo.issues.new_label_desc_placeholder"}}" maxlength="200">
</div> </div>
</div> <div class="field color-field">
<div class="field color-field"> <label for="color">{{ctx.Locale.Tr "repo.issues.label_color"}}</label>
<label for="color">{{ctx.Locale.Tr "repo.issues.label_color"}}</label> <div class="js-color-picker-input column">
<div class="js-color-picker-input column"> <input name="color" value="#70c24a" placeholder="#c320f6" required maxlength="7">
<input name="color" value="#70c24a" placeholder="#c320f6" required maxlength="7"> {{template "repo/issue/label_precolors"}}
{{template "repo/issue/label_precolors"}} </div>
</div> </div>
</div> </form>
</form> </div>
</div>
<div class="actions"> <div class="actions">
<button class="ui cancel button"> <button class="ui cancel button">
{{svg "octicon-x"}} {{svg "octicon-x"}}
{{ctx.Locale.Tr "cancel"}} {{ctx.Locale.Tr "cancel"}}
</button> </button>
<button class="ui primary ok button"> <button class="ui primary ok button">
{{svg "octicon-check"}} {{svg "octicon-check"}}
{{ctx.Locale.Tr "repo.issues.create_label"}} {{ctx.Locale.Tr "repo.issues.create_label"}}
</button> </button>
</div> </div>
</div> </article>
</dialog>

View file

@ -0,0 +1,58 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
// @watch start
// web_src/js/features/admin/**
// templates/admin/**
// @watch end
import {expect} from '@playwright/test';
import {test} from './utils_e2e.ts';
import {screenshot} from './shared/screenshots.ts';
test.use({user: 'user1'});
test('Admin notices modal', async ({page}) => {
const response = await page.goto('/admin/notices');
expect(response?.status()).toBe(200);
await page.getByText('description1').click();
await expect(page.locator('#detail-modal .content')).toHaveText('description1');
await screenshot(page, page.locator('#detail-modal'));
await page.getByText('Cancel').click();
await expect(page.locator('#change-email-modal')).toBeHidden();
await page.getByText('description2').click();
await expect(page.locator('#detail-modal .content')).toHaveText('description2');
await screenshot(page, page.locator('#detail-modal'));
await page.getByText('Cancel').click();
await expect(page.locator('#change-email-modal')).toBeHidden();
await page.getByText('description3').click();
await expect(page.locator('#detail-modal .content')).toHaveText('description3');
await screenshot(page, page.locator('#detail-modal'));
await page.getByText('Cancel').click();
await expect(page.locator('#change-email-modal')).toBeHidden();
});
test('Admin email list', async ({page}) => {
const response = await page.goto('/admin/emails');
expect(response?.status()).toBe(200);
await page.locator('[data-uid="21"]').click();
await expect(page.locator('#change-email-modal .content')).toHaveText('Are you sure you want to update this email address?');
await screenshot(page, page.locator('#change-email-modal .content'));
await page.locator('#email-action-form').getByText('No').click();
await expect(page.locator('#change-email-modal')).toBeHidden();
const activated = await page.locator('[data-uid="9"] .svg').evaluate((node) => node.classList.contains('octicon-check'));
await page.locator('[data-uid="9"]').click();
await page.getByRole('button', {name: 'Yes'}).click();
// Retry-proof
if (activated) {
await expect(page.locator('[data-uid="9"] .svg')).toHaveClass(/octicon-x/);
} else {
await expect(page.locator('[data-uid="9"] svg')).toHaveClass(/octicon-check/);
}
});

View file

@ -0,0 +1,43 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
// @watch start
// templates/repo/issues/labels/**
// web_src/js/features/comp/LabelEdit.js
// @watch end
import {expect} from '@playwright/test';
import {test, dynamic_id} from './utils_e2e.ts';
import {screenshot} from './shared/screenshots.ts';
test.use({user: 'user2'});
test('New label', async ({page}) => {
const response = await page.goto('/user2/repo1/labels');
expect(response?.status()).toBe(200);
await page.getByRole('button', {name: 'New label'}).click();
await expect(page.locator('#new-label-modal')).toBeVisible();
await screenshot(page, page.locator('#new-label-modal'));
const labelName = dynamic_id();
await page.keyboard.type(labelName);
await page.getByRole('button', {name: 'Create label'}).click();
await page.locator('.label-title').filter({hasText: labelName}).isVisible();
});
test('Edit label', async ({page}) => {
const response = await page.goto('/user2/repo1/labels');
expect(response?.status()).toBe(200);
await page.getByText('Edit').first().click();
await expect(page.locator('#edit-label-modal')).toBeVisible();
await screenshot(page, page.locator('#edit-label-modal'));
const labelName = dynamic_id();
await page.keyboard.type(labelName);
await page.getByRole('button', {name: 'Save'}).click();
await page.locator('.label-title').filter({hasText: labelName}).isVisible();
});

View file

@ -29,8 +29,8 @@
min-width: 100px; min-width: 100px;
} }
.admin code, :is(.admin, #detail-modal) code,
.admin pre { :is(.admin, #detail-modal) pre {
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
} }

View file

@ -2133,17 +2133,6 @@ details.repo-search-result summary::marker {
padding: 1em; padding: 1em;
} }
.edit-label.modal .form .column,
.new-label.modal .form .column {
padding-right: 0;
}
.edit-label.modal .form .buttons,
.new-label.modal .form .buttons {
margin-left: auto;
padding-top: 15px;
}
.stats-table { .stats-table {
display: table; display: table;
width: 100%; width: 100%;

View file

@ -2,6 +2,7 @@ import $ from 'jquery';
import {checkAppUrl} from '../common-global.js'; import {checkAppUrl} from '../common-global.js';
import {hideElem, showElem, toggleElem} from '../../utils/dom.js'; import {hideElem, showElem, toggleElem} from '../../utils/dom.js';
import {POST} from '../../modules/fetch.js'; import {POST} from '../../modules/fetch.js';
import {showModal} from '../../modules/modal.ts';
const {appSubUrl} = window.config; const {appSubUrl} = window.config;
@ -216,7 +217,7 @@ export function initAdminCommon() {
$('.view-detail').on('click', function () { $('.view-detail').on('click', function () {
const description = this.closest('tr').querySelector('.notice-description').textContent; const description = this.closest('tr').querySelector('.notice-description').textContent;
detailModal.querySelector('.content pre').textContent = description; detailModal.querySelector('.content pre').textContent = description;
$(detailModal).modal('show'); showModal('detail-modal', undefined);
return false; return false;
}); });

View file

@ -1,4 +1,5 @@
import $ from 'jquery'; import $ from 'jquery';
import {showModal} from '../../modules/modal.ts';
export function initAdminEmails() { export function initAdminEmails() {
function linkEmailAction(e) { function linkEmailAction(e) {
@ -7,7 +8,7 @@ export function initAdminEmails() {
$('#form-email').val($this.data('email')); $('#form-email').val($this.data('email'));
$('#form-primary').val($this.data('primary')); $('#form-primary').val($this.data('primary'));
$('#form-activate').val($this.data('activate')); $('#form-activate').val($this.data('activate'));
$('#change-email-modal').modal('show'); showModal('change-email-modal', undefined);
e.preventDefault(); e.preventDefault();
} }
$('.link-email-action').on('click', linkEmailAction); $('.link-email-action').on('click', linkEmailAction);

View file

@ -1,4 +1,5 @@
import $ from 'jquery'; import $ from 'jquery';
import {showModal} from '../../modules/modal.ts';
function isExclusiveScopeName(name) { function isExclusiveScopeName(name) {
return /.*[^/]\/[^/].*/.test(name); return /.*[^/]\/[^/].*/.test(name);
@ -27,16 +28,14 @@ export function initCompLabelEdit(selector) {
// Create label // Create label
$('.new-label.button').on('click', () => { $('.new-label.button').on('click', () => {
updateExclusiveLabelEdit('.new-label'); updateExclusiveLabelEdit('.new-label');
$('.new-label.modal').modal({ showModal('new-label-modal', () => {
onApprove() { const form = document.querySelector('.new-label.form');
const form = document.querySelector('.new-label.form'); if (!form.checkValidity()) {
if (!form.checkValidity()) { form.reportValidity();
form.reportValidity(); return false;
return false; }
} document.querySelector('.new-label.form').requestSubmit();
document.querySelector('.new-label.form').requestSubmit(); });
},
}).modal('show');
return false; return false;
}); });
@ -64,16 +63,14 @@ export function initCompLabelEdit(selector) {
colorInput.value = this.getAttribute('data-color'); colorInput.value = this.getAttribute('data-color');
colorInput.dispatchEvent(new Event('input', {bubbles: true})); colorInput.dispatchEvent(new Event('input', {bubbles: true}));
$('.edit-label.modal').modal({ showModal('edit-label-modal', () => {
onApprove() { const form = document.querySelector('.edit-label.form');
const form = document.querySelector('.edit-label.form'); if (!form.checkValidity()) {
if (!form.checkValidity()) { form.reportValidity();
form.reportValidity(); return false;
return false; }
} document.querySelector('.edit-label.form').requestSubmit();
document.querySelector('.edit-label.form').requestSubmit(); });
},
}).modal('show');
return false; return false;
}); });

View file

@ -81,6 +81,7 @@ function attachTooltip(target, content = null) {
hideOnClick, hideOnClick,
placement: target.getAttribute('data-tooltip-placement') || 'top-start', placement: target.getAttribute('data-tooltip-placement') || 'top-start',
followCursor: target.getAttribute('data-tooltip-follow-cursor') || false, followCursor: target.getAttribute('data-tooltip-follow-cursor') || false,
...(target.getAttribute('data-tooltip-appendto') === 'parent' ? {appendTo: 'parent'} : {}),
...(target.getAttribute('data-tooltip-interactive') === 'true' ? {interactive: true, aria: {content: 'describedby', expanded: false}} : {}), ...(target.getAttribute('data-tooltip-interactive') === 'true' ? {interactive: true, aria: {content: 'describedby', expanded: false}} : {}),
}; };