import { createApp, h, nextTick, reactive, ref } from 'vue'; import TicketDescriptionEditor from '../../resources/js/components/tickets/TicketDescriptionEditor.vue'; import TicketDescription from '../../resources/js/components/tickets/TicketDescription.vue'; import { createLocalization } from '../../resources/js/lib/localization'; import '../../resources/css/app.css'; const original = 'First line\nSecond line\n\n**Existing bold**'; const state = reactive({ value: original, disabled: false, error: '', result: 'Running browser checks…', updates: 0, }); const component = ref(null); const app = createApp({ render: () => h('main', { class: 'mx-auto max-w-3xl space-y-6 p-6' }, [ h( 'h1', { class: 'text-xl font-semibold' }, 'Ticket description editor', ), h('p', { id: 'test-result', role: 'status' }, state.result), h(TicketDescriptionEditor, { ref: component, id: 'description', label: 'Description', modelValue: state.value, disabled: state.disabled, error: state.error, 'onUpdate:modelValue': (value) => { state.value = value; state.updates++; }, }), h('h2', { class: 'font-semibold' }, 'Saved description preview'), h(TicketDescription, { description: state.value }), ]), }).use(createLocalization()); app.mount('#app'); const tick = async () => { await nextTick(); await new Promise((resolve) => requestAnimationFrame(resolve)); }; const assert = (condition, message) => { if (!condition) { throw new Error(message); } }; const button = (name) => [...document.querySelectorAll('button')].find( (element) => element.getAttribute('aria-label') === name || element.textContent.trim() === name, ); const click = async (name) => { const element = button(name); assert(element && !element.disabled, `Button available: ${name}`); element.click(); await tick(); }; try { await tick(); const editor = component.value.$.setupState.editor; assert(editor, 'Editor mounted'); assert( state.value === original && state.updates === 0, 'Mount preserves exact source', ); assert( document .querySelector('#description') .getAttribute('contenteditable') === 'true', 'Visual textbox is editable', ); assert( editor.getText().includes('First line\nSecond line'), 'Legacy newlines retained', ); state.value = 'Format this'; await tick(); editor.commands.selectAll(); for (const name of ['Bold', 'Italic', 'Strikethrough']) { await click(name); assert( button(name).getAttribute('aria-pressed') === 'true', `${name} active`, ); } assert( state.value.includes('**') && state.value.includes('~~'), 'Formatting emits Markdown', ); await click('Undo'); assert(!editor.isActive('strike'), 'Undo restores prior state'); await click('Redo'); assert(editor.isActive('strike'), 'Redo restores formatting'); await click('Markdown'); const source = document.querySelector('textarea#description'); assert(source.value === state.value, 'Source matches the editor output'); source.value = '## From source\n\n- One\n- Two'; source.dispatchEvent(new Event('input', { bubbles: true })); await tick(); await click('Visual editor'); assert( document.querySelector('#description h2')?.textContent === 'From source', 'Source updates visual editor', ); assert( document.querySelectorAll('#description li').length === 2, 'Lists render after source switch', ); for (const [name, selector] of [ ['Bullet list', 'ul li'], ['Numbered list', 'ol li'], ['Quote', 'blockquote'], ['Inline code', 'code'], ['Code block', 'pre code'], ['Heading 2', 'h2'], ['Heading 3', 'h3'], ]) { state.value = `Check ${name}`; await tick(); editor.commands.selectAll(); await click(name); assert( document.querySelector(`#description ${selector}`), `${name} renders`, ); } state.value = 'Link text'; await tick(); editor.commands.selectAll(); await click('Add or edit link'); assert( document.activeElement.id === 'description-link', 'Link field receives focus', ); const input = document.querySelector('#description-link'); input.value = 'javascript:alert(1)'; input.dispatchEvent(new Event('input', { bubbles: true })); await tick(); await click('Apply link'); assert( document .querySelector('#description-link-error') .textContent.includes('Enter a valid'), 'Unsafe link rejected', ); input.value = 'https://example.test/docs'; input.dispatchEvent(new Event('input', { bubbles: true })); await tick(); await click('Apply link'); assert( state.value === '[Link text](https://example.test/docs)', 'Selection becomes a Markdown link', ); await click('Remove link'); assert(state.value === 'Link text', 'Link can be removed'); state.disabled = true; state.error = 'Description rejected.'; await tick(); assert( document .querySelector('#description') .getAttribute('contenteditable') === 'false', 'Processing disables editing', ); assert( document.querySelector('#description').getAttribute('aria-invalid') === 'true', 'Server error reflected on editor', ); assert( button('Bold').disabled && button('Markdown').disabled, 'Processing disables controls', ); assert(state.value === 'Link text', 'Validation retains draft'); state.disabled = false; state.error = ''; state.value = 'Reset draft'; await tick(); assert(editor.getText() === 'Reset draft', 'External reset syncs editor'); editor.commands.selectAll(); await click('Heading 1'); assert(state.value.startsWith('# '), 'Heading command saves Markdown'); editor.commands.clearContent(); await tick(); assert(state.value === '', 'Clearing editor saves an empty description'); state.value = '| A | B |\n| - | - |\n| a | b |'; await tick(); assert( document.querySelector('textarea#description').value === state.value, 'Unsupported content retained as source', ); assert(button('Visual editor').disabled, 'Lossy conversion is unavailable'); state.value = '# Plan\n\nUse **bold** and *italic* formatting, lists and [links](https://example.test).\n\n- Create ticket\n- Save description\n\n> Keep existing descriptions readable.'; await tick(); state.result = 'Browser checks passed: formatting, undo/redo, source round-trip, links, errors, disabled state, reset, clearing and legacy content.'; } catch (error) { state.result = `FAILED: ${error.message}`; console.error(error); } window.addEventListener('pagehide', () => app.unmount(), { once: true });