diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js
index f40b0bdc17b..bd11c8383c3 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 b3acaf5e6f0..8d70fc774b1 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 aa9ca657b09..f25da911df1 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 5fafdcf17c1..8bc0c02bcb4 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 a754e2ae9a5..57c4f19163b 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 35d69706355..8aff052664b 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 7289f19cbfe..57c7c8796ac 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 fd7d97cad5e..b9212ec284a 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('');
+});