From b5326a431f8b5c1c0fde524a50d75a4e19833e68 Mon Sep 17 00:00:00 2001
From: Chl <42654312+xlii-chl@users.noreply.github.com>
Date: Tue, 25 Jun 2024 19:09:13 +0200
Subject: [PATCH 01/59] Optimization of labels handling in issue_search
(#26460)
This PR enhances the labels handling in issue_search by optimizing the
SQL query and de-duplicate the IDs when generating the query string.
---------
Co-authored-by: techknowlogick
---
models/issues/issue_search.go | 26 ++++++++++++++++++++++----
models/issues/label.go | 21 ++++++++++++++++-----
models/issues/label_test.go | 21 +++++++++++++++++++++
3 files changed, 59 insertions(+), 9 deletions(-)
diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go
index c1d7d921a948..b238f831ae54 100644
--- a/models/issues/issue_search.go
+++ b/models/issues/issue_search.go
@@ -6,6 +6,7 @@ package issues
import (
"context"
"fmt"
+ "strconv"
"strings"
"code.gitea.io/gitea/models/db"
@@ -13,6 +14,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/optional"
"xorm.io/builder"
@@ -116,14 +118,30 @@ func applyLabelsCondition(sess *xorm.Session, opts *IssuesOptions) {
if opts.LabelIDs[0] == 0 {
sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_label)")
} else {
- for i, labelID := range opts.LabelIDs {
+ // We sort and deduplicate the labels' ids
+ IncludedLabelIDs := make(container.Set[int64])
+ ExcludedLabelIDs := make(container.Set[int64])
+ for _, labelID := range opts.LabelIDs {
if labelID > 0 {
- sess.Join("INNER", fmt.Sprintf("issue_label il%d", i),
- fmt.Sprintf("issue.id = il%[1]d.issue_id AND il%[1]d.label_id = %[2]d", i, labelID))
+ IncludedLabelIDs.Add(labelID)
} else if labelID < 0 { // 0 is not supported here, so just ignore it
- sess.Where("issue.id not in (select issue_id from issue_label where label_id = ?)", -labelID)
+ ExcludedLabelIDs.Add(-labelID)
}
}
+ // ... and use them in a subquery of the form :
+ // where (select count(*) from issue_label where issue_id=issue.id and label_id in (2, 4, 6)) = 3
+ // This equality is guaranteed thanks to unique index (issue_id,label_id) on table issue_label.
+ if len(IncludedLabelIDs) > 0 {
+ subquery := builder.Select("count(*)").From("issue_label").Where(builder.Expr("issue_id = issue.id")).
+ And(builder.In("label_id", IncludedLabelIDs.Values()))
+ sess.Where(builder.Eq{strconv.Itoa(len(IncludedLabelIDs)): subquery})
+ }
+ // or (select count(*)...) = 0 for excluded labels
+ if len(ExcludedLabelIDs) > 0 {
+ subquery := builder.Select("count(*)").From("issue_label").Where(builder.Expr("issue_id = issue.id")).
+ And(builder.In("label_id", ExcludedLabelIDs.Values()))
+ sess.Where(builder.Eq{"0": subquery})
+ }
}
}
diff --git a/models/issues/label.go b/models/issues/label.go
index 2397a29e357e..2d7acb7f0c53 100644
--- a/models/issues/label.go
+++ b/models/issues/label.go
@@ -7,6 +7,7 @@ package issues
import (
"context"
"fmt"
+ "slices"
"strconv"
"strings"
@@ -142,9 +143,8 @@ func (l *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64) {
// LoadSelectedLabelsAfterClick calculates the set of selected labels when a label is clicked
func (l *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64, currentSelectedExclusiveScopes []string) {
- var labelQuerySlice []string
+ labelQuerySlice := []int64{}
labelSelected := false
- labelID := strconv.FormatInt(l.ID, 10)
labelScope := l.ExclusiveScope()
for i, s := range currentSelectedLabels {
if s == l.ID {
@@ -155,15 +155,26 @@ func (l *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64, curr
} else if s != 0 {
// Exclude other labels in the same scope from selection
if s < 0 || labelScope == "" || labelScope != currentSelectedExclusiveScopes[i] {
- labelQuerySlice = append(labelQuerySlice, strconv.FormatInt(s, 10))
+ labelQuerySlice = append(labelQuerySlice, s)
}
}
}
+
if !labelSelected {
- labelQuerySlice = append(labelQuerySlice, labelID)
+ labelQuerySlice = append(labelQuerySlice, l.ID)
}
l.IsSelected = labelSelected
- l.QueryString = strings.Join(labelQuerySlice, ",")
+
+ // Sort and deduplicate the ids to avoid the crawlers asking for the
+ // same thing with simply a different order of parameters
+ slices.Sort(labelQuerySlice)
+ labelQuerySlice = slices.Compact(labelQuerySlice)
+ // Quick conversion (strings.Join() doesn't accept slices of Int64)
+ labelQuerySliceStrings := make([]string, len(labelQuerySlice))
+ for i, x := range labelQuerySlice {
+ labelQuerySliceStrings[i] = strconv.FormatInt(x, 10)
+ }
+ l.QueryString = strings.Join(labelQuerySliceStrings, ",")
}
// BelongsToOrg returns true if label is an organization label
diff --git a/models/issues/label_test.go b/models/issues/label_test.go
index 517a3cf1abd4..396de809e1d8 100644
--- a/models/issues/label_test.go
+++ b/models/issues/label_test.go
@@ -23,6 +23,27 @@ func TestLabel_CalOpenIssues(t *testing.T) {
assert.EqualValues(t, 2, label.NumOpenIssues)
}
+func TestLabel_LoadSelectedLabelsAfterClick(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ // Loading the label id:8 which have a scope and an exclusivity
+ label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 8})
+
+ // First test : with negative and scope
+ label.LoadSelectedLabelsAfterClick([]int64{1, -8}, []string{"", "scope"})
+ assert.Equal(t, "1", label.QueryString)
+ assert.Equal(t, true, label.IsSelected)
+
+ // Second test : with duplicates
+ label.LoadSelectedLabelsAfterClick([]int64{1, 7, 1, 7, 7}, []string{"", "scope", "", "scope", "scope"})
+ assert.Equal(t, "1,8", label.QueryString)
+ assert.Equal(t, false, label.IsSelected)
+
+ // Third test : empty set
+ label.LoadSelectedLabelsAfterClick([]int64{}, []string{})
+ assert.False(t, label.IsSelected)
+ assert.Equal(t, "8", label.QueryString)
+}
+
func TestLabel_ExclusiveScope(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: 7})
From 35ce7a5e0e03f9a2ce14a6f34ef6ac839e36aacd Mon Sep 17 00:00:00 2001
From: GiteaBot
Date: Wed, 26 Jun 2024 00:26:17 +0000
Subject: [PATCH 02/59] [skip ci] Updated translations via Crowdin
---
options/locale/locale_fr-FR.ini | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/options/locale/locale_fr-FR.ini b/options/locale/locale_fr-FR.ini
index 44bfdfa26f83..ae32b861f675 100644
--- a/options/locale/locale_fr-FR.ini
+++ b/options/locale/locale_fr-FR.ini
@@ -2562,7 +2562,7 @@ release.stable=Stable
release.compare=Comparer
release.edit=Éditer
release.ahead.commits=%d révisions
-release.ahead.target=à %s depuis cette publication
+release.ahead.target=sur %s depuis cette publication
tag.ahead.target=à %s depuis cette étiquette
release.source_code=Code source
release.new_subheader=Les publications vous aide à organiser les versions marquantes de votre projet.
From a88f718c1055c7b5a3c3e77e04d433c77ab6d12e Mon Sep 17 00:00:00 2001
From: wxiaoguang
Date: Thu, 27 Jun 2024 01:01:20 +0800
Subject: [PATCH 03/59] Refactor dropzone (#31482)
Refactor the legacy code and remove some jQuery calls.
---
.../js/features/comp/ComboMarkdownEditor.js | 22 ++-
web_src/js/features/dropzone.js | 164 ++++++++++++------
web_src/js/features/repo-editor.js | 12 +-
web_src/js/features/repo-issue-edit.js | 114 ++----------
web_src/js/features/repo-issue.js | 27 +--
web_src/js/index.js | 2 -
web_src/js/utils/dom.js | 14 ++
web_src/js/utils/dom.test.js | 13 +-
8 files changed, 184 insertions(+), 184 deletions(-)
diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js
index f40b0bdc17bc..bd11c8383c35 100644
--- a/web_src/js/features/comp/ComboMarkdownEditor.js
+++ b/web_src/js/features/comp/ComboMarkdownEditor.js
@@ -11,6 +11,7 @@ import {initTextExpander} from './TextExpander.js';
import {showErrorToast} from '../../modules/toast.js';
import {POST} from '../../modules/fetch.js';
import {initTextareaMarkdown} from './EditorMarkdown.js';
+import {initDropzone} from '../dropzone.js';
let elementIdCounter = 0;
@@ -47,7 +48,7 @@ class ComboMarkdownEditor {
this.prepareEasyMDEToolbarActions();
this.setupContainer();
this.setupTab();
- this.setupDropzone();
+ await this.setupDropzone(); // textarea depends on dropzone
this.setupTextarea();
await this.switchToUserPreference();
@@ -114,13 +115,30 @@ class ComboMarkdownEditor {
}
}
- setupDropzone() {
+ async setupDropzone() {
const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container');
if (dropzoneParentContainer) {
this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container'))?.querySelector('.dropzone');
+ if (this.dropzone) this.attachedDropzoneInst = await initDropzone(this.dropzone);
}
}
+ dropzoneGetFiles() {
+ if (!this.dropzone) return null;
+ return Array.from(this.dropzone.querySelectorAll('.files [name=files]'), (el) => el.value);
+ }
+
+ dropzoneReloadFiles() {
+ if (!this.dropzone) return;
+ this.attachedDropzoneInst.emit('reload');
+ }
+
+ dropzoneSubmitReload() {
+ if (!this.dropzone) return;
+ this.attachedDropzoneInst.emit('submit');
+ this.attachedDropzoneInst.emit('reload');
+ }
+
setupTab() {
const tabs = this.container.querySelectorAll('.tabular.menu > .item');
diff --git a/web_src/js/features/dropzone.js b/web_src/js/features/dropzone.js
index b3acaf5e6f09..8d70fc774b19 100644
--- a/web_src/js/features/dropzone.js
+++ b/web_src/js/features/dropzone.js
@@ -1,14 +1,14 @@
-import $ from 'jquery';
import {svg} from '../svg.js';
import {htmlEscape} from 'escape-goat';
import {clippie} from 'clippie';
import {showTemporaryTooltip} from '../modules/tippy.js';
-import {POST} from '../modules/fetch.js';
+import {GET, POST} from '../modules/fetch.js';
import {showErrorToast} from '../modules/toast.js';
+import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.js';
const {csrfToken, i18n} = window.config;
-export async function createDropzone(el, opts) {
+async function createDropzone(el, opts) {
const [{Dropzone}] = await Promise.all([
import(/* webpackChunkName: "dropzone" */'dropzone'),
import(/* webpackChunkName: "dropzone" */'dropzone/dist/dropzone.css'),
@@ -16,65 +16,119 @@ export async function createDropzone(el, opts) {
return new Dropzone(el, opts);
}
-export function initGlobalDropzone() {
- for (const el of document.querySelectorAll('.dropzone')) {
- initDropzone(el);
- }
+function addCopyLink(file) {
+ // Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard
+ // The "" element has a hardcoded cursor: pointer because the default is overridden by .dropzone
+ const copyLinkEl = createElementFromHTML(`
+`);
+ copyLinkEl.addEventListener('click', async (e) => {
+ e.preventDefault();
+ let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`;
+ if (file.type?.startsWith('image/')) {
+ fileMarkdown = `!${fileMarkdown}`;
+ } else if (file.type?.startsWith('video/')) {
+ fileMarkdown = ``;
+ }
+ const success = await clippie(fileMarkdown);
+ showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error);
+ });
+ file.previewTemplate.append(copyLinkEl);
}
-export function initDropzone(el) {
- const $dropzone = $(el);
- const _promise = createDropzone(el, {
- url: $dropzone.data('upload-url'),
+/**
+ * @param {HTMLElement} dropzoneEl
+ */
+export async function initDropzone(dropzoneEl) {
+ const listAttachmentsUrl = dropzoneEl.closest('[data-attachment-url]')?.getAttribute('data-attachment-url');
+ const removeAttachmentUrl = dropzoneEl.getAttribute('data-remove-url');
+ const attachmentBaseLinkUrl = dropzoneEl.getAttribute('data-link-url');
+
+ let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event
+ let fileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone
+ const opts = {
+ url: dropzoneEl.getAttribute('data-upload-url'),
headers: {'X-Csrf-Token': csrfToken},
- maxFiles: $dropzone.data('max-file'),
- maxFilesize: $dropzone.data('max-size'),
- acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'),
+ acceptedFiles: ['*/*', ''].includes(dropzoneEl.getAttribute('data-accepts')) ? null : dropzoneEl.getAttribute('data-accepts'),
addRemoveLinks: true,
- dictDefaultMessage: $dropzone.data('default-message'),
- dictInvalidFileType: $dropzone.data('invalid-input-type'),
- dictFileTooBig: $dropzone.data('file-too-big'),
- dictRemoveFile: $dropzone.data('remove-file'),
+ dictDefaultMessage: dropzoneEl.getAttribute('data-default-message'),
+ dictInvalidFileType: dropzoneEl.getAttribute('data-invalid-input-type'),
+ dictFileTooBig: dropzoneEl.getAttribute('data-file-too-big'),
+ dictRemoveFile: dropzoneEl.getAttribute('data-remove-file'),
timeout: 0,
thumbnailMethod: 'contain',
thumbnailWidth: 480,
thumbnailHeight: 480,
- init() {
- this.on('success', (file, data) => {
- file.uuid = data.uuid;
- const $input = $(``).val(data.uuid);
- $dropzone.find('.files').append($input);
- // Create a "Copy Link" element, to conveniently copy the image
- // or file link as Markdown to the clipboard
- const copyLinkElement = document.createElement('div');
- copyLinkElement.className = 'tw-text-center';
- // The a element has a hardcoded cursor: pointer because the default is overridden by .dropzone
- copyLinkElement.innerHTML = `${svg('octicon-copy', 14, 'copy link')} Copy link`;
- copyLinkElement.addEventListener('click', async (e) => {
- e.preventDefault();
- let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`;
- if (file.type.startsWith('image/')) {
- fileMarkdown = `!${fileMarkdown}`;
- } else if (file.type.startsWith('video/')) {
- fileMarkdown = ``;
- }
- const success = await clippie(fileMarkdown);
- showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error);
- });
- file.previewTemplate.append(copyLinkElement);
- });
- this.on('removedfile', (file) => {
- $(`#${file.uuid}`).remove();
- if ($dropzone.data('remove-url')) {
- POST($dropzone.data('remove-url'), {
- data: new URLSearchParams({file: file.uuid}),
- });
- }
- });
- this.on('error', function (file, message) {
- showErrorToast(message);
- this.removeFile(file);
- });
- },
+ };
+ if (dropzoneEl.hasAttribute('data-max-file')) opts.maxFiles = Number(dropzoneEl.getAttribute('data-max-file'));
+ if (dropzoneEl.hasAttribute('data-max-size')) opts.maxFilesize = Number(dropzoneEl.getAttribute('data-max-size'));
+
+ // there is a bug in dropzone: if a non-image file is uploaded, then it tries to request the file from server by something like:
+ // "http://localhost:3000/owner/repo/issues/[object%20Event]"
+ // the reason is that the preview "callback(dataURL)" is assign to "img.onerror" then "thumbnail" uses the error object as the dataURL and generates ''
+ const dzInst = await createDropzone(dropzoneEl, opts);
+ dzInst.on('success', (file, data) => {
+ file.uuid = data.uuid;
+ fileUuidDict[file.uuid] = {submitted: false};
+ const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${data.uuid}`, value: data.uuid});
+ dropzoneEl.querySelector('.files').append(input);
+ addCopyLink(file);
+ });
+
+ dzInst.on('removedfile', async (file) => {
+ if (disableRemovedfileEvent) return;
+ document.querySelector(`#dropzone-file-${file.uuid}`)?.remove();
+ // when the uploaded file number reaches the limit, there is no uuid in the dict, and it doesn't need to be removed from server
+ if (removeAttachmentUrl && fileUuidDict[file.uuid] && !fileUuidDict[file.uuid].submitted) {
+ await POST(removeAttachmentUrl, {data: new URLSearchParams({file: file.uuid})});
+ }
+ });
+
+ dzInst.on('submit', () => {
+ for (const fileUuid of Object.keys(fileUuidDict)) {
+ fileUuidDict[fileUuid].submitted = true;
+ }
});
+
+ dzInst.on('reload', async () => {
+ try {
+ const resp = await GET(listAttachmentsUrl);
+ const respData = await resp.json();
+ // do not trigger the "removedfile" event, otherwise the attachments would be deleted from server
+ disableRemovedfileEvent = true;
+ dzInst.removeAllFiles(true);
+ disableRemovedfileEvent = false;
+
+ dropzoneEl.querySelector('.files').innerHTML = '';
+ for (const el of dropzoneEl.querySelectorAll('.dz-preview')) el.remove();
+ fileUuidDict = {};
+ for (const attachment of respData) {
+ const imgSrc = `${attachmentBaseLinkUrl}/${attachment.uuid}`;
+ dzInst.emit('addedfile', attachment);
+ dzInst.emit('thumbnail', attachment, imgSrc);
+ dzInst.emit('complete', attachment);
+ addCopyLink(attachment);
+ fileUuidDict[attachment.uuid] = {submitted: true};
+ const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${attachment.uuid}`, value: attachment.uuid});
+ dropzoneEl.querySelector('.files').append(input);
+ }
+ if (!dropzoneEl.querySelector('.dz-preview')) {
+ dropzoneEl.classList.remove('dz-started');
+ }
+ } catch (error) {
+ // TODO: if listing the existing attachments failed, it should stop from operating the content or attachments,
+ // otherwise the attachments might be lost.
+ showErrorToast(`Failed to load attachments: ${error}`);
+ console.error(error);
+ }
+ });
+
+ dzInst.on('error', (file, message) => {
+ showErrorToast(`Dropzone upload error: ${message}`);
+ dzInst.removeFile(file);
+ });
+
+ if (listAttachmentsUrl) dzInst.emit('reload');
+ return dzInst;
}
diff --git a/web_src/js/features/repo-editor.js b/web_src/js/features/repo-editor.js
index aa9ca657b098..f25da911df1c 100644
--- a/web_src/js/features/repo-editor.js
+++ b/web_src/js/features/repo-editor.js
@@ -5,6 +5,7 @@ import {hideElem, queryElems, showElem} from '../utils/dom.js';
import {initMarkupContent} from '../markup/content.js';
import {attachRefIssueContextPopup} from './contextpopup.js';
import {POST} from '../modules/fetch.js';
+import {initDropzone} from './dropzone.js';
function initEditPreviewTab($form) {
const $tabMenu = $form.find('.repo-editor-menu');
@@ -41,8 +42,11 @@ function initEditPreviewTab($form) {
}
export function initRepoEditor() {
- const $editArea = $('.repository.editor textarea#edit_area');
- if (!$editArea.length) return;
+ const dropzoneUpload = document.querySelector('.page-content.repository.editor.upload .dropzone');
+ if (dropzoneUpload) initDropzone(dropzoneUpload);
+
+ const editArea = document.querySelector('.page-content.repository.editor textarea#edit_area');
+ if (!editArea) return;
for (const el of queryElems('.js-quick-pull-choice-option')) {
el.addEventListener('input', () => {
@@ -108,7 +112,7 @@ export function initRepoEditor() {
initEditPreviewTab($form);
(async () => {
- const editor = await createCodeEditor($editArea[0], filenameInput);
+ const editor = await createCodeEditor(editArea, filenameInput);
// Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage
// to enable or disable the commit button
@@ -142,7 +146,7 @@ export function initRepoEditor() {
commitButton?.addEventListener('click', (e) => {
// A modal which asks if an empty file should be committed
- if (!$editArea.val()) {
+ if (!editArea.value) {
$('#edit-empty-content-modal').modal({
onApprove() {
$('.edit.form').trigger('submit');
diff --git a/web_src/js/features/repo-issue-edit.js b/web_src/js/features/repo-issue-edit.js
index 5fafdcf17c10..8bc0c02bcb40 100644
--- a/web_src/js/features/repo-issue-edit.js
+++ b/web_src/js/features/repo-issue-edit.js
@@ -1,15 +1,12 @@
import $ from 'jquery';
import {handleReply} from './repo-issue.js';
import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
-import {createDropzone} from './dropzone.js';
-import {GET, POST} from '../modules/fetch.js';
+import {POST} from '../modules/fetch.js';
import {showErrorToast} from '../modules/toast.js';
import {hideElem, showElem} from '../utils/dom.js';
import {attachRefIssueContextPopup} from './contextpopup.js';
import {initCommentContent, initMarkupContent} from '../markup/content.js';
-const {csrfToken} = window.config;
-
async function onEditContent(event) {
event.preventDefault();
@@ -20,114 +17,27 @@ async function onEditContent(event) {
let comboMarkdownEditor;
- /**
- * @param {HTMLElement} dropzone
- */
- const setupDropzone = async (dropzone) => {
- if (!dropzone) return null;
-
- let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event
- let fileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone
- const dz = await createDropzone(dropzone, {
- url: dropzone.getAttribute('data-upload-url'),
- headers: {'X-Csrf-Token': csrfToken},
- maxFiles: dropzone.getAttribute('data-max-file'),
- maxFilesize: dropzone.getAttribute('data-max-size'),
- acceptedFiles: ['*/*', ''].includes(dropzone.getAttribute('data-accepts')) ? null : dropzone.getAttribute('data-accepts'),
- addRemoveLinks: true,
- dictDefaultMessage: dropzone.getAttribute('data-default-message'),
- dictInvalidFileType: dropzone.getAttribute('data-invalid-input-type'),
- dictFileTooBig: dropzone.getAttribute('data-file-too-big'),
- dictRemoveFile: dropzone.getAttribute('data-remove-file'),
- timeout: 0,
- thumbnailMethod: 'contain',
- thumbnailWidth: 480,
- thumbnailHeight: 480,
- init() {
- this.on('success', (file, data) => {
- file.uuid = data.uuid;
- fileUuidDict[file.uuid] = {submitted: false};
- const input = document.createElement('input');
- input.id = data.uuid;
- input.name = 'files';
- input.type = 'hidden';
- input.value = data.uuid;
- dropzone.querySelector('.files').append(input);
- });
- this.on('removedfile', async (file) => {
- document.querySelector(`#${file.uuid}`)?.remove();
- if (disableRemovedfileEvent) return;
- if (dropzone.getAttribute('data-remove-url') && !fileUuidDict[file.uuid].submitted) {
- try {
- await POST(dropzone.getAttribute('data-remove-url'), {data: new URLSearchParams({file: file.uuid})});
- } catch (error) {
- console.error(error);
- }
- }
- });
- this.on('submit', () => {
- for (const fileUuid of Object.keys(fileUuidDict)) {
- fileUuidDict[fileUuid].submitted = true;
- }
- });
- this.on('reload', async () => {
- try {
- const response = await GET(editContentZone.getAttribute('data-attachment-url'));
- const data = await response.json();
- // do not trigger the "removedfile" event, otherwise the attachments would be deleted from server
- disableRemovedfileEvent = true;
- dz.removeAllFiles(true);
- dropzone.querySelector('.files').innerHTML = '';
- for (const el of dropzone.querySelectorAll('.dz-preview')) el.remove();
- fileUuidDict = {};
- disableRemovedfileEvent = false;
-
- for (const attachment of data) {
- const imgSrc = `${dropzone.getAttribute('data-link-url')}/${attachment.uuid}`;
- dz.emit('addedfile', attachment);
- dz.emit('thumbnail', attachment, imgSrc);
- dz.emit('complete', attachment);
- fileUuidDict[attachment.uuid] = {submitted: true};
- dropzone.querySelector(`img[src='${imgSrc}']`).style.maxWidth = '100%';
- const input = document.createElement('input');
- input.id = attachment.uuid;
- input.name = 'files';
- input.type = 'hidden';
- input.value = attachment.uuid;
- dropzone.querySelector('.files').append(input);
- }
- if (!dropzone.querySelector('.dz-preview')) {
- dropzone.classList.remove('dz-started');
- }
- } catch (error) {
- console.error(error);
- }
- });
- },
- });
- dz.emit('reload');
- return dz;
- };
-
const cancelAndReset = (e) => {
e.preventDefault();
showElem(renderContent);
hideElem(editContentZone);
- comboMarkdownEditor.attachedDropzoneInst?.emit('reload');
+ comboMarkdownEditor.dropzoneReloadFiles();
};
const saveAndRefresh = async (e) => {
e.preventDefault();
+ renderContent.classList.add('is-loading');
showElem(renderContent);
hideElem(editContentZone);
- const dropzoneInst = comboMarkdownEditor.attachedDropzoneInst;
try {
const params = new URLSearchParams({
content: comboMarkdownEditor.value(),
context: editContentZone.getAttribute('data-context'),
content_version: editContentZone.getAttribute('data-content-version'),
});
- for (const fileInput of dropzoneInst?.element.querySelectorAll('.files [name=files]')) params.append('files[]', fileInput.value);
+ for (const file of comboMarkdownEditor.dropzoneGetFiles() ?? []) {
+ params.append('files[]', file);
+ }
const response = await POST(editContentZone.getAttribute('data-update-url'), {data: params});
const data = await response.json();
@@ -155,12 +65,14 @@ async function onEditContent(event) {
} else {
content.querySelector('.dropzone-attachments').outerHTML = data.attachments;
}
- dropzoneInst?.emit('submit');
- dropzoneInst?.emit('reload');
+ comboMarkdownEditor.dropzoneSubmitReload();
initMarkupContent();
initCommentContent();
} catch (error) {
+ showErrorToast(`Failed to save the content: ${error}`);
console.error(error);
+ } finally {
+ renderContent.classList.remove('is-loading');
}
};
@@ -168,7 +80,6 @@ async function onEditContent(event) {
if (!comboMarkdownEditor) {
editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template').innerHTML;
comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
- comboMarkdownEditor.attachedDropzoneInst = await setupDropzone(editContentZone.querySelector('.dropzone'));
editContentZone.querySelector('.ui.cancel.button').addEventListener('click', cancelAndReset);
editContentZone.querySelector('.ui.primary.button').addEventListener('click', saveAndRefresh);
}
@@ -176,6 +87,7 @@ async function onEditContent(event) {
// Show write/preview tab and copy raw content as needed
showElem(editContentZone);
hideElem(renderContent);
+ // FIXME: ideally here should reload content and attachment list from backend for existing editor, to avoid losing data
if (!comboMarkdownEditor.value()) {
comboMarkdownEditor.value(rawContent.textContent);
}
@@ -196,8 +108,8 @@ export function initRepoIssueCommentEdit() {
let editor;
if (this.classList.contains('quote-reply-diff')) {
- const $replyBtn = $(this).closest('.comment-code-cloud').find('button.comment-form-reply');
- editor = await handleReply($replyBtn);
+ const replyBtn = this.closest('.comment-code-cloud').querySelector('button.comment-form-reply');
+ editor = await handleReply(replyBtn);
} else {
// for normal issue/comment page
editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor'));
diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js
index a754e2ae9a5e..57c4f19163b9 100644
--- a/web_src/js/features/repo-issue.js
+++ b/web_src/js/features/repo-issue.js
@@ -5,7 +5,6 @@ import {hideElem, showElem, toggleElem} from '../utils/dom.js';
import {setFileFolding} from './file-fold.js';
import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
import {toAbsoluteUrl} from '../utils.js';
-import {initDropzone} from './dropzone.js';
import {GET, POST} from '../modules/fetch.js';
import {showErrorToast} from '../modules/toast.js';
@@ -410,21 +409,13 @@ export function initRepoIssueComments() {
});
}
-export async function handleReply($el) {
- hideElem($el);
- const $form = $el.closest('.comment-code-cloud').find('.comment-form');
- showElem($form);
-
- const $textarea = $form.find('textarea');
- let editor = getComboMarkdownEditor($textarea);
- if (!editor) {
- // FIXME: the initialization of the dropzone is not consistent.
- // When the page is loaded, the dropzone is initialized by initGlobalDropzone, but the editor is not initialized.
- // When the form is submitted and partially reload, none of them is initialized.
- const dropzone = $form.find('.dropzone')[0];
- if (!dropzone.dropzone) initDropzone(dropzone);
- editor = await initComboMarkdownEditor($form.find('.combo-markdown-editor'));
- }
+export async function handleReply(el) {
+ const form = el.closest('.comment-code-cloud').querySelector('.comment-form');
+ const textarea = form.querySelector('textarea');
+
+ hideElem(el);
+ showElem(form);
+ const editor = getComboMarkdownEditor(textarea) ?? await initComboMarkdownEditor(form.querySelector('.combo-markdown-editor'));
editor.focus();
return editor;
}
@@ -486,7 +477,7 @@ export function initRepoPullRequestReview() {
$(document).on('click', 'button.comment-form-reply', async function (e) {
e.preventDefault();
- await handleReply($(this));
+ await handleReply(this);
});
const $reviewBox = $('.review-box-panel');
@@ -554,8 +545,6 @@ export function initRepoPullRequestReview() {
$td.find("input[name='line']").val(idx);
$td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed');
$td.find("input[name='path']").val(path);
-
- initDropzone($td.find('.dropzone')[0]);
const editor = await initComboMarkdownEditor($td.find('.combo-markdown-editor'));
editor.focus();
} catch (error) {
diff --git a/web_src/js/index.js b/web_src/js/index.js
index 35d69706355b..8aff052664b6 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -91,7 +91,6 @@ import {
initGlobalDeleteButton,
initGlobalShowModal,
} from './features/common-button.js';
-import {initGlobalDropzone} from './features/dropzone.js';
import {initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.js';
initGiteaFomantic();
@@ -135,7 +134,6 @@ onDomReady(() => {
initGlobalButtonClickOnEnter,
initGlobalButtons,
initGlobalCopyToClipboardListener,
- initGlobalDropzone,
initGlobalEnterQuickSubmit,
initGlobalFormDirtyLeaveConfirm,
initGlobalDeleteButton,
diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js
index 7289f19cbfef..57c7c8796acf 100644
--- a/web_src/js/utils/dom.js
+++ b/web_src/js/utils/dom.js
@@ -304,3 +304,17 @@ export function createElementFromHTML(htmlString) {
div.innerHTML = htmlString.trim();
return div.firstChild;
}
+
+export function createElementFromAttrs(tagName, attrs) {
+ const el = document.createElement(tagName);
+ for (const [key, value] of Object.entries(attrs)) {
+ if (value === undefined || value === null) continue;
+ if (value === true) {
+ el.toggleAttribute(key, value);
+ } else {
+ el.setAttribute(key, String(value));
+ }
+ // TODO: in the future we could make it also support "textContent" and "innerHTML" properties if needed
+ }
+ return el;
+}
diff --git a/web_src/js/utils/dom.test.js b/web_src/js/utils/dom.test.js
index fd7d97cad5e3..b9212ec284a9 100644
--- a/web_src/js/utils/dom.test.js
+++ b/web_src/js/utils/dom.test.js
@@ -1,5 +1,16 @@
-import {createElementFromHTML} from './dom.js';
+import {createElementFromAttrs, createElementFromHTML} from './dom.js';
test('createElementFromHTML', () => {
expect(createElementFromHTML('foobar').outerHTML).toEqual('foobar');
});
+
+test('createElementFromAttrs', () => {
+ const el = createElementFromAttrs('button', {
+ id: 'the-id',
+ class: 'cls-1 cls-2',
+ 'data-foo': 'the-data',
+ disabled: true,
+ required: null,
+ });
+ expect(el.outerHTML).toEqual('');
+});
From 00fc29aee1245ab5b00653f701d2264dd9a9ade7 Mon Sep 17 00:00:00 2001
From: wxiaoguang
Date: Thu, 27 Jun 2024 07:41:59 +0800
Subject: [PATCH 04/59] Refactor issue label selection (#31497)
Follow #26460
---
models/issues/issue_search.go | 26 +++++++++++++-------------
models/issues/label.go | 30 +++++++++++++-----------------
2 files changed, 26 insertions(+), 30 deletions(-)
diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go
index b238f831ae54..e9f116bfc6e8 100644
--- a/models/issues/issue_search.go
+++ b/models/issues/issue_search.go
@@ -118,29 +118,29 @@ func applyLabelsCondition(sess *xorm.Session, opts *IssuesOptions) {
if opts.LabelIDs[0] == 0 {
sess.Where("issue.id NOT IN (SELECT issue_id FROM issue_label)")
} else {
- // We sort and deduplicate the labels' ids
- IncludedLabelIDs := make(container.Set[int64])
- ExcludedLabelIDs := make(container.Set[int64])
+ // deduplicate the label IDs for inclusion and exclusion
+ includedLabelIDs := make(container.Set[int64])
+ excludedLabelIDs := make(container.Set[int64])
for _, labelID := range opts.LabelIDs {
if labelID > 0 {
- IncludedLabelIDs.Add(labelID)
+ includedLabelIDs.Add(labelID)
} else if labelID < 0 { // 0 is not supported here, so just ignore it
- ExcludedLabelIDs.Add(-labelID)
+ excludedLabelIDs.Add(-labelID)
}
}
// ... and use them in a subquery of the form :
// where (select count(*) from issue_label where issue_id=issue.id and label_id in (2, 4, 6)) = 3
// This equality is guaranteed thanks to unique index (issue_id,label_id) on table issue_label.
- if len(IncludedLabelIDs) > 0 {
- subquery := builder.Select("count(*)").From("issue_label").Where(builder.Expr("issue_id = issue.id")).
- And(builder.In("label_id", IncludedLabelIDs.Values()))
- sess.Where(builder.Eq{strconv.Itoa(len(IncludedLabelIDs)): subquery})
+ if len(includedLabelIDs) > 0 {
+ subQuery := builder.Select("count(*)").From("issue_label").Where(builder.Expr("issue_id = issue.id")).
+ And(builder.In("label_id", includedLabelIDs.Values()))
+ sess.Where(builder.Eq{strconv.Itoa(len(includedLabelIDs)): subQuery})
}
// or (select count(*)...) = 0 for excluded labels
- if len(ExcludedLabelIDs) > 0 {
- subquery := builder.Select("count(*)").From("issue_label").Where(builder.Expr("issue_id = issue.id")).
- And(builder.In("label_id", ExcludedLabelIDs.Values()))
- sess.Where(builder.Eq{"0": subquery})
+ if len(excludedLabelIDs) > 0 {
+ subQuery := builder.Select("count(*)").From("issue_label").Where(builder.Expr("issue_id = issue.id")).
+ And(builder.In("label_id", excludedLabelIDs.Values()))
+ sess.Where(builder.Eq{"0": subQuery})
}
}
}
diff --git a/models/issues/label.go b/models/issues/label.go
index 2d7acb7f0c53..2530f71004de 100644
--- a/models/issues/label.go
+++ b/models/issues/label.go
@@ -12,6 +12,7 @@ import (
"strings"
"code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/label"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/timeutil"
@@ -143,37 +144,32 @@ func (l *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64) {
// LoadSelectedLabelsAfterClick calculates the set of selected labels when a label is clicked
func (l *Label) LoadSelectedLabelsAfterClick(currentSelectedLabels []int64, currentSelectedExclusiveScopes []string) {
- labelQuerySlice := []int64{}
+ labelQueryParams := container.Set[string]{}
labelSelected := false
- labelScope := l.ExclusiveScope()
- for i, s := range currentSelectedLabels {
- if s == l.ID {
+ exclusiveScope := l.ExclusiveScope()
+ for i, curSel := range currentSelectedLabels {
+ if curSel == l.ID {
labelSelected = true
- } else if -s == l.ID {
+ } else if -curSel == l.ID {
labelSelected = true
l.IsExcluded = true
- } else if s != 0 {
+ } else if curSel != 0 {
// Exclude other labels in the same scope from selection
- if s < 0 || labelScope == "" || labelScope != currentSelectedExclusiveScopes[i] {
- labelQuerySlice = append(labelQuerySlice, s)
+ if curSel < 0 || exclusiveScope == "" || exclusiveScope != currentSelectedExclusiveScopes[i] {
+ labelQueryParams.Add(strconv.FormatInt(curSel, 10))
}
}
}
if !labelSelected {
- labelQuerySlice = append(labelQuerySlice, l.ID)
+ labelQueryParams.Add(strconv.FormatInt(l.ID, 10))
}
l.IsSelected = labelSelected
// Sort and deduplicate the ids to avoid the crawlers asking for the
// same thing with simply a different order of parameters
- slices.Sort(labelQuerySlice)
- labelQuerySlice = slices.Compact(labelQuerySlice)
- // Quick conversion (strings.Join() doesn't accept slices of Int64)
- labelQuerySliceStrings := make([]string, len(labelQuerySlice))
- for i, x := range labelQuerySlice {
- labelQuerySliceStrings[i] = strconv.FormatInt(x, 10)
- }
+ labelQuerySliceStrings := labelQueryParams.Values()
+ slices.Sort(labelQuerySliceStrings) // the sort is still needed because the underlying map of Set doesn't guarantee order
l.QueryString = strings.Join(labelQuerySliceStrings, ",")
}
@@ -187,7 +183,7 @@ func (l *Label) BelongsToRepo() bool {
return l.RepoID > 0
}
-// Return scope substring of label name, or empty string if none exists
+// ExclusiveScope returns scope substring of label name, or empty string if none exists
func (l *Label) ExclusiveScope() string {
if !l.Exclusive {
return ""
From 9bc5552c11f6aca08c8c873a0561882b3e099350 Mon Sep 17 00:00:00 2001
From: Tyrone Yeh
Date: Thu, 27 Jun 2024 17:31:49 +0800
Subject: [PATCH 05/59] Improve attachment upload methods (#30513)
* Use dropzone to handle file uploading for all cases, including pasting
and dragging
* Merge duplicate code, use consistent behavior for link generating
Close #20130
---------
Co-authored-by: silverwind
Co-authored-by: wxiaoguang
---
.../js/features/comp/ComboMarkdownEditor.js | 10 +-
web_src/js/features/comp/EditorMarkdown.js | 4 +-
.../comp/{Paste.js => EditorUpload.js} | 128 ++++++++++--------
web_src/js/features/comp/EditorUpload.test.js | 14 ++
web_src/js/features/dropzone.js | 62 ++++++---
web_src/js/utils.js | 14 +-
web_src/js/utils.test.js | 18 ++-
web_src/js/utils/dom.js | 12 --
web_src/js/utils/image.js | 7 +-
web_src/js/utils/image.test.js | 1 +
10 files changed, 171 insertions(+), 99 deletions(-)
rename web_src/js/features/comp/{Paste.js => EditorUpload.js} (52%)
create mode 100644 web_src/js/features/comp/EditorUpload.test.js
diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js
index bd11c8383c35..21779e32a8ef 100644
--- a/web_src/js/features/comp/ComboMarkdownEditor.js
+++ b/web_src/js/features/comp/ComboMarkdownEditor.js
@@ -3,7 +3,7 @@ import '@github/text-expander-element';
import $ from 'jquery';
import {attachTribute} from '../tribute.js';
import {hideElem, showElem, autosize, isElemVisible} from '../../utils/dom.js';
-import {initEasyMDEPaste, initTextareaPaste} from './Paste.js';
+import {initEasyMDEPaste, initTextareaUpload} from './EditorUpload.js';
import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js';
import {renderPreviewPanelContent} from '../repo-editor.js';
import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js';
@@ -11,7 +11,7 @@ import {initTextExpander} from './TextExpander.js';
import {showErrorToast} from '../../modules/toast.js';
import {POST} from '../../modules/fetch.js';
import {initTextareaMarkdown} from './EditorMarkdown.js';
-import {initDropzone} from '../dropzone.js';
+import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.js';
let elementIdCounter = 0;
@@ -111,7 +111,7 @@ class ComboMarkdownEditor {
initTextareaMarkdown(this.textarea);
if (this.dropzone) {
- initTextareaPaste(this.textarea, this.dropzone);
+ initTextareaUpload(this.textarea, this.dropzone);
}
}
@@ -130,13 +130,13 @@ class ComboMarkdownEditor {
dropzoneReloadFiles() {
if (!this.dropzone) return;
- this.attachedDropzoneInst.emit('reload');
+ this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles);
}
dropzoneSubmitReload() {
if (!this.dropzone) return;
this.attachedDropzoneInst.emit('submit');
- this.attachedDropzoneInst.emit('reload');
+ this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles);
}
setupTab() {
diff --git a/web_src/js/features/comp/EditorMarkdown.js b/web_src/js/features/comp/EditorMarkdown.js
index cf412e3807a3..9ec71aba7444 100644
--- a/web_src/js/features/comp/EditorMarkdown.js
+++ b/web_src/js/features/comp/EditorMarkdown.js
@@ -1,4 +1,6 @@
-import {triggerEditorContentChanged} from './Paste.js';
+export function triggerEditorContentChanged(target) {
+ target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true}));
+}
function handleIndentSelection(textarea, e) {
const selStart = textarea.selectionStart;
diff --git a/web_src/js/features/comp/Paste.js b/web_src/js/features/comp/EditorUpload.js
similarity index 52%
rename from web_src/js/features/comp/Paste.js
rename to web_src/js/features/comp/EditorUpload.js
index c72434c4cc65..8861abfe03a5 100644
--- a/web_src/js/features/comp/Paste.js
+++ b/web_src/js/features/comp/EditorUpload.js
@@ -1,19 +1,29 @@
-import {htmlEscape} from 'escape-goat';
-import {POST} from '../../modules/fetch.js';
import {imageInfo} from '../../utils/image.js';
-import {getPastedContent, replaceTextareaSelection} from '../../utils/dom.js';
+import {replaceTextareaSelection} from '../../utils/dom.js';
import {isUrl} from '../../utils/url.js';
-
-async function uploadFile(file, uploadUrl) {
- const formData = new FormData();
- formData.append('file', file, file.name);
-
- const res = await POST(uploadUrl, {data: formData});
- return await res.json();
-}
-
-export function triggerEditorContentChanged(target) {
- target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true}));
+import {triggerEditorContentChanged} from './EditorMarkdown.js';
+import {
+ DropzoneCustomEventRemovedFile,
+ DropzoneCustomEventUploadDone,
+ generateMarkdownLinkForAttachment,
+} from '../dropzone.js';
+
+let uploadIdCounter = 0;
+
+function uploadFile(dropzoneEl, file) {
+ return new Promise((resolve) => {
+ const curUploadId = uploadIdCounter++;
+ file._giteaUploadId = curUploadId;
+ const dropzoneInst = dropzoneEl.dropzone;
+ const onUploadDone = ({file}) => {
+ if (file._giteaUploadId === curUploadId) {
+ dropzoneInst.off(DropzoneCustomEventUploadDone, onUploadDone);
+ resolve();
+ }
+ };
+ dropzoneInst.on(DropzoneCustomEventUploadDone, onUploadDone);
+ dropzoneInst.handleFiles([file]);
+ });
}
class TextareaEditor {
@@ -82,48 +92,25 @@ class CodeMirrorEditor {
}
}
-async function handleClipboardImages(editor, dropzone, images, e) {
- const uploadUrl = dropzone.getAttribute('data-upload-url');
- const filesContainer = dropzone.querySelector('.files');
-
- if (!dropzone || !uploadUrl || !filesContainer || !images.length) return;
-
+async function handleUploadFiles(editor, dropzoneEl, files, e) {
e.preventDefault();
- e.stopPropagation();
-
- for (const img of images) {
- const name = img.name.slice(0, img.name.lastIndexOf('.'));
+ for (const file of files) {
+ const name = file.name.slice(0, file.name.lastIndexOf('.'));
+ const {width, dppx} = await imageInfo(file);
+ const placeholder = `[${name}](uploading ...)`;
- const placeholder = `![${name}](uploading ...)`;
editor.insertPlaceholder(placeholder);
-
- const {uuid} = await uploadFile(img, uploadUrl);
- const {width, dppx} = await imageInfo(img);
-
- let text;
- if (width > 0 && dppx > 1) {
- // Scale down images from HiDPI monitors. This uses the tag because it's the only
- // method to change image size in Markdown that is supported by all implementations.
- // Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}"
- const url = `attachments/${uuid}`;
- text = ``;
- } else {
- // Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}"
- // TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments"
- const url = `/attachments/${uuid}`;
- text = `![${name}](${url})`;
- }
- editor.replacePlaceholder(placeholder, text);
-
- const input = document.createElement('input');
- input.setAttribute('name', 'files');
- input.setAttribute('type', 'hidden');
- input.setAttribute('id', uuid);
- input.value = uuid;
- filesContainer.append(input);
+ await uploadFile(dropzoneEl, file); // the "file" will get its "uuid" during the upload
+ editor.replacePlaceholder(placeholder, generateMarkdownLinkForAttachment(file, {width, dppx}));
}
}
+export function removeAttachmentLinksFromMarkdown(text, fileUuid) {
+ text = text.replace(new RegExp(`!?\\[([^\\]]+)\\]\\(/?attachments/${fileUuid}\\)`, 'g'), '');
+ text = text.replace(new RegExp(`]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), '');
+ return text;
+}
+
function handleClipboardText(textarea, e, {text, isShiftDown}) {
// pasting with "shift" means "paste as original content" in most applications
if (isShiftDown) return; // let the browser handle it
@@ -139,16 +126,37 @@ function handleClipboardText(textarea, e, {text, isShiftDown}) {
// else, let the browser handle it
}
-export function initEasyMDEPaste(easyMDE, dropzone) {
+// extract text and images from "paste" event
+function getPastedContent(e) {
+ const images = [];
+ for (const item of e.clipboardData?.items ?? []) {
+ if (item.type?.startsWith('image/')) {
+ images.push(item.getAsFile());
+ }
+ }
+ const text = e.clipboardData?.getData?.('text') ?? '';
+ return {text, images};
+}
+
+export function initEasyMDEPaste(easyMDE, dropzoneEl) {
+ const editor = new CodeMirrorEditor(easyMDE.codemirror);
easyMDE.codemirror.on('paste', (_, e) => {
const {images} = getPastedContent(e);
- if (images.length) {
- handleClipboardImages(new CodeMirrorEditor(easyMDE.codemirror), dropzone, images, e);
- }
+ if (!images.length) return;
+ handleUploadFiles(editor, dropzoneEl, images, e);
+ });
+ easyMDE.codemirror.on('drop', (_, e) => {
+ if (!e.dataTransfer.files.length) return;
+ handleUploadFiles(editor, dropzoneEl, e.dataTransfer.files, e);
+ });
+ dropzoneEl.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}) => {
+ const oldText = easyMDE.codemirror.getValue();
+ const newText = removeAttachmentLinksFromMarkdown(oldText, fileUuid);
+ if (oldText !== newText) easyMDE.codemirror.setValue(newText);
});
}
-export function initTextareaPaste(textarea, dropzone) {
+export function initTextareaUpload(textarea, dropzoneEl) {
let isShiftDown = false;
textarea.addEventListener('keydown', (e) => {
if (e.shiftKey) isShiftDown = true;
@@ -159,9 +167,17 @@ export function initTextareaPaste(textarea, dropzone) {
textarea.addEventListener('paste', (e) => {
const {images, text} = getPastedContent(e);
if (images.length) {
- handleClipboardImages(new TextareaEditor(textarea), dropzone, images, e);
+ handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, images, e);
} else if (text) {
handleClipboardText(textarea, e, {text, isShiftDown});
}
});
+ textarea.addEventListener('drop', (e) => {
+ if (!e.dataTransfer.files.length) return;
+ handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, e.dataTransfer.files, e);
+ });
+ dropzoneEl.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}) => {
+ const newText = removeAttachmentLinksFromMarkdown(textarea.value, fileUuid);
+ if (textarea.value !== newText) textarea.value = newText;
+ });
}
diff --git a/web_src/js/features/comp/EditorUpload.test.js b/web_src/js/features/comp/EditorUpload.test.js
new file mode 100644
index 000000000000..caecfb91ea2d
--- /dev/null
+++ b/web_src/js/features/comp/EditorUpload.test.js
@@ -0,0 +1,14 @@
+import {removeAttachmentLinksFromMarkdown} from './EditorUpload.js';
+
+test('removeAttachmentLinksFromMarkdown', () => {
+ expect(removeAttachmentLinksFromMarkdown('a foo b', 'foo')).toBe('a foo b');
+ expect(removeAttachmentLinksFromMarkdown('a [x](attachments/foo) b', 'foo')).toBe('a b');
+ expect(removeAttachmentLinksFromMarkdown('a ![x](attachments/foo) b', 'foo')).toBe('a b');
+ expect(removeAttachmentLinksFromMarkdown('a [x](/attachments/foo) b', 'foo')).toBe('a b');
+ expect(removeAttachmentLinksFromMarkdown('a ![x](/attachments/foo) b', 'foo')).toBe('a b');
+
+ expect(removeAttachmentLinksFromMarkdown('a b', 'foo')).toBe('a b');
+ expect(removeAttachmentLinksFromMarkdown('a b', 'foo')).toBe('a b');
+ expect(removeAttachmentLinksFromMarkdown('a b', 'foo')).toBe('a b');
+ expect(removeAttachmentLinksFromMarkdown('a b', 'foo')).toBe('a b');
+});
diff --git a/web_src/js/features/dropzone.js b/web_src/js/features/dropzone.js
index 8d70fc774b19..f25a6137186f 100644
--- a/web_src/js/features/dropzone.js
+++ b/web_src/js/features/dropzone.js
@@ -5,9 +5,15 @@ import {showTemporaryTooltip} from '../modules/tippy.js';
import {GET, POST} from '../modules/fetch.js';
import {showErrorToast} from '../modules/toast.js';
import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.js';
+import {isImageFile, isVideoFile} from '../utils.js';
const {csrfToken, i18n} = window.config;
+// dropzone has its owner event dispatcher (emitter)
+export const DropzoneCustomEventReloadFiles = 'dropzone-custom-reload-files';
+export const DropzoneCustomEventRemovedFile = 'dropzone-custom-removed-file';
+export const DropzoneCustomEventUploadDone = 'dropzone-custom-upload-done';
+
async function createDropzone(el, opts) {
const [{Dropzone}] = await Promise.all([
import(/* webpackChunkName: "dropzone" */'dropzone'),
@@ -16,6 +22,26 @@ async function createDropzone(el, opts) {
return new Dropzone(el, opts);
}
+export function generateMarkdownLinkForAttachment(file, {width, dppx} = {}) {
+ let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`;
+ if (isImageFile(file)) {
+ fileMarkdown = `!${fileMarkdown}`;
+ if (width > 0 && dppx > 1) {
+ // Scale down images from HiDPI monitors. This uses the tag because it's the only
+ // method to change image size in Markdown that is supported by all implementations.
+ // Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}"
+ fileMarkdown = ``;
+ } else {
+ // Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}"
+ // TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments"
+ fileMarkdown = `![${file.name}](/attachments/${file.uuid})`;
+ }
+ } else if (isVideoFile(file)) {
+ fileMarkdown = ``;
+ }
+ return fileMarkdown;
+}
+
function addCopyLink(file) {
// Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard
// The "" element has a hardcoded cursor: pointer because the default is overridden by .dropzone
@@ -25,13 +51,7 @@ function addCopyLink(file) {
`);
copyLinkEl.addEventListener('click', async (e) => {
e.preventDefault();
- let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`;
- if (file.type?.startsWith('image/')) {
- fileMarkdown = `!${fileMarkdown}`;
- } else if (file.type?.startsWith('video/')) {
- fileMarkdown = ``;
- }
- const success = await clippie(fileMarkdown);
+ const success = await clippie(generateMarkdownLinkForAttachment(file));
showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error);
});
file.previewTemplate.append(copyLinkEl);
@@ -68,16 +88,19 @@ export async function initDropzone(dropzoneEl) {
// "http://localhost:3000/owner/repo/issues/[object%20Event]"
// the reason is that the preview "callback(dataURL)" is assign to "img.onerror" then "thumbnail" uses the error object as the dataURL and generates ''
const dzInst = await createDropzone(dropzoneEl, opts);
- dzInst.on('success', (file, data) => {
- file.uuid = data.uuid;
+ dzInst.on('success', (file, resp) => {
+ file.uuid = resp.uuid;
fileUuidDict[file.uuid] = {submitted: false};
- const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${data.uuid}`, value: data.uuid});
+ const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${resp.uuid}`, value: resp.uuid});
dropzoneEl.querySelector('.files').append(input);
addCopyLink(file);
+ dzInst.emit(DropzoneCustomEventUploadDone, {file});
});
dzInst.on('removedfile', async (file) => {
if (disableRemovedfileEvent) return;
+
+ dzInst.emit(DropzoneCustomEventRemovedFile, {fileUuid: file.uuid});
document.querySelector(`#dropzone-file-${file.uuid}`)?.remove();
// when the uploaded file number reaches the limit, there is no uuid in the dict, and it doesn't need to be removed from server
if (removeAttachmentUrl && fileUuidDict[file.uuid] && !fileUuidDict[file.uuid].submitted) {
@@ -91,7 +114,7 @@ export async function initDropzone(dropzoneEl) {
}
});
- dzInst.on('reload', async () => {
+ dzInst.on(DropzoneCustomEventReloadFiles, async () => {
try {
const resp = await GET(listAttachmentsUrl);
const respData = await resp.json();
@@ -104,13 +127,14 @@ export async function initDropzone(dropzoneEl) {
for (const el of dropzoneEl.querySelectorAll('.dz-preview')) el.remove();
fileUuidDict = {};
for (const attachment of respData) {
- const imgSrc = `${attachmentBaseLinkUrl}/${attachment.uuid}`;
- dzInst.emit('addedfile', attachment);
- dzInst.emit('thumbnail', attachment, imgSrc);
- dzInst.emit('complete', attachment);
- addCopyLink(attachment);
- fileUuidDict[attachment.uuid] = {submitted: true};
- const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${attachment.uuid}`, value: attachment.uuid});
+ const file = {name: attachment.name, uuid: attachment.uuid, size: attachment.size};
+ const imgSrc = `${attachmentBaseLinkUrl}/${file.uuid}`;
+ dzInst.emit('addedfile', file);
+ dzInst.emit('thumbnail', file, imgSrc);
+ dzInst.emit('complete', file);
+ addCopyLink(file); // it is from server response, so no "type"
+ fileUuidDict[file.uuid] = {submitted: true};
+ const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${file.uuid}`, value: file.uuid});
dropzoneEl.querySelector('.files').append(input);
}
if (!dropzoneEl.querySelector('.dz-preview')) {
@@ -129,6 +153,6 @@ export async function initDropzone(dropzoneEl) {
dzInst.removeFile(file);
});
- if (listAttachmentsUrl) dzInst.emit('reload');
+ if (listAttachmentsUrl) dzInst.emit(DropzoneCustomEventReloadFiles);
return dzInst;
}
diff --git a/web_src/js/utils.js b/web_src/js/utils.js
index ce0fb66343b1..2d40fa20a8c7 100644
--- a/web_src/js/utils.js
+++ b/web_src/js/utils.js
@@ -1,14 +1,16 @@
import {encode, decode} from 'uint8-to-base64';
// transform /path/to/file.ext to file.ext
-export function basename(path = '') {
+export function basename(path) {
const lastSlashIndex = path.lastIndexOf('/');
return lastSlashIndex < 0 ? path : path.substring(lastSlashIndex + 1);
}
// transform /path/to/file.ext to .ext
-export function extname(path = '') {
+export function extname(path) {
+ const lastSlashIndex = path.lastIndexOf('/');
const lastPointIndex = path.lastIndexOf('.');
+ if (lastSlashIndex > lastPointIndex) return '';
return lastPointIndex < 0 ? '' : path.substring(lastPointIndex);
}
@@ -142,3 +144,11 @@ export function serializeXml(node) {
}
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+export function isImageFile({name, type}) {
+ return /\.(jpe?g|png|gif|webp|svg|heic)$/i.test(name || '') || type?.startsWith('image/');
+}
+
+export function isVideoFile({name, type}) {
+ return /\.(mpe?g|mp4|mkv|webm)$/i.test(name || '') || type?.startsWith('video/');
+}
diff --git a/web_src/js/utils.test.js b/web_src/js/utils.test.js
index 2754e41c433e..1ec3d3630b5b 100644
--- a/web_src/js/utils.test.js
+++ b/web_src/js/utils.test.js
@@ -1,7 +1,7 @@
import {
basename, extname, isObject, stripTags, parseIssueHref,
parseUrl, translateMonth, translateDay, blobToDataURI,
- toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64,
+ toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile,
} from './utils.js';
test('basename', () => {
@@ -15,6 +15,7 @@ test('extname', () => {
expect(extname('/path/')).toEqual('');
expect(extname('/path')).toEqual('');
expect(extname('file.js')).toEqual('.js');
+ expect(extname('/my.path/file')).toEqual('');
});
test('isObject', () => {
@@ -112,3 +113,18 @@ test('encodeURLEncodedBase64, decodeURLEncodedBase64', () => {
expect(Array.from(decodeURLEncodedBase64('YQ'))).toEqual(Array.from(uint8array('a')));
expect(Array.from(decodeURLEncodedBase64('YQ=='))).toEqual(Array.from(uint8array('a')));
});
+
+test('file detection', () => {
+ for (const name of ['a.jpg', '/a.jpeg', '.file.png', '.webp', 'file.svg']) {
+ expect(isImageFile({name})).toBeTruthy();
+ }
+ for (const name of ['', 'a.jpg.x', '/path.png/x', 'webp']) {
+ expect(isImageFile({name})).toBeFalsy();
+ }
+ for (const name of ['a.mpg', '/a.mpeg', '.file.mp4', '.webm', 'file.mkv']) {
+ expect(isVideoFile({name})).toBeTruthy();
+ }
+ for (const name of ['', 'a.mpg.x', '/path.mp4/x', 'webm']) {
+ expect(isVideoFile({name})).toBeFalsy();
+ }
+});
diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js
index 57c7c8796acf..6a38968899f9 100644
--- a/web_src/js/utils/dom.js
+++ b/web_src/js/utils/dom.js
@@ -262,18 +262,6 @@ export function isElemVisible(element) {
return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
}
-// extract text and images from "paste" event
-export function getPastedContent(e) {
- const images = [];
- for (const item of e.clipboardData?.items ?? []) {
- if (item.type?.startsWith('image/')) {
- images.push(item.getAsFile());
- }
- }
- const text = e.clipboardData?.getData?.('text') ?? '';
- return {text, images};
-}
-
// replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this
export function replaceTextareaSelection(textarea, text) {
const before = textarea.value.slice(0, textarea.selectionStart ?? undefined);
diff --git a/web_src/js/utils/image.js b/web_src/js/utils/image.js
index ed5d98e35ad0..c71d71594121 100644
--- a/web_src/js/utils/image.js
+++ b/web_src/js/utils/image.js
@@ -19,11 +19,10 @@ export async function pngChunks(blob) {
return chunks;
}
-// decode a image and try to obtain width and dppx. If will never throw but instead
+// decode a image and try to obtain width and dppx. It will never throw but instead
// return default values.
export async function imageInfo(blob) {
- let width = 0; // 0 means no width could be determined
- let dppx = 1; // 1 dot per pixel for non-HiDPI screens
+ let width = 0, dppx = 1; // dppx: 1 dot per pixel for non-HiDPI screens
if (blob.type === 'image/png') { // only png is supported currently
try {
@@ -41,6 +40,8 @@ export async function imageInfo(blob) {
}
}
} catch {}
+ } else {
+ return {}; // no image info for non-image files
}
return {width, dppx};
diff --git a/web_src/js/utils/image.test.js b/web_src/js/utils/image.test.js
index ba4758250c7f..af56ed2331fb 100644
--- a/web_src/js/utils/image.test.js
+++ b/web_src/js/utils/image.test.js
@@ -26,4 +26,5 @@ test('imageInfo', async () => {
expect(await imageInfo(await dataUriToBlob(pngNoPhys))).toEqual({width: 1, dppx: 1});
expect(await imageInfo(await dataUriToBlob(pngPhys))).toEqual({width: 2, dppx: 2});
expect(await imageInfo(await dataUriToBlob(pngEmpty))).toEqual({width: 0, dppx: 1});
+ expect(await imageInfo(await dataUriToBlob(`data:image/gif;base64,`))).toEqual({});
});
From c1fe6fbcc36bd29549019274d39f273b3e37755d Mon Sep 17 00:00:00 2001
From: wxiaoguang
Date: Thu, 27 Jun 2024 21:58:38 +0800
Subject: [PATCH 06/59] Make toast support preventDuplicates (#31501)
make preventDuplicates default to true, users get a clear UI feedback
and know that "a new message appears".
Fixes: https://github.com/go-gitea/gitea/issues/26651
---------
Co-authored-by: silverwind
---
templates/devtest/gitea-ui.tmpl | 11 -----------
templates/devtest/toast.tmpl | 15 +++++++++++++++
web_src/css/modules/animations.css | 10 ++++++----
web_src/css/modules/toast.css | 26 ++++++++++++++++++++------
web_src/js/features/repo-diff.js | 8 ++------
web_src/js/modules/toast.js | 23 ++++++++++++++++++++---
web_src/js/standalone/devtest.js | 21 ++++++++++++---------
web_src/js/utils/dom.js | 11 +++++++++++
8 files changed, 86 insertions(+), 39 deletions(-)
create mode 100644 templates/devtest/toast.tmpl
diff --git a/templates/devtest/gitea-ui.tmpl b/templates/devtest/gitea-ui.tmpl
index ea293fd3b41b..06d0e36569a1 100644
--- a/templates/devtest/gitea-ui.tmpl
+++ b/templates/devtest/gitea-ui.tmpl
@@ -182,15 +182,6 @@
-
-
Toast
-
-
-
-
-
-
-
ComboMarkdownEditor
ps: no JS code attached, so just a layout
@@ -201,7 +192,5 @@
-
-
{{template "base/footer" .}}
diff --git a/templates/devtest/toast.tmpl b/templates/devtest/toast.tmpl
new file mode 100644
index 000000000000..412f23964a2b
--- /dev/null
+++ b/templates/devtest/toast.tmpl
@@ -0,0 +1,15 @@
+{{template "base/head" .}}
+
+
+
Toast
+
+
+
+
+
+
+
+
+
+
+{{template "base/footer" .}}
diff --git a/web_src/css/modules/animations.css b/web_src/css/modules/animations.css
index a86c9234aa33..481e997d4fcd 100644
--- a/web_src/css/modules/animations.css
+++ b/web_src/css/modules/animations.css
@@ -92,20 +92,22 @@ code.language-math.is-loading::after {
}
}
-@keyframes pulse {
+/* 1p5 means 1-point-5. it can't use "pulse" here, otherwise the animation is not right (maybe due to some conflicts */
+@keyframes pulse-1p5 {
0% {
transform: scale(1);
}
50% {
- transform: scale(1.8);
+ transform: scale(1.5);
}
100% {
transform: scale(1);
}
}
-.pulse {
- animation: pulse 2s linear;
+/* pulse animation for scale(1.5) in 200ms */
+.pulse-1p5-200 {
+ animation: pulse-1p5 200ms linear;
}
.ui.modal,
diff --git a/web_src/css/modules/toast.css b/web_src/css/modules/toast.css
index 2a9f78e01756..1145f3b1b58b 100644
--- a/web_src/css/modules/toast.css
+++ b/web_src/css/modules/toast.css
@@ -22,17 +22,31 @@
overflow-wrap: anywhere;
}
-.toast-close,
-.toast-icon {
- color: currentcolor;
+.toast-close {
border-radius: var(--border-radius);
- background: transparent;
- border: none;
- display: flex;
width: 30px;
height: 30px;
justify-content: center;
+}
+
+.toast-icon {
+ display: inline-flex;
+ width: 30px;
+ height: 30px;
align-items: center;
+ justify-content: center;
+}
+
+.toast-duplicate-number::before {
+ content: "(";
+}
+.toast-duplicate-number {
+ display: inline-block;
+ margin-right: 5px;
+ user-select: none;
+}
+.toast-duplicate-number::after {
+ content: ")";
}
.toast-close:hover {
diff --git a/web_src/js/features/repo-diff.js b/web_src/js/features/repo-diff.js
index cd01232a7e53..279f6da75729 100644
--- a/web_src/js/features/repo-diff.js
+++ b/web_src/js/features/repo-diff.js
@@ -7,7 +7,7 @@ import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.js';
import {initViewedCheckboxListenerFor, countAndUpdateViewedFiles, initExpandAndCollapseFilesButton} from './pull-view-file.js';
import {initImageDiff} from './imagediff.js';
import {showErrorToast} from '../modules/toast.js';
-import {submitEventSubmitter, queryElemSiblings, hideElem, showElem} from '../utils/dom.js';
+import {submitEventSubmitter, queryElemSiblings, hideElem, showElem, animateOnce} from '../utils/dom.js';
import {POST, GET} from '../modules/fetch.js';
const {pageData, i18n} = window.config;
@@ -26,11 +26,7 @@ function initRepoDiffReviewButton() {
const num = parseInt(counter.getAttribute('data-pending-comment-number')) + 1 || 1;
counter.setAttribute('data-pending-comment-number', num);
counter.textContent = num;
-
- reviewBox.classList.remove('pulse');
- requestAnimationFrame(() => {
- reviewBox.classList.add('pulse');
- });
+ animateOnce(reviewBox, 'pulse-1p5-200');
});
});
}
diff --git a/web_src/js/modules/toast.js b/web_src/js/modules/toast.js
index d12d203718ba..627e24a1ff95 100644
--- a/web_src/js/modules/toast.js
+++ b/web_src/js/modules/toast.js
@@ -1,5 +1,6 @@
import {htmlEscape} from 'escape-goat';
import {svg} from '../svg.js';
+import {animateOnce, showElem} from '../utils/dom.js';
import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown
const levels = {
@@ -21,13 +22,28 @@ const levels = {
};
// See https://github.com/apvarun/toastify-js#api for options
-function showToast(message, level, {gravity, position, duration, useHtmlBody, ...other} = {}) {
+function showToast(message, level, {gravity, position, duration, useHtmlBody, preventDuplicates = true, ...other} = {}) {
+ const body = useHtmlBody ? String(message) : htmlEscape(message);
+ const key = `${level}-${body}`;
+
+ // prevent showing duplicate toasts with same level and message, and give a visual feedback for end users
+ if (preventDuplicates) {
+ const toastEl = document.querySelector(`.toastify[data-toast-unique-key="${CSS.escape(key)}"]`);
+ if (toastEl) {
+ const toastDupNumEl = toastEl.querySelector('.toast-duplicate-number');
+ showElem(toastDupNumEl);
+ toastDupNumEl.textContent = String(Number(toastDupNumEl.textContent) + 1);
+ animateOnce(toastDupNumEl, 'pulse-1p5-200');
+ return;
+ }
+ }
+
const {icon, background, duration: levelDuration} = levels[level ?? 'info'];
const toast = Toastify({
text: `
${svg(icon)}
- ${useHtmlBody ? message : htmlEscape(message)}
-
+ 1${body}
+
`,
escapeMarkup: false,
gravity: gravity ?? 'top',
@@ -39,6 +55,7 @@ function showToast(message, level, {gravity, position, duration, useHtmlBody, ..
toast.showToast();
toast.toastElement.querySelector('.toast-close').addEventListener('click', () => toast.hideToast());
+ toast.toastElement.setAttribute('data-toast-unique-key', key);
return toast;
}
diff --git a/web_src/js/standalone/devtest.js b/web_src/js/standalone/devtest.js
index 8dbba554acb8..d3e3e13a8790 100644
--- a/web_src/js/standalone/devtest.js
+++ b/web_src/js/standalone/devtest.js
@@ -1,11 +1,14 @@
import {showInfoToast, showWarningToast, showErrorToast} from '../modules/toast.js';
-document.querySelector('#info-toast').addEventListener('click', () => {
- showInfoToast('success 😀');
-});
-document.querySelector('#warning-toast').addEventListener('click', () => {
- showWarningToast('warning 😐');
-});
-document.querySelector('#error-toast').addEventListener('click', () => {
- showErrorToast('error 🙁');
-});
+function initDevtestToast() {
+ const levelMap = {info: showInfoToast, warning: showWarningToast, error: showErrorToast};
+ for (const el of document.querySelectorAll('.toast-test-button')) {
+ el.addEventListener('click', () => {
+ const level = el.getAttribute('data-toast-level');
+ const message = el.getAttribute('data-toast-message');
+ levelMap[level](message);
+ });
+ }
+}
+
+initDevtestToast();
diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js
index 6a38968899f9..9bdb2332362a 100644
--- a/web_src/js/utils/dom.js
+++ b/web_src/js/utils/dom.js
@@ -306,3 +306,14 @@ export function createElementFromAttrs(tagName, attrs) {
}
return el;
}
+
+export function animateOnce(el, animationClassName) {
+ return new Promise((resolve) => {
+ el.addEventListener('animationend', function onAnimationEnd() {
+ el.classList.remove(animationClassName);
+ el.removeEventListener('animationend', onAnimationEnd);
+ resolve();
+ }, {once: true});
+ el.classList.add(animationClassName);
+ });
+}
From d655ff18b3183807dc568b133035c7c555e3b595 Mon Sep 17 00:00:00 2001
From: charles <30816317+charles7668@users.noreply.github.com>
Date: Thu, 27 Jun 2024 22:04:05 +0800
Subject: [PATCH 07/59] Fix avatar radius problem on the new issue page
(#31506)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Close #31502
Related to #31419.
In this PR, the avatar width is set to 3em, but the height is not set,
so the image is not squared.
When object-fit is set to contain, it can't maintain the radius of the
image.
Result:
![圖片](https://github.com/go-gitea/gitea/assets/30816317/bceb98aa-b0f7-4753-bc8b-3b9c41dfd55a)
---
web_src/css/repo.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/web_src/css/repo.css b/web_src/css/repo.css
index 28b84d3af421..f37ea3110a46 100644
--- a/web_src/css/repo.css
+++ b/web_src/css/repo.css
@@ -535,7 +535,7 @@ td .commit-summary {
min-width: 100px;
}
-#new-issue .avatar {
+#new-issue .comment .avatar {
width: 3em;
}
From 62b373896869da769992fbad06d13606c57c5585 Mon Sep 17 00:00:00 2001
From: silverwind
Date: Fri, 28 Jun 2024 07:59:22 +0200
Subject: [PATCH 08/59] Fix JS error with disabled attachment and easymde
(#31511)
Not sure if this is a regression from
https://github.com/go-gitea/gitea/pull/30513, but when attachments are
disabled, `this.dropzone` is null and the code had failed in
`initEasyMDEPaste` trying to access `dropzoneEl.dropzone`.
---
web_src/js/features/comp/ComboMarkdownEditor.js | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js
index 21779e32a8ef..f511adab1a1c 100644
--- a/web_src/js/features/comp/ComboMarkdownEditor.js
+++ b/web_src/js/features/comp/ComboMarkdownEditor.js
@@ -264,7 +264,9 @@ class ComboMarkdownEditor {
});
this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights);
await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true});
- initEasyMDEPaste(this.easyMDE, this.dropzone);
+ if (this.dropzone) {
+ initEasyMDEPaste(this.easyMDE, this.dropzone);
+ }
hideElem(this.textareaMarkdownToolbar);
}
From df805d6ed0458dbec258d115238fde794ed4d0ce Mon Sep 17 00:00:00 2001
From: Royce Remer
Date: Fri, 28 Jun 2024 01:42:57 -0700
Subject: [PATCH 09/59] Support legacy _links LFS batch responses (#31513)
Support legacy _links LFS batch response.
Fixes #31512.
This is backwards-compatible change to the LFS client so that, upon
mirroring from an upstream which has a batch api, it can download
objects whether the responses contain the `_links` field or its
successor the `actions` field. When Gitea must fallback to the legacy
`_links` field a logline is emitted at INFO level which looks like this:
```
...s/lfs/http_client.go:188:performOperation() [I] is using a deprecated batch schema response!
```
I've only run `test-backend` with this code, but added a new test to
cover this case. Additionally I have a fork with this change deployed
which I've confirmed syncs LFS from Gitea<-Artifactory (which has legacy
`_links`) as well as from Gitea<-Gitea (which has the modern `actions`).
Signed-off-by: Royce Remer
---
modules/lfs/http_client.go | 4 ++++
modules/lfs/http_client_test.go | 16 ++++++++++++++++
modules/lfs/shared.go | 1 +
3 files changed, 21 insertions(+)
diff --git a/modules/lfs/http_client.go b/modules/lfs/http_client.go
index f5ddd38b0911..7ee2449b0ec4 100644
--- a/modules/lfs/http_client.go
+++ b/modules/lfs/http_client.go
@@ -180,6 +180,10 @@ func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc
}
} else {
link, ok := object.Actions["download"]
+ if !ok {
+ // no actions block in response, try legacy response schema
+ link, ok = object.Links["download"]
+ }
if !ok {
log.Debug("%+v", object)
return errors.New("missing action 'download'")
diff --git a/modules/lfs/http_client_test.go b/modules/lfs/http_client_test.go
index 7431132f760f..ec90f5375d1b 100644
--- a/modules/lfs/http_client_test.go
+++ b/modules/lfs/http_client_test.go
@@ -59,6 +59,17 @@ func lfsTestRoundtripHandler(req *http.Request) *http.Response {
},
},
}
+ } else if strings.Contains(url, "legacy-batch-request-download") {
+ batchResponse = &BatchResponse{
+ Transfer: "dummy",
+ Objects: []*ObjectResponse{
+ {
+ Links: map[string]*Link{
+ "download": {},
+ },
+ },
+ },
+ }
} else if strings.Contains(url, "valid-batch-request-upload") {
batchResponse = &BatchResponse{
Transfer: "dummy",
@@ -229,6 +240,11 @@ func TestHTTPClientDownload(t *testing.T) {
endpoint: "https://unknown-actions-map.io",
expectederror: "missing action 'download'",
},
+ // case 11
+ {
+ endpoint: "https://legacy-batch-request-download.io",
+ expectederror: "",
+ },
}
for n, c := range cases {
diff --git a/modules/lfs/shared.go b/modules/lfs/shared.go
index 80f4fed00d40..675d2328b7d1 100644
--- a/modules/lfs/shared.go
+++ b/modules/lfs/shared.go
@@ -47,6 +47,7 @@ type BatchResponse struct {
type ObjectResponse struct {
Pointer
Actions map[string]*Link `json:"actions,omitempty"`
+ Links map[string]*Link `json:"_links,omitempty"`
Error *ObjectError `json:"error,omitempty"`
}
From 08579d6cbb65399dec408f3b90bc9a9e285e6206 Mon Sep 17 00:00:00 2001
From: silverwind
Date: Fri, 28 Jun 2024 18:15:51 +0200
Subject: [PATCH 10/59] Add initial typescript config and use it for
eslint,vitest,playwright (#31186)
This enables eslint to use the typescript parser and resolver which
brings some benefits that eslint rules now have type information
available and a tsconfig.json is required for the upcoming typescript
migration as well. Notable changes done:
- Add typescript parser and resolver
- Move the vue-specific config into the root file
- Enable `vue-scoped-css/enforce-style-type` rule, there was only one
violation and I added a inline disable there.
- Fix new lint errors that were detected because of the parser change
- Update `i/no-unresolved` to remove now-unnecessary workaround for the
resolver
- Disable `i/no-named-as-default` as it seems to raise bogus issues in
the webpack config
- Change vitest config to typescript
- Change playwright config to typescript
- Add `eslint-plugin-playwright` and fix issues
- Add `tsc` linting to `make lint-js`
---
.eslintrc.yaml | 31 +++-
Makefile | 6 +-
package-lock.json | 169 ++++++++++++++++--
package.json | 4 +
playwright.config.js => playwright.config.ts | 19 +-
tests/e2e/README.md | 2 +-
tests/e2e/e2e_test.go | 4 +-
...xample.test.e2e.js => example.test.e2e.ts} | 13 +-
tests/e2e/{utils_e2e.js => utils_e2e.ts} | 7 +-
tsconfig.json | 30 ++++
vitest.config.js => vitest.config.ts | 0
web_src/js/components/.eslintrc.yaml | 22 ---
web_src/js/components/RepoActionView.vue | 2 +-
web_src/js/features/repo-code.js | 4 +-
web_src/js/webcomponents/overflow-menu.js | 2 +-
15 files changed, 249 insertions(+), 66 deletions(-)
rename playwright.config.js => playwright.config.ts (81%)
rename tests/e2e/{example.test.e2e.js => example.test.e2e.ts} (86%)
rename tests/e2e/{utils_e2e.js => utils_e2e.ts} (88%)
create mode 100644 tsconfig.json
rename vitest.config.js => vitest.config.ts (100%)
delete mode 100644 web_src/js/components/.eslintrc.yaml
diff --git a/.eslintrc.yaml b/.eslintrc.yaml
index 4e3da6cab440..55a0f556fc71 100644
--- a/.eslintrc.yaml
+++ b/.eslintrc.yaml
@@ -6,9 +6,20 @@ ignorePatterns:
- /web_src/fomantic
- /public/assets/js
+parser: "@typescript-eslint/parser"
+
parserOptions:
sourceType: module
ecmaVersion: latest
+ project: true
+ extraFileExtensions: [".vue"]
+
+settings:
+ import/extensions: [".js", ".ts"]
+ import/parsers:
+ "@typescript-eslint/parser": [".js", ".ts"]
+ import/resolver:
+ typescript: true
plugins:
- "@eslint-community/eslint-plugin-eslint-comments"
@@ -103,6 +114,22 @@ overrides:
- files: ["web_src/js/modules/fetch.js", "web_src/js/standalone/**/*"]
rules:
no-restricted-syntax: [2, WithStatement, ForInStatement, LabeledStatement, SequenceExpression]
+ - files: ["**/*.vue"]
+ plugins:
+ - eslint-plugin-vue
+ - eslint-plugin-vue-scoped-css
+ extends:
+ - plugin:vue/vue3-recommended
+ - plugin:vue-scoped-css/vue3-recommended
+ rules:
+ vue/attributes-order: [0]
+ vue/html-closing-bracket-spacing: [2, {startTag: never, endTag: never, selfClosingTag: never}]
+ vue/max-attributes-per-line: [0]
+ vue/singleline-html-element-content-newline: [0]
+ - files: ["tests/e2e/**"]
+ plugins:
+ - eslint-plugin-playwright
+ extends: plugin:playwright/recommended
rules:
"@eslint-community/eslint-comments/disable-enable-pair": [2]
@@ -264,7 +291,7 @@ rules:
i/no-internal-modules: [0]
i/no-mutable-exports: [0]
i/no-named-as-default-member: [0]
- i/no-named-as-default: [2]
+ i/no-named-as-default: [0]
i/no-named-default: [0]
i/no-named-export: [0]
i/no-namespace: [0]
@@ -274,7 +301,7 @@ rules:
i/no-restricted-paths: [0]
i/no-self-import: [2]
i/no-unassigned-import: [0]
- i/no-unresolved: [2, {commonjs: true, ignore: ["\\?.+$", ^vitest/]}]
+ i/no-unresolved: [2, {commonjs: true, ignore: ["\\?.+$"]}]
i/no-unused-modules: [2, {unusedExports: true}]
i/no-useless-path-segments: [2, {commonjs: true}]
i/no-webpack-loader-syntax: [2]
diff --git a/Makefile b/Makefile
index 51577a48f0da..2a6b61348ab4 100644
--- a/Makefile
+++ b/Makefile
@@ -375,11 +375,13 @@ lint-backend-fix: lint-go-fix lint-go-vet lint-editorconfig
.PHONY: lint-js
lint-js: node_modules
- npx eslint --color --max-warnings=0 --ext js,vue $(ESLINT_FILES)
+ npx eslint --color --max-warnings=0 --ext js,ts,vue $(ESLINT_FILES)
+ npx tsc
.PHONY: lint-js-fix
lint-js-fix: node_modules
- npx eslint --color --max-warnings=0 --ext js,vue $(ESLINT_FILES) --fix
+ npx eslint --color --max-warnings=0 --ext js,ts,vue $(ESLINT_FILES) --fix
+ npx tsc
.PHONY: lint-css
lint-css: node_modules
diff --git a/package-lock.json b/package-lock.json
index 2f7a200ed274..3102d0223303 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -52,6 +52,7 @@
"tippy.js": "6.3.7",
"toastify-js": "1.12.0",
"tributejs": "5.1.3",
+ "typescript": "5.5.2",
"uint8-to-base64": "0.2.0",
"vanilla-colorful": "0.7.2",
"vue": "3.4.29",
@@ -68,13 +69,16 @@
"@stoplight/spectral-cli": "6.11.1",
"@stylistic/eslint-plugin-js": "2.2.1",
"@stylistic/stylelint-plugin": "2.1.2",
+ "@typescript-eslint/parser": "7.14.1",
"@vitejs/plugin-vue": "5.0.5",
"eslint": "8.57.0",
+ "eslint-import-resolver-typescript": "3.6.1",
"eslint-plugin-array-func": "4.0.0",
"eslint-plugin-github": "5.0.1",
"eslint-plugin-i": "2.29.1",
"eslint-plugin-no-jquery": "3.0.1",
"eslint-plugin-no-use-extend-native": "0.5.0",
+ "eslint-plugin-playwright": "1.6.2",
"eslint-plugin-regexp": "2.6.0",
"eslint-plugin-sonarjs": "1.0.3",
"eslint-plugin-unicorn": "54.0.0",
@@ -2399,15 +2403,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "7.13.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.13.1.tgz",
- "integrity": "sha512-1ELDPlnLvDQ5ybTSrMhRTFDfOQEOXNM+eP+3HT/Yq7ruWpciQw+Avi73pdEbA4SooCawEWo3dtYbF68gN7Ed1A==",
+ "version": "7.14.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.14.1.tgz",
+ "integrity": "sha512-8lKUOebNLcR0D7RvlcloOacTOWzOqemWEWkKSVpMZVF/XVcwjPR+3MD08QzbW9TCGJ+DwIc6zUSGZ9vd8cO1IA==",
"dev": true,
+ "license": "BSD-2-Clause",
"dependencies": {
- "@typescript-eslint/scope-manager": "7.13.1",
- "@typescript-eslint/types": "7.13.1",
- "@typescript-eslint/typescript-estree": "7.13.1",
- "@typescript-eslint/visitor-keys": "7.13.1",
+ "@typescript-eslint/scope-manager": "7.14.1",
+ "@typescript-eslint/types": "7.14.1",
+ "@typescript-eslint/typescript-estree": "7.14.1",
+ "@typescript-eslint/visitor-keys": "7.14.1",
"debug": "^4.3.4"
},
"engines": {
@@ -2426,6 +2431,98 @@
}
}
},
+ "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
+ "version": "7.14.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.14.1.tgz",
+ "integrity": "sha512-gPrFSsoYcsffYXTOZ+hT7fyJr95rdVe4kGVX1ps/dJ+DfmlnjFN/GcMxXcVkeHDKqsq6uAcVaQaIi3cFffmAbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "7.14.1",
+ "@typescript-eslint/visitor-keys": "7.14.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
+ "version": "7.14.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.14.1.tgz",
+ "integrity": "sha512-mL7zNEOQybo5R3AavY+Am7KLv8BorIv7HCYS5rKoNZKQD9tsfGUpO4KdAn3sSUvTiS4PQkr2+K0KJbxj8H9NDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
+ "version": "7.14.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.14.1.tgz",
+ "integrity": "sha512-k5d0VuxViE2ulIO6FbxxSZaxqDVUyMbXcidC8rHvii0I56XZPv8cq+EhMns+d/EVIL41sMXqRbK3D10Oza1bbA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@typescript-eslint/types": "7.14.1",
+ "@typescript-eslint/visitor-keys": "7.14.1",
+ "debug": "^4.3.4",
+ "globby": "^11.1.0",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
+ "version": "7.14.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.14.1.tgz",
+ "integrity": "sha512-Crb+F75U1JAEtBeQGxSKwI60hZmmzaqA3z9sYsVm8X7W5cwLEm5bRe0/uXS6+MR/y8CVpKSR/ontIAIEPFcEkA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "7.14.1",
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
"node_modules/@typescript-eslint/scope-manager": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.13.1.tgz",
@@ -5353,6 +5450,31 @@
"ms": "^2.1.1"
}
},
+ "node_modules/eslint-import-resolver-typescript": {
+ "version": "3.6.1",
+ "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.1.tgz",
+ "integrity": "sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.3.4",
+ "enhanced-resolve": "^5.12.0",
+ "eslint-module-utils": "^2.7.4",
+ "fast-glob": "^3.3.1",
+ "get-tsconfig": "^4.5.0",
+ "is-core-module": "^2.11.0",
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/unts/projects/eslint-import-resolver-ts"
+ },
+ "peerDependencies": {
+ "eslint": "*",
+ "eslint-plugin-import": "*"
+ }
+ },
"node_modules/eslint-module-utils": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.1.tgz",
@@ -5701,6 +5823,30 @@
"node": ">=6.0.0"
}
},
+ "node_modules/eslint-plugin-playwright": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-1.6.2.tgz",
+ "integrity": "sha512-mraN4Em3b5jLt01q7qWPyLg0Q5v3KAWfJSlEWwldyUXoa7DSPrBR4k6B6LROLqipsG8ndkwWMdjl1Ffdh15tag==",
+ "dev": true,
+ "workspaces": [
+ "examples"
+ ],
+ "dependencies": {
+ "globals": "^13.23.0"
+ },
+ "engines": {
+ "node": ">=16.6.0"
+ },
+ "peerDependencies": {
+ "eslint": ">=8.40.0",
+ "eslint-plugin-jest": ">=25"
+ },
+ "peerDependenciesMeta": {
+ "eslint-plugin-jest": {
+ "optional": true
+ }
+ }
+ },
"node_modules/eslint-plugin-prettier": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz",
@@ -11867,11 +12013,10 @@
}
},
"node_modules/typescript": {
- "version": "5.4.5",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
- "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
- "devOptional": true,
- "peer": true,
+ "version": "5.5.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.2.tgz",
+ "integrity": "sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==",
+ "license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
diff --git a/package.json b/package.json
index 2efebb8df8f1..ec1500fb8b0c 100644
--- a/package.json
+++ b/package.json
@@ -51,6 +51,7 @@
"tippy.js": "6.3.7",
"toastify-js": "1.12.0",
"tributejs": "5.1.3",
+ "typescript": "5.5.2",
"uint8-to-base64": "0.2.0",
"vanilla-colorful": "0.7.2",
"vue": "3.4.29",
@@ -67,13 +68,16 @@
"@stoplight/spectral-cli": "6.11.1",
"@stylistic/eslint-plugin-js": "2.2.1",
"@stylistic/stylelint-plugin": "2.1.2",
+ "@typescript-eslint/parser": "7.14.1",
"@vitejs/plugin-vue": "5.0.5",
"eslint": "8.57.0",
+ "eslint-import-resolver-typescript": "3.6.1",
"eslint-plugin-array-func": "4.0.0",
"eslint-plugin-github": "5.0.1",
"eslint-plugin-i": "2.29.1",
"eslint-plugin-no-jquery": "3.0.1",
"eslint-plugin-no-use-extend-native": "0.5.0",
+ "eslint-plugin-playwright": "1.6.2",
"eslint-plugin-regexp": "2.6.0",
"eslint-plugin-sonarjs": "1.0.3",
"eslint-plugin-unicorn": "54.0.0",
diff --git a/playwright.config.js b/playwright.config.ts
similarity index 81%
rename from playwright.config.js
rename to playwright.config.ts
index bdd303ae2534..d1cd299e2518 100644
--- a/playwright.config.js
+++ b/playwright.config.ts
@@ -1,15 +1,12 @@
-// @ts-check
import {devices} from '@playwright/test';
+import {env} from 'node:process';
+import type {PlaywrightTestConfig} from '@playwright/test';
-const BASE_URL = process.env.GITEA_URL?.replace?.(/\/$/g, '') || 'http://localhost:3000';
+const BASE_URL = env.GITEA_URL?.replace?.(/\/$/g, '') || 'http://localhost:3000';
-/**
- * @see https://playwright.dev/docs/test-configuration
- * @type {import('@playwright/test').PlaywrightTestConfig}
- */
export default {
testDir: './tests/e2e/',
- testMatch: /.*\.test\.e2e\.js/, // Match any .test.e2e.js files
+ testMatch: /.*\.test\.e2e\.ts/, // Match any .test.e2e.ts files
/* Maximum time one test can run for. */
timeout: 30 * 1000,
@@ -24,13 +21,13 @@ export default {
},
/* Fail the build on CI if you accidentally left test.only in the source code. */
- forbidOnly: Boolean(process.env.CI),
+ forbidOnly: Boolean(env.CI),
/* Retry on CI only */
- retries: process.env.CI ? 2 : 0,
+ retries: env.CI ? 2 : 0,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
- reporter: process.env.CI ? 'list' : [['list'], ['html', {outputFolder: 'tests/e2e/reports/', open: 'never'}]],
+ reporter: env.CI ? 'list' : [['list'], ['html', {outputFolder: 'tests/e2e/reports/', open: 'never'}]],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
@@ -98,4 +95,4 @@ export default {
outputDir: 'tests/e2e/test-artifacts/',
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
snapshotDir: 'tests/e2e/test-snapshots/',
-};
+} satisfies PlaywrightTestConfig;
diff --git a/tests/e2e/README.md b/tests/e2e/README.md
index e5fd1ca6c07f..db083793d8b5 100644
--- a/tests/e2e/README.md
+++ b/tests/e2e/README.md
@@ -65,7 +65,7 @@ TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=gitea_test TEST_MSSQL_USERNAME=
## Running individual tests
-Example command to run `example.test.e2e.js` test file:
+Example command to run `example.test.e2e.ts` test file:
_Note: unlike integration tests, this filtering is at the file level, not function_
diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go
index 60528a1a78ef..d6d27e66be18 100644
--- a/tests/e2e/e2e_test.go
+++ b/tests/e2e/e2e_test.go
@@ -73,10 +73,10 @@ func TestMain(m *testing.M) {
os.Exit(exitVal)
}
-// TestE2e should be the only test e2e necessary. It will collect all "*.test.e2e.js" files in this directory and build a test for each.
+// TestE2e should be the only test e2e necessary. It will collect all "*.test.e2e.ts" files in this directory and build a test for each.
func TestE2e(t *testing.T) {
// Find the paths of all e2e test files in test directory.
- searchGlob := filepath.Join(filepath.Dir(setting.AppPath), "tests", "e2e", "*.test.e2e.js")
+ searchGlob := filepath.Join(filepath.Dir(setting.AppPath), "tests", "e2e", "*.test.e2e.ts")
paths, err := filepath.Glob(searchGlob)
if err != nil {
t.Fatal(err)
diff --git a/tests/e2e/example.test.e2e.js b/tests/e2e/example.test.e2e.ts
similarity index 86%
rename from tests/e2e/example.test.e2e.js
rename to tests/e2e/example.test.e2e.ts
index 57c69a291787..32813b393446 100644
--- a/tests/e2e/example.test.e2e.js
+++ b/tests/e2e/example.test.e2e.ts
@@ -1,19 +1,18 @@
-// @ts-check
import {test, expect} from '@playwright/test';
-import {login_user, save_visual, load_logged_in_context} from './utils_e2e.js';
+import {login_user, save_visual, load_logged_in_context} from './utils_e2e.ts';
test.beforeAll(async ({browser}, workerInfo) => {
await login_user(browser, workerInfo, 'user2');
});
-test('Load Homepage', async ({page}) => {
+test('homepage', async ({page}) => {
const response = await page.goto('/');
await expect(response?.status()).toBe(200); // Status OK
await expect(page).toHaveTitle(/^Gitea: Git with a cup of tea\s*$/);
await expect(page.locator('.logo')).toHaveAttribute('src', '/assets/img/logo.svg');
});
-test('Test Register Form', async ({page}, workerInfo) => {
+test('register', async ({page}, workerInfo) => {
const response = await page.goto('/user/sign_up');
await expect(response?.status()).toBe(200); // Status OK
await page.type('input[name=user_name]', `e2e-test-${workerInfo.workerIndex}`);
@@ -29,7 +28,7 @@ test('Test Register Form', async ({page}, workerInfo) => {
save_visual(page);
});
-test('Test Login Form', async ({page}, workerInfo) => {
+test('login', async ({page}, workerInfo) => {
const response = await page.goto('/user/login');
await expect(response?.status()).toBe(200); // Status OK
@@ -37,14 +36,14 @@ test('Test Login Form', async ({page}, workerInfo) => {
await page.type('input[name=password]', `password`);
await page.click('form button.ui.primary.button:visible');
- await page.waitForLoadState('networkidle');
+ await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle
await expect(page.url()).toBe(`${workerInfo.project.use.baseURL}/`);
save_visual(page);
});
-test('Test Logged In User', async ({browser}, workerInfo) => {
+test('logged in user', async ({browser}, workerInfo) => {
const context = await load_logged_in_context(browser, workerInfo, 'user2');
const page = await context.newPage();
diff --git a/tests/e2e/utils_e2e.js b/tests/e2e/utils_e2e.ts
similarity index 88%
rename from tests/e2e/utils_e2e.js
rename to tests/e2e/utils_e2e.ts
index d60c78b16e04..5678c9c9d0ee 100644
--- a/tests/e2e/utils_e2e.js
+++ b/tests/e2e/utils_e2e.ts
@@ -1,4 +1,5 @@
import {expect} from '@playwright/test';
+import {env} from 'node:process';
const ARTIFACTS_PATH = `tests/e2e/test-artifacts`;
const LOGIN_PASSWORD = 'password';
@@ -20,7 +21,7 @@ export async function login_user(browser, workerInfo, user) {
await page.type('input[name=password]', LOGIN_PASSWORD);
await page.click('form button.ui.primary.button:visible');
- await page.waitForLoadState('networkidle');
+ await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle
await expect(page.url(), {message: `Failed to login user ${user}`}).toBe(`${workerInfo.project.use.baseURL}/`);
@@ -44,8 +45,8 @@ export async function load_logged_in_context(browser, workerInfo, user) {
export async function save_visual(page) {
// Optionally include visual testing
- if (process.env.VISUAL_TEST) {
- await page.waitForLoadState('networkidle');
+ if (env.VISUAL_TEST) {
+ await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle
// Mock page/version string
await page.locator('footer div.ui.left').evaluate((node) => node.innerHTML = 'MOCK');
await expect(page).toHaveScreenshot({
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 000000000000..7ddbada76548
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "include": [
+ "*",
+ "tests/e2e/**/*",
+ "tools/**/*",
+ "web_src/js/**/*",
+ ],
+ "compilerOptions": {
+ "target": "es2020",
+ "module": "node16",
+ "moduleResolution": "node16",
+ "lib": ["dom", "dom.iterable", "dom.asynciterable", "esnext"],
+ "allowImportingTsExtensions": true,
+ "allowJs": true,
+ "allowSyntheticDefaultImports": true,
+ "alwaysStrict": true,
+ "esModuleInterop": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "verbatimModuleSyntax": true,
+ "stripInternal": true,
+ "strict": false,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noPropertyAccessFromIndexSignature": false,
+ "exactOptionalPropertyTypes": false,
+ }
+}
diff --git a/vitest.config.js b/vitest.config.ts
similarity index 100%
rename from vitest.config.js
rename to vitest.config.ts
diff --git a/web_src/js/components/.eslintrc.yaml b/web_src/js/components/.eslintrc.yaml
deleted file mode 100644
index a79e96f330dd..000000000000
--- a/web_src/js/components/.eslintrc.yaml
+++ /dev/null
@@ -1,22 +0,0 @@
-plugins:
- - eslint-plugin-vue
- - eslint-plugin-vue-scoped-css
-
-extends:
- - ../../../.eslintrc.yaml
- - plugin:vue/vue3-recommended
- - plugin:vue-scoped-css/vue3-recommended
-
-parserOptions:
- sourceType: module
- ecmaVersion: latest
-
-env:
- browser: true
-
-rules:
- vue/attributes-order: [0]
- vue/html-closing-bracket-spacing: [2, {startTag: never, endTag: never, selfClosingTag: never}]
- vue/max-attributes-per-line: [0]
- vue/singleline-html-element-content-newline: [0]
- vue-scoped-css/enforce-style-type: [0]
diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue
index 7f6524c7e3dd..97dc0d950f01 100644
--- a/web_src/js/components/RepoActionView.vue
+++ b/web_src/js/components/RepoActionView.vue
@@ -797,7 +797,7 @@ export function initRepositoryActionView() {
}
-
-
-
- {{if .IsMention}}
-
- You are receiving this because @{{.Doer.Name}} mentioned you.
-
- {{end}}
-
-
- @{{.Doer.Name}}
- {{if not (eq .Doer.FullName "")}}
- ({{.Doer.FullName}})
- {{end}}
- {{if eq .ActionName "new"}}
- created
- {{else if eq .ActionName "close"}}
- closed
- {{else if eq .ActionName "reopen"}}
- reopened
- {{else}}
- updated
- {{end}}
- {{.Repo}}#{{.Issue.Index}}.
-
- {{if not (eq .Body "")}}
- Message content
-
- {{.Body}}
- {{end}}
-
-
-
- View it on Gitea.
-
-
-