feat(KAN-68): add visual Markdown ticket description editor
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m12s
CI/CD / test_and_check_app (pull_request) Failing after 1m59s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m12s
CI/CD / test_and_check_app (pull_request) Failing after 1m59s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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 });
|
||||
Reference in New Issue
Block a user