feat(17-02): richeditor field on TipTap with the untouched rule
- RichEditorField: Reka toolbar, link popover with the http/https/mailto/tel and relative URL policy, Edit HTML view, lossy note, skeleton while the lazily imported TipTap chunk loads, HTML view fallback when it fails - untouched values are submitted byte-identical; after an edit the value is the editor HTML (empty paragraph -> ""); a value from outside (the save's sanitized answer) reloads the editor - read-only renders through a client display allowlist, never as stored - TipTap 3.31.4 (core, pm, starter-kit) pinned exact after the package check - richeditor.* phrase keys (en, pl), smoke specs, forms docs
This commit is contained in:
27
admin/tests/fixtures/companions.form-schema.json
vendored
Normal file
27
admin/tests/fixtures/companions.form-schema.json
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"data": {
|
||||
"name": "Link",
|
||||
"modelClass": "Link",
|
||||
"fields": [
|
||||
{ "name": "title", "type": "text", "label": "Title", "span": "left" },
|
||||
{ "name": "description", "type": "richeditor", "label": "Description", "comment": "Shown under the title." }
|
||||
],
|
||||
"messages": {
|
||||
"create": { "other": "New link" },
|
||||
"update": { "other": "Edit link" },
|
||||
"saved": { "other": "Saved" },
|
||||
"deleteConfirm": { "other": "Delete this record?" },
|
||||
"deleted": { "other": "Record deleted" },
|
||||
"preview": { "other": "Link details" },
|
||||
"edit": { "other": "Edit link" }
|
||||
},
|
||||
"redirects": {
|
||||
"default": "acme/companions/links",
|
||||
"create": { "redirect": "acme/companions/links/:id", "redirectClose": "acme/companions/links" },
|
||||
"update": { "redirect": "acme/companions/links", "redirectClose": "acme/companions/links" }
|
||||
},
|
||||
"meta": { "locale": "en" },
|
||||
"assets": { "scripts": [], "styles": [] }
|
||||
},
|
||||
"meta": { "locale": "en" }
|
||||
}
|
||||
75
admin/tests/fixtures/lang.json
vendored
75
admin/tests/fixtures/lang.json
vendored
@@ -609,6 +609,81 @@
|
||||
"backend::lang.relation.unlink": {
|
||||
"other": "Odłącz"
|
||||
},
|
||||
"backend::lang.richeditor.bold": {
|
||||
"other": "Pogrubienie"
|
||||
},
|
||||
"backend::lang.richeditor.bullet_list": {
|
||||
"other": "Lista punktowana"
|
||||
},
|
||||
"backend::lang.richeditor.heading": {
|
||||
"other": "Nagłówek"
|
||||
},
|
||||
"backend::lang.richeditor.italic": {
|
||||
"other": "Kursywa"
|
||||
},
|
||||
"backend::lang.richeditor.link": {
|
||||
"other": "Link"
|
||||
},
|
||||
"backend::lang.richeditor.link_apply": {
|
||||
"other": "Zastosuj link"
|
||||
},
|
||||
"backend::lang.richeditor.link_invalid": {
|
||||
"other": "Link musi używać http, https, mailto, tel albo adresu względnego."
|
||||
},
|
||||
"backend::lang.richeditor.link_url": {
|
||||
"other": "Adres linku"
|
||||
},
|
||||
"backend::lang.richeditor.load_failed": {
|
||||
"other": "Nie udało się wczytać edytora. Możesz edytować HTML bezpośrednio."
|
||||
},
|
||||
"backend::lang.richeditor.lossy_note": {
|
||||
"other": "Części formatowania tego tekstu nie da się pokazać w edytorze. Użyj „Edytuj HTML”, aby zmienić tekst bez jej utraty."
|
||||
},
|
||||
"backend::lang.richeditor.markup_changed": {
|
||||
"other": "Filtr HTML zmienił część znaczników."
|
||||
},
|
||||
"backend::lang.richeditor.ordered_list": {
|
||||
"other": "Lista numerowana"
|
||||
},
|
||||
"backend::lang.richeditor.quote": {
|
||||
"other": "Cytat"
|
||||
},
|
||||
"backend::lang.richeditor.redo": {
|
||||
"other": "Ponów"
|
||||
},
|
||||
"backend::lang.richeditor.removed_attributes": {
|
||||
"one": "Usunięto :count atrybut: :names",
|
||||
"few": "Usunięto :count atrybuty: :names",
|
||||
"many": "Usunięto :count atrybutów: :names",
|
||||
"other": "Usunięto :count atrybutu: :names"
|
||||
},
|
||||
"backend::lang.richeditor.removed_comments": {
|
||||
"one": "Usunięto :count komentarz HTML",
|
||||
"few": "Usunięto :count komentarze HTML",
|
||||
"many": "Usunięto :count komentarzy HTML",
|
||||
"other": "Usunięto :count komentarza HTML"
|
||||
},
|
||||
"backend::lang.richeditor.removed_elements": {
|
||||
"one": "Usunięto :count element: :names",
|
||||
"few": "Usunięto :count elementy: :names",
|
||||
"many": "Usunięto :count elementów: :names",
|
||||
"other": "Usunięto :count elementu: :names"
|
||||
},
|
||||
"backend::lang.richeditor.source": {
|
||||
"other": "Edytuj HTML"
|
||||
},
|
||||
"backend::lang.richeditor.toolbar": {
|
||||
"other": "Formatowanie"
|
||||
},
|
||||
"backend::lang.richeditor.underline": {
|
||||
"other": "Podkreślenie"
|
||||
},
|
||||
"backend::lang.richeditor.undo": {
|
||||
"other": "Cofnij"
|
||||
},
|
||||
"backend::lang.richeditor.unlink": {
|
||||
"other": "Usuń link"
|
||||
},
|
||||
"backend::lang.settings.back": {
|
||||
"other": "Wróć do ustawień"
|
||||
},
|
||||
|
||||
3
admin/tests/fixtures/typed.ts
vendored
3
admin/tests/fixtures/typed.ts
vendored
@@ -7,6 +7,7 @@ import type { components } from '../../src/api/schema'
|
||||
import langJson from './lang.json'
|
||||
import navigationJson from './navigation.json'
|
||||
import settingsJson from './settings.json'
|
||||
import companionsFormSchemaJson from './companions.form-schema.json'
|
||||
import extensionFormSchemaJson from './extension.form-schema.json'
|
||||
import extensionListSchemaJson from './extension.list-schema.json'
|
||||
import extensionPartialJson from './extension.partial.json'
|
||||
@@ -46,6 +47,8 @@ export const deferredRelationSchemaFixture: {
|
||||
} = deferredRelationSchemaJson
|
||||
/** A protected file list: a captioned image, a pending image and a PDF (Phase 12.2). */
|
||||
export const deferredFilesFixture: S['cabana.Envelope-array_cabana_FileItem'] = deferredFilesJson
|
||||
/** A link form with the Phase 17 field types: richeditor, repeater and a dependent dropdown. */
|
||||
export const companionsFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = companionsFormSchemaJson
|
||||
/** A form with a plugin widget and its controller script (Phase 10.1). */
|
||||
export const extensionFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = extensionFormSchemaJson
|
||||
/** A list with a header partial and a controller stylesheet (Phase 10.1). */
|
||||
|
||||
190
admin/tests/form/RichEditorField.test.ts
Normal file
190
admin/tests/form/RichEditorField.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import type { FormField } from '../../src/api/types'
|
||||
import RichEditorField from '../../src/components/form/fields/RichEditorField.vue'
|
||||
import { displayHtml, isAllowedLink } from '../../src/components/form/richeditor/html'
|
||||
import { isLossy } from '../../src/components/form/richeditor/lossy'
|
||||
import { companionsFormSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
// Phase 17 plan 02 smoke specs for the richeditor field (D-10). Plan 06
|
||||
// completes the coverage; these prove the tracer slice end to end.
|
||||
const BASE = `${API}/acme/companions/links`
|
||||
const RECORD = `${BASE}/1`
|
||||
|
||||
// b, div and attributes: markup TipTap would re-serialize if it were asked.
|
||||
const STORED = '<P class="lead">Hello <b>bold</b> world</P>\n<div data-x="1" style="color: red">Box</div><!-- kept -->'
|
||||
|
||||
function record(description: string | null) {
|
||||
return { data: { id: 1, title: 'Docs', description }, meta: {} }
|
||||
}
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${BASE}/schema/form`]: { body: companionsFormSchemaFixture },
|
||||
[`GET ${RECORD}`]: { body: record(STORED) },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function editorReady(wrapper: VueWrapper): Promise<void> {
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(wrapper.find('[data-richeditor-area]').exists()).toBe(true)
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('RichEditorField', () => {
|
||||
it('RichEditorField tracer: untouched value is submitted byte-identical', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes({
|
||||
[`PUT ${RECORD}`]: async (request) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return { body: record(body.description as string) }
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await editorReady(wrapper)
|
||||
|
||||
// The editor shows the stored text; b and div are inside the editor's set.
|
||||
expect(wrapper.find('[data-richeditor-area]').text()).toContain('Hello bold world')
|
||||
expect(wrapper.find('[data-richeditor-lossy]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-richeditor-area]').attributes('aria-labelledby')).toBe('field-description-label')
|
||||
expect(wrapper.find('#field-description-label').text()).toBe('Description')
|
||||
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
||||
expect(body).not.toBeNull()
|
||||
expect(body!.description).toBe(STORED)
|
||||
expect(body!.description === STORED).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the lossy note for markup outside the editor schema and keeps the HTML view lossless', async () => {
|
||||
const lossy = '<table><tr><td>Cell</td></tr></table><p>Text</p>'
|
||||
const { wrapper } = await mountApp('/acme/companions/links/1', routes({ [`GET ${RECORD}`]: { body: record(lossy) } }), {
|
||||
attach: true,
|
||||
})
|
||||
await editorReady(wrapper)
|
||||
expect(wrapper.find('[data-richeditor-lossy]').attributes('role')).toBe('note')
|
||||
|
||||
await wrapper.find('[data-tool="source"]').trigger('click')
|
||||
const textarea = wrapper.find<HTMLTextAreaElement>('[data-richeditor-source]')
|
||||
expect(textarea.element.value).toBe(lossy)
|
||||
expect(textarea.attributes('id')).toBe('field-description')
|
||||
})
|
||||
|
||||
it('sends the editor HTML after an edit and shows what the server removed', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
const sanitized = '<h3>Hello bold world</h3>'
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/companions/links/1',
|
||||
routes({
|
||||
[`GET ${RECORD}`]: { body: record('<p>Hello <b>bold</b> world</p>') },
|
||||
[`PUT ${RECORD}`]: async (request) => {
|
||||
body = (await request.json()) as Record<string, unknown>
|
||||
return {
|
||||
body: {
|
||||
data: { id: 1, title: 'Docs', description: sanitized },
|
||||
meta: { notices: [{ field: 'description', message: '1 element was removed: b' }] },
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await editorReady(wrapper)
|
||||
|
||||
await wrapper.find('[data-tool="heading"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-tool="heading"]').attributes('aria-pressed')).toBe('true')
|
||||
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(body!.description).toBe('<h3>Hello <strong>bold</strong> world</h3>')
|
||||
expect(wrapper.find('[data-field="description"] [data-field-notice]').text()).toBe('1 element was removed: b')
|
||||
expect(wrapper.find('[data-richeditor-area]').html()).toContain('<h3>Hello bold world</h3>')
|
||||
})
|
||||
|
||||
it('RichEditorField edit emits editor HTML', async () => {
|
||||
const field = { name: 'body', label: 'Body', type: 'richeditor' } as FormField
|
||||
const wrapper = mount(RichEditorField, {
|
||||
props: { field, modelValue: '<p>Hello</p>', controlId: 'f-body' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
expect(wrapper.find('[data-richeditor-loading]').attributes('aria-busy')).toBe('true')
|
||||
await editorReady(wrapper)
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
|
||||
await wrapper.find('[data-tool="bold"]').trigger('click')
|
||||
await wrapper.find('[data-tool="heading"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue') ?? []
|
||||
expect(emitted.length).toBeGreaterThan(0)
|
||||
const last = emitted[emitted.length - 1]![0]
|
||||
expect(last).toBe('<h3>Hello</h3>')
|
||||
expect(wrapper.find('[data-richeditor-area]').html()).toContain('<h3>Hello</h3>')
|
||||
|
||||
// The HTML view emits the text as typed.
|
||||
await wrapper.find('[data-tool="source"]').trigger('click')
|
||||
await wrapper.find('[data-richeditor-source]').setValue('<p>Typed <em>here</em></p>')
|
||||
const afterSource = wrapper.emitted('update:modelValue') ?? []
|
||||
expect(afterSource[afterSource.length - 1]![0]).toBe('<p>Typed <em>here</em></p>')
|
||||
})
|
||||
|
||||
it('renders read-only values through the display policy, never as stored', () => {
|
||||
const field = { name: 'body', label: 'Body', type: 'richeditor', readOnly: true } as FormField
|
||||
const wrapper = mount(RichEditorField, {
|
||||
props: {
|
||||
field,
|
||||
modelValue: '<p onclick="x()">Hi <a href="javascript:alert(1)">bad</a> <a href="/ok">ok</a></p><script>window.__re = 1</script>',
|
||||
controlId: 'f-body',
|
||||
},
|
||||
})
|
||||
const box = wrapper.find('[data-richeditor-readonly]')
|
||||
expect(box.find('script').exists()).toBe(false)
|
||||
expect(box.find('p').attributes('onclick')).toBeUndefined()
|
||||
expect(box.findAll('a').map((link) => link.attributes('href'))).toEqual([undefined, '/ok'])
|
||||
expect((window as unknown as { __re?: number }).__re).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('richeditor policy helpers', () => {
|
||||
it('accepts policy links only', () => {
|
||||
for (const url of ['https://example.test/a', 'http://x.test', 'mailto:a@example.test', 'tel:+48123', '/path', '#top', '?q=1', './rel', 'docs/page']) {
|
||||
expect(isAllowedLink(url), url).toBe(true)
|
||||
}
|
||||
for (const url of ['javascript:alert(1)', 'java\tscript:alert(1)', 'data:text/html,x', 'ftp://x.test', '', ' ']) {
|
||||
expect(isAllowedLink(url), url).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('compares tag names case-insensitively for the lossy note', () => {
|
||||
expect(isLossy('<P>a</P><DIV><SPAN>b</SPAN></DIV>')).toBe(false)
|
||||
expect(isLossy('<p>a</p><H2>b</H2>')).toBe(true)
|
||||
expect(isLossy('')).toBe(false)
|
||||
})
|
||||
|
||||
it('unwraps unknown elements and drops scripts for display', () => {
|
||||
expect(displayHtml('<section><p class="x">a</p></section><script>b</script>')).toBe('<p>a</p>')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user