1
0
Fork 0
mirror of https://codeberg.org/forgejo/forgejo.git synced 2025-07-19 17:49:39 +02:00

fix(ui): multiple ComboMarkdownEditors on one page interfere (#8417)

When there are multiple combo-markdown-editors, then only the first will get changes from the toolbar buttons.
Fixes: #6742

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8417
Reviewed-by: Beowulf <beowulf@beocode.eu>
Co-authored-by: zokki <zokki.softwareschmiede@gmail.com>
Co-committed-by: zokki <zokki.softwareschmiede@gmail.com>
This commit is contained in:
zokki 2025-07-14 13:24:45 +02:00 committed by Beowulf
parent 81e59014da
commit 1937fcf476
3 changed files with 91 additions and 17 deletions

View file

@ -79,6 +79,22 @@ func DeclareGitRepos(t *testing.T) func() {
Filename: "a-file", Filename: "a-file",
Versions: []string{"{a}{а}"}, Versions: []string{"{a}{а}"},
}}, nil), }}, nil),
newRepo(t, 2, "multiple-combo-boxes", nil, []FileChanges{{
Filename: ".forgejo/issue_template/multi-combo-boxes.yaml",
Versions: []string{`
name: "Multiple combo-boxes"
description: "To show something"
body:
- type: textarea
id: textarea-one
attributes:
label: one
- type: textarea
id: textarea-two
attributes:
label: two
`},
}}, nil),
newRepo(t, 11, "dependency-test", &tests.DeclarativeRepoOptions{ newRepo(t, 11, "dependency-test", &tests.DeclarativeRepoOptions{
UnitConfig: optional.Some(map[unit_model.Type]convert.Conversion{ UnitConfig: optional.Some(map[unit_model.Type]convert.Conversion{
unit_model.TypeIssues: &repo_model.IssuesConfig{ unit_model.TypeIssues: &repo_model.IssuesConfig{

View file

@ -456,3 +456,62 @@ test('Combo Markdown: preview mode switch', async ({page}) => {
await expect(previewPanel).toBeHidden(); await expect(previewPanel).toBeHidden();
await save_visual(page); await save_visual(page);
}); });
test('Multiple combo markdown: insert table', async ({page}) => {
const response = await page.goto('/user2/multiple-combo-boxes/issues/new?template=.forgejo%2fissue_template%2fmulti-combo-boxes.yaml');
expect(response?.status()).toBe(200);
// check that there are two textareas
const textareaOne = page.locator('textarea[name=form-field-textarea-one]');
const comboboxOne = page.locator('textarea#_combo_markdown_editor_0');
await expect(textareaOne).toBeVisible();
await expect(comboboxOne).toBeHidden();
const textareaTwo = page.locator('textarea[name=form-field-textarea-two]');
const comboboxTwo = page.locator('textarea#_combo_markdown_editor_1');
await expect(textareaTwo).toBeVisible();
await expect(comboboxTwo).toBeHidden();
// focus first one and add table to it
await textareaOne.click();
await expect(comboboxOne).toBeVisible();
await expect(comboboxTwo).toBeHidden();
const newTableButtonOne = page.locator('[for="_combo_markdown_editor_0"] button[data-md-action="new-table"]');
await newTableButtonOne.click();
const newTableModalOne = page.locator('div[data-markdown-table-modal-id="0"]');
await expect(newTableModalOne).toBeVisible();
await newTableModalOne.locator('input[name="table-rows"]').fill('3');
await newTableModalOne.locator('input[name="table-columns"]').fill('2');
await newTableModalOne.locator('button[data-selector-name="ok-button"]').click();
await expect(newTableModalOne).toBeHidden();
await expect(comboboxOne).toHaveValue('| Header | Header |\n|---------|---------|\n| Content | Content |\n| Content | Content |\n| Content | Content |\n');
await expect(comboboxTwo).toBeEmpty();
await save_visual(page);
// focus second one and add table to it
await textareaTwo.click();
await expect(comboboxOne).toBeHidden();
await expect(comboboxTwo).toBeVisible();
const newTableButtonTwo = page.locator('[for="_combo_markdown_editor_1"] button[data-md-action="new-table"]');
await newTableButtonTwo.click();
const newTableModalTwo = page.locator('div[data-markdown-table-modal-id="1"]');
await expect(newTableModalTwo).toBeVisible();
await newTableModalTwo.locator('input[name="table-rows"]').fill('2');
await newTableModalTwo.locator('input[name="table-columns"]').fill('3');
await newTableModalTwo.locator('button[data-selector-name="ok-button"]').click();
await expect(newTableModalTwo).toBeHidden();
await expect(comboboxOne).toHaveValue('| Header | Header |\n|---------|---------|\n| Content | Content |\n| Content | Content |\n| Content | Content |\n');
await expect(comboboxTwo).toHaveValue('| Header | Header | Header |\n|---------|---------|---------|\n| Content | Content | Content |\n| Content | Content | Content |\n');
await save_visual(page);
});

View file

@ -11,8 +11,6 @@ import {initTextExpander} from './TextExpander.js';
import {showErrorToast, showHintToast} from '../../modules/toast.js'; import {showErrorToast, showHintToast} from '../../modules/toast.js';
import {POST} from '../../modules/fetch.js'; import {POST} from '../../modules/fetch.js';
let elementIdCounter = 0;
/** /**
* validate if the given textarea is non-empty. * validate if the given textarea is non-empty.
* @param {HTMLElement} textarea - The textarea element to be validated. * @param {HTMLElement} textarea - The textarea element to be validated.
@ -39,10 +37,13 @@ export function validateTextareaNonEmpty(textarea) {
const listPrefixRegex = /^\s*((\d+)[.)]\s|[-*+]\s{1,4}\[[ x]\]\s?|[-*+]\s|(>\s?)+)?/; const listPrefixRegex = /^\s*((\d+)[.)]\s|[-*+]\s{1,4}\[[ x]\]\s?|[-*+]\s|(>\s?)+)?/;
class ComboMarkdownEditor { class ComboMarkdownEditor {
static idSuffixCounter = 0;
constructor(container, options = {}) { constructor(container, options = {}) {
container._giteaComboMarkdownEditor = this; container._giteaComboMarkdownEditor = this;
this.options = options; this.options = options;
this.container = container; this.container = container;
this.elementIdSuffix = ComboMarkdownEditor.idSuffixCounter++;
} }
async init() { async init() {
@ -55,8 +56,6 @@ class ComboMarkdownEditor {
this.setupLinkInserter(); this.setupLinkInserter();
await this.switchToUserPreference(); await this.switchToUserPreference();
elementIdCounter++;
} }
applyEditorHeights(el, heights) { applyEditorHeights(el, heights) {
@ -74,7 +73,7 @@ class ComboMarkdownEditor {
setupTextarea() { setupTextarea() {
this.textarea = this.container.querySelector('.markdown-text-editor'); this.textarea = this.container.querySelector('.markdown-text-editor');
this.textarea._giteaComboMarkdownEditor = this; this.textarea._giteaComboMarkdownEditor = this;
this.textarea.id = `_combo_markdown_editor_${elementIdCounter}`; this.textarea.id = `_combo_markdown_editor_${this.elementIdSuffix}`;
this.textarea.addEventListener('input', (e) => this.options?.onContentChanged?.(this, e)); this.textarea.addEventListener('input', (e) => this.options?.onContentChanged?.(this, e));
this.applyEditorHeights(this.textarea, this.options.editorHeights); this.applyEditorHeights(this.textarea, this.options.editorHeights);
@ -96,8 +95,8 @@ class ComboMarkdownEditor {
this.textareaMarkdownToolbar.querySelector('button[data-md-action="unindent"]')?.addEventListener('click', () => { this.textareaMarkdownToolbar.querySelector('button[data-md-action="unindent"]')?.addEventListener('click', () => {
this.indentSelection(true, false); this.indentSelection(true, false);
}); });
this.textareaMarkdownToolbar.querySelector('button[data-md-action="new-table"]')?.setAttribute('data-modal', `div[data-markdown-table-modal-id="${elementIdCounter}"]`); this.textareaMarkdownToolbar.querySelector('button[data-md-action="new-table"]')?.setAttribute('data-modal', `div[data-markdown-table-modal-id="${this.elementIdSuffix}"]`);
this.textareaMarkdownToolbar.querySelector('button[data-md-action="new-link"]')?.setAttribute('data-modal', `div[data-markdown-link-modal-id="${elementIdCounter}"]`); this.textareaMarkdownToolbar.querySelector('button[data-md-action="new-link"]')?.setAttribute('data-modal', `div[data-markdown-link-modal-id="${this.elementIdSuffix}"]`);
// Track whether any actual input or pointer action was made after focusing, and only intercept Tab presses after that. // Track whether any actual input or pointer action was made after focusing, and only intercept Tab presses after that.
this.tabEnabled = false; this.tabEnabled = false;
@ -195,7 +194,7 @@ class ComboMarkdownEditor {
setupDropzone() { setupDropzone() {
const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container'); const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container');
if (dropzoneParentContainer) { if (dropzoneParentContainer) {
this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container'))?.querySelector('.dropzone'); this.dropzone = this.container.closest(dropzoneParentContainer)?.querySelector('.dropzone');
} }
} }
@ -207,13 +206,13 @@ class ComboMarkdownEditor {
// So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic. // So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic.
const tabEditor = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-writer'); const tabEditor = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-writer');
const tabPreviewer = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-previewer'); const tabPreviewer = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-previewer');
tabEditor.setAttribute('data-tab', `markdown-writer-${elementIdCounter}`); tabEditor.setAttribute('data-tab', `markdown-writer-${this.elementIdSuffix}`);
tabPreviewer.setAttribute('data-tab', `markdown-previewer-${elementIdCounter}`); tabPreviewer.setAttribute('data-tab', `markdown-previewer-${this.elementIdSuffix}`);
const toolbar = $container[0].querySelector('markdown-toolbar'); const toolbar = $container[0].querySelector('markdown-toolbar');
const panelEditor = $container[0].querySelector('.ui.tab[data-tab-panel="markdown-writer"]'); const panelEditor = $container[0].querySelector('.ui.tab[data-tab-panel="markdown-writer"]');
const panelPreviewer = $container[0].querySelector('.ui.tab[data-tab-panel="markdown-previewer"]'); const panelPreviewer = $container[0].querySelector('.ui.tab[data-tab-panel="markdown-previewer"]');
panelEditor.setAttribute('data-tab', `markdown-writer-${elementIdCounter}`); panelEditor.setAttribute('data-tab', `markdown-writer-${this.elementIdSuffix}`);
panelPreviewer.setAttribute('data-tab', `markdown-previewer-${elementIdCounter}`); panelPreviewer.setAttribute('data-tab', `markdown-previewer-${this.elementIdSuffix}`);
tabEditor.addEventListener('click', () => { tabEditor.addEventListener('click', () => {
toolbar.classList.remove('markdown-toolbar-hidden'); toolbar.classList.remove('markdown-toolbar-hidden');
@ -276,10 +275,10 @@ class ComboMarkdownEditor {
setupTableInserter() { setupTableInserter() {
const newTableModal = this.container.querySelector('div[data-modal-name="new-markdown-table"]'); const newTableModal = this.container.querySelector('div[data-modal-name="new-markdown-table"]');
newTableModal.setAttribute('data-markdown-table-modal-id', elementIdCounter); newTableModal.setAttribute('data-markdown-table-modal-id', this.elementIdSuffix);
const button = newTableModal.querySelector('button[data-selector-name="ok-button"]'); const button = newTableModal.querySelector('button[data-selector-name="ok-button"]');
button.setAttribute('data-element-id', elementIdCounter); button.setAttribute('data-element-id', this.elementIdSuffix);
button.addEventListener('click', this.addNewTable); button.addEventListener('click', this.addNewTable);
} }
@ -311,8 +310,8 @@ class ComboMarkdownEditor {
setupLinkInserter() { setupLinkInserter() {
const newLinkModal = this.container.querySelector('div[data-modal-name="new-markdown-link"]'); const newLinkModal = this.container.querySelector('div[data-modal-name="new-markdown-link"]');
newLinkModal.setAttribute('data-markdown-link-modal-id', elementIdCounter); newLinkModal.setAttribute('data-markdown-link-modal-id', this.elementIdSuffix);
const textarea = document.getElementById(`_combo_markdown_editor_${elementIdCounter}`); const textarea = document.getElementById(`_combo_markdown_editor_${this.elementIdSuffix}`);
$(newLinkModal).modal({ $(newLinkModal).modal({
// Pre-fill the description field from the selection to create behavior similar // Pre-fill the description field from the selection to create behavior similar
@ -331,7 +330,7 @@ class ComboMarkdownEditor {
}); });
const button = newLinkModal.querySelector('button[data-selector-name="ok-button"]'); const button = newLinkModal.querySelector('button[data-selector-name="ok-button"]');
button.setAttribute('data-element-id', elementIdCounter); button.setAttribute('data-element-id', this.elementIdSuffix);
button.addEventListener('click', this.addNewLink); button.addEventListener('click', this.addNewLink);
} }