feat(KAN-68): add visual Markdown ticket description editor
CI/CD / preconditions (pull_request) Successful in 1s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m11s
CI/CD / test_and_check_app (pull_request) Successful in 5m58s
CI/CD / Publish immutable image (pull_request) Skipped
CI/CD / preconditions (pull_request) Successful in 1s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m11s
CI/CD / test_and_check_app (pull_request) Successful in 5m58s
CI/CD / Publish immutable image (pull_request) Skipped
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
||||
use App\Models\Project;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TicketDescriptionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_description_formatting_and_safe_rendering(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/ticket-description.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Ticket description checks passed.', $process->getOutput());
|
||||
}
|
||||
|
||||
public function test_markdown_descriptions_can_be_created_read_updated_and_cleared(): void
|
||||
{
|
||||
$this->withoutVite();
|
||||
$user = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
$this->syncProjectPermissions($project, $user, ['view', 'write']);
|
||||
$description = "## Plan\n\n**Bold** and *italic*\n\n- First\n- [Second](https://example.test)\n\n```php\necho '<tag>';\n```";
|
||||
$payload = [
|
||||
'title' => 'Formatted description',
|
||||
'description' => $description,
|
||||
'ticket_type_id' => $project->ticketTypes()->where('is_parent', false)->firstOrFail()->id,
|
||||
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||
'ticket_status_id' => $project->ticketStatuses()->whereNull('board_column_id')->firstOrFail()->id,
|
||||
];
|
||||
|
||||
$this->actingAs($user)->post(route('projects.tickets.store', $project), $payload)
|
||||
->assertSessionHasNoErrors()->assertRedirect();
|
||||
$ticket = $project->tickets()->sole();
|
||||
$this->assertSame($description, $ticket->description);
|
||||
$this->get(route('projects.tickets.show', [$project, $ticket]))
|
||||
->assertInertia(fn (Assert $page) => $page->where('ticket.description', $description));
|
||||
|
||||
$updated = "> Updated quote\n\n1. One\n2. Two\n\n`inline`";
|
||||
$this->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||
...$payload, 'description' => $updated, 'is_blocked' => false,
|
||||
])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$this->assertSame($updated, $ticket->fresh()->description);
|
||||
|
||||
$this->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||
...$payload, 'description' => ['invalid'], 'is_blocked' => false,
|
||||
])->assertSessionHasErrors('description');
|
||||
$this->assertSame($updated, $ticket->fresh()->description);
|
||||
|
||||
$this->patch(route('projects.tickets.update', [$project, $ticket]), [
|
||||
...$payload, 'description' => '', 'is_blocked' => false,
|
||||
])->assertSessionHasNoErrors()->assertRedirect();
|
||||
$this->assertNull($ticket->fresh()->description);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -0,0 +1,184 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import tailwind from '@tailwindcss/vite';
|
||||
import { createServer } from 'vite';
|
||||
import { createSSRApp, h } from 'vue';
|
||||
import { renderToString } from 'vue/server-renderer';
|
||||
|
||||
const serve = process.argv.includes('--serve');
|
||||
const server = await createServer({
|
||||
configFile: false,
|
||||
plugins: [vue(), ...(serve ? [tailwind()] : [])],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: serve
|
||||
? { host: '127.0.0.1', port: 4186, strictPort: true, hmr: false }
|
||||
: { middlewareMode: true, hmr: false, watch: null },
|
||||
appType: 'custom',
|
||||
});
|
||||
if (serve) {
|
||||
server.middlewares.use(async (request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end(
|
||||
await server.transformIndexHtml(
|
||||
request.url,
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app"></div><script type="module" src="/tests/Fixtures/ticket-description-browser.mjs"></script></body></html>',
|
||||
),
|
||||
);
|
||||
});
|
||||
await server.listen();
|
||||
console.log('Description fixture ready on port 4186');
|
||||
} else {
|
||||
try {
|
||||
const {
|
||||
safeDescriptionLink,
|
||||
supportsVisualDescription,
|
||||
parseDescription,
|
||||
renderDescriptionNode,
|
||||
} = await server.ssrLoadModule(
|
||||
'/resources/js/lib/ticket-description.ts',
|
||||
);
|
||||
const { default: Description } = await server.ssrLoadModule(
|
||||
'/resources/js/components/tickets/TicketDescription.vue',
|
||||
);
|
||||
const { default: Editor } = await server.ssrLoadModule(
|
||||
'/resources/js/components/tickets/TicketDescriptionEditor.vue',
|
||||
);
|
||||
const { createLocalization } = await server.ssrLoadModule(
|
||||
'/resources/js/lib/localization.ts',
|
||||
);
|
||||
const render = (description) =>
|
||||
renderToString(
|
||||
createSSRApp({ render: () => h(Description, { description }) }),
|
||||
);
|
||||
const markdown =
|
||||
'# Title\n\n**Bold** and *italic* and ~~strike~~\n\n- One\n - Nested\n\n3. Third\n4. Fourth\n\n> Quote\n\n`inline`\n\n```js\n<tag>\n```\n\n[Link](https://example.test/path)\n\n---';
|
||||
assert.equal(supportsVisualDescription(markdown), true);
|
||||
const rendered = await render(markdown);
|
||||
for (const snippet of [
|
||||
'<h1>Title</h1>',
|
||||
'<strong>Bold</strong>',
|
||||
'<em>italic</em>',
|
||||
'<s>strike</s>',
|
||||
'<ul>',
|
||||
'Nested',
|
||||
'<ol start="3">',
|
||||
'<blockquote>',
|
||||
'<code>inline</code>',
|
||||
'<tag>',
|
||||
'rel="noopener noreferrer"',
|
||||
'<hr>',
|
||||
]) {
|
||||
assert.ok(
|
||||
rendered.includes(snippet),
|
||||
`Missing formatted output: ${snippet}`,
|
||||
);
|
||||
}
|
||||
assert.match(await render('First\nSecond'), /First<br>Second/);
|
||||
assert.match(
|
||||
await render('München 🚀 & details'),
|
||||
/München 🚀 & details/,
|
||||
);
|
||||
assert.match(
|
||||
await render('<img src=x onerror=alert(1)>'),
|
||||
/<img/,
|
||||
);
|
||||
for (const value of [
|
||||
'https://example.test/a?b=1&c=2',
|
||||
'http://example.test',
|
||||
'mailto:test@example.test',
|
||||
'/projects/1',
|
||||
'#section',
|
||||
]) {
|
||||
assert.equal(safeDescriptionLink(value), value);
|
||||
}
|
||||
for (const value of [
|
||||
'javascript:alert(1)',
|
||||
'JaVaScRiPt:alert(1)',
|
||||
'data:text/html,test',
|
||||
'vbscript:test',
|
||||
'//evil.test',
|
||||
'/\\evil.test',
|
||||
'\u0000https://example.test',
|
||||
'https://',
|
||||
'https://exa mple.test',
|
||||
'java\nscript:alert(1)',
|
||||
null,
|
||||
]) {
|
||||
assert.equal(safeDescriptionLink(value), null);
|
||||
}
|
||||
for (const value of [
|
||||
'<script>alert(1)</script>',
|
||||
'<img src=x onerror=alert(1)>',
|
||||
'| A | B |\n| - | - |\n| a | b |',
|
||||
'',
|
||||
'- [x] Done',
|
||||
'[unsafe](javascript:alert%281%29)',
|
||||
]) {
|
||||
assert.equal(supportsVisualDescription(value), false, value);
|
||||
const html = await render(value);
|
||||
assert.doesNotMatch(html, /<script|<img|href="javascript:/);
|
||||
assert.ok(
|
||||
html.includes(
|
||||
value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>'),
|
||||
),
|
||||
'Original source is retained',
|
||||
);
|
||||
}
|
||||
const maliciousNode = {
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '<script>bad</script>',
|
||||
marks: [
|
||||
{ type: 'link', attrs: { href: 'javascript:bad()' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const safe = await renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h('div', {}, [renderDescriptionNode(maliciousNode)]),
|
||||
}),
|
||||
);
|
||||
assert.doesNotMatch(safe, /<script|<a/);
|
||||
assert.match(safe, /<script>/);
|
||||
assert.deepEqual(parseDescription(''), { type: 'doc', content: [] });
|
||||
let updates = 0;
|
||||
const initial = await renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(Editor, {
|
||||
id: 'description',
|
||||
label: 'Description',
|
||||
modelValue: markdown,
|
||||
error: 'Invalid description.',
|
||||
disabled: true,
|
||||
'onUpdate:modelValue': () => updates++,
|
||||
}),
|
||||
}).use(createLocalization()),
|
||||
);
|
||||
assert.match(initial, /id="description"/);
|
||||
assert.match(initial, /aria-invalid="true"/);
|
||||
assert.match(initial, /id="description-error"/);
|
||||
assert.match(initial, /disabled/);
|
||||
assert.equal(
|
||||
updates,
|
||||
0,
|
||||
'Mounting must not rewrite existing descriptions',
|
||||
);
|
||||
console.log('Ticket description checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user