diff --git a/README.md b/README.md index 185b65d..aef8fc5 100644 --- a/README.md +++ b/README.md @@ -67,3 +67,57 @@ To add new Vue components: 1. Create the component in the `assets/vue/` directory 2. Import and mount it in `assets/app.js` 3. Add a mount point in the appropriate template + +## CKEditor 5 Integration + +The rich text editor lives in `assets/editor/` and is exposed to the existing forms through `assets/vue/components/base/CkEditorField.vue`. + +### Architecture + +- `assets/editor/CkEditor.vue` is the reusable Vue 3 editor component. +- `assets/editor/uploadAdapter.ts` provides CKEditor upload support against a Symfony endpoint. +- `assets/editor/assetBrowserPlugin.js` adds a toolbar button that opens the native asset browser. +- `assets/editor/EditorAssetPicker.vue` shows existing uploaded files and inserts them as images or links. +- `assets/editor/plugins.ts` and `assets/editor/toolbar.ts` keep the editor configuration isolated and reusable. +- `src/Service/EditorUploadService.php` stores uploads in the public filesystem and returns a browser URL. +- `src/Controller/EditorUploadController.php` accepts authenticated uploads and lists existing assets. + +### Reused from the legacy plugin + +- upload path conventions +- image validation rules +- public-file URL generation strategy +- file storage separation for editor content +- asset browsing behavior, expressed as a native Vue modal instead of a popup window + +### New behavior + +- Vue component with `v-model` +- configurable toolbar and CKEditor options +- upload adapter with progress support +- cleanup on unmount +- read-only mode support +- existing file browser for reusing uploaded content + +### Build and test + +```bash +yarn encore dev +yarn test:vue +vendor/bin/phpunit +``` + +### Configuration + +The backend uses the existing phpList upload directory parameters: + +- `phplist.upload_images_dir` +- `phplist.editor_images_dir` + +Uploads are stored below `public//ckeditor5/`. + +### Limitations + +- This integration only covers image uploads from CKEditor. +- elFinder is not embedded in the frontend UI; the frontend now provides a native asset browser instead. +- Existing legacy HTML content is preserved as-is, but custom HTML support still depends on CKEditor 5 output rules. diff --git a/assets/editor/CkEditor.vue b/assets/editor/CkEditor.vue new file mode 100644 index 0000000..1439afb --- /dev/null +++ b/assets/editor/CkEditor.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/assets/editor/EditorAssetPicker.vue b/assets/editor/EditorAssetPicker.vue new file mode 100644 index 0000000..440b728 --- /dev/null +++ b/assets/editor/EditorAssetPicker.vue @@ -0,0 +1,192 @@ + + + diff --git a/assets/editor/assetBrowserPlugin.js b/assets/editor/assetBrowserPlugin.js new file mode 100644 index 0000000..1bf752e --- /dev/null +++ b/assets/editor/assetBrowserPlugin.js @@ -0,0 +1,26 @@ +import { ButtonView, Plugin } from 'ckeditor5'; + +export default class AssetBrowserPlugin extends Plugin { + init() { + const editor = this.editor; + + editor.ui.componentFactory.add('assetBrowser', (locale) => { + const view = new ButtonView(locale); + const openAssetPicker = editor.config.get('openAssetPicker'); + + view.set({ + label: 'Browse files', + tooltip: true, + withText: true, + }); + + view.on('execute', () => { + if (typeof openAssetPicker === 'function') { + openAssetPicker(editor); + } + }); + + return view; + }); + } +} diff --git a/assets/editor/index.ts b/assets/editor/index.ts new file mode 100644 index 0000000..a5c67cf --- /dev/null +++ b/assets/editor/index.ts @@ -0,0 +1,6 @@ +export { default as CkEditor } from './CkEditor.vue'; +export { default as EditorUploadAdapter } from './uploadAdapter.ts'; +export { DEFAULT_TOOLBAR, DEFAULT_IMAGE_TOOLBAR } from './toolbar.ts'; +export { DEFAULT_PLUGINS } from './plugins.ts'; +export { default as EditorAssetPicker } from './EditorAssetPicker.vue'; +export { default as AssetBrowserPlugin } from './assetBrowserPlugin.js'; diff --git a/assets/editor/plugins.ts b/assets/editor/plugins.ts new file mode 100644 index 0000000..8809fcf --- /dev/null +++ b/assets/editor/plugins.ts @@ -0,0 +1,118 @@ +import { + Alignment, + AutoImage, + AutoLink, + Autosave, + BlockQuote, + Bold, + Code, + FontBackgroundColor, + FontColor, + FontFamily, + FontSize, + Essentials, + FileRepository, + GeneralHtmlSupport, + Heading, + Highlight, + HorizontalLine, + HtmlEmbed, + Indent, + IndentBlock, + Image, + ImageCaption, + ImageBlock, + ImageInsert, + ImageInsertUI, + ImageInsertViaUrl, + ImageInline, + ImageResize, + ImageStyle, + ImageToolbar, + ImageUpload, + Italic, + Link, + LinkImage, + List, + ListProperties, + MediaEmbed, + Paragraph, + PictureEditing, + PlainTableOutput, + RemoveFormat, + SourceEditing, + Strikethrough, + Subscript, + Superscript, + Table, + TableCaption, + TableCellProperties, + TableColumnResize, + TableLayout, + TableProperties, + TableToolbar, + TextTransformation, + TodoList, + Underline, +} from 'ckeditor5'; + +import AssetBrowserPlugin from './assetBrowserPlugin.js'; + +export const DEFAULT_PLUGINS = [ + Essentials, + Alignment, + AutoLink, + Autosave, + Paragraph, + Bold, + Italic, + Code, + Heading, + FontBackgroundColor, + FontColor, + FontFamily, + FontSize, + Link, + List, + ListProperties, + BlockQuote, + Highlight, + Table, + TableCaption, + TableCellProperties, + TableColumnResize, + TableLayout, + TableProperties, + TableToolbar, + HorizontalLine, + HtmlEmbed, + SourceEditing, + RemoveFormat, + Strikethrough, + Subscript, + Superscript, + Underline, + TextTransformation, + TodoList, + Indent, + IndentBlock, + MediaEmbed, + Image, + ImageToolbar, + ImageCaption, + ImageStyle, + ImageResize, + ImageUpload, + ImageInsert, + ImageInsertUI, + ImageInsertViaUrl, + ImageInline, + ImageBlock, + LinkImage, + AutoImage, + PictureEditing, + PlainTableOutput, + FileRepository, + GeneralHtmlSupport, + AssetBrowserPlugin, +]; diff --git a/assets/editor/toolbar.ts b/assets/editor/toolbar.ts new file mode 100644 index 0000000..b711b54 --- /dev/null +++ b/assets/editor/toolbar.ts @@ -0,0 +1,50 @@ +export const DEFAULT_TOOLBAR = [ + 'undo', + 'redo', + '|', + 'heading', + '|', + 'fontSize', + 'fontFamily', + 'fontColor', + 'fontBackgroundColor', + '|', + 'bold', + 'italic', + 'underline', + 'strikethrough', + 'subscript', + 'superscript', + 'code', + 'removeFormat', + '|', + 'link', + 'highlight', + 'alignment', + '|', + 'bulletedList', + 'numberedList', + 'todoList', + '|', + 'blockQuote', + 'insertTable', + 'insertTableLayout', + 'insertImage', + 'sourceEditing', + 'htmlEmbed', + 'mediaEmbed', + 'assetBrowser', + '|', + 'horizontalLine', + 'outdent', + 'indent', +]; + +export const DEFAULT_IMAGE_TOOLBAR = [ + 'imageStyle:inline', + 'imageStyle:wrapText', + 'imageStyle:breakText', + '|', + 'toggleImageCaption', + 'imageTextAlternative', +]; diff --git a/assets/editor/uploadAdapter.ts b/assets/editor/uploadAdapter.ts new file mode 100644 index 0000000..f54c273 --- /dev/null +++ b/assets/editor/uploadAdapter.ts @@ -0,0 +1,88 @@ +export default class EditorUploadAdapter { + constructor(loader, options = {}) { + this.loader = loader; + this.endpoint = options.endpoint || '/editor/upload'; + this.headers = options.headers || {}; + this.withCredentials = options.withCredentials !== false; + this.xhr = null; + } + + upload() { + return this.loader.file.then((file) => new Promise((resolve, reject) => { + const xhr = this.xhr = new XMLHttpRequest(); + const formData = new FormData(); + + xhr.open('POST', this.endpoint, true); + xhr.responseType = 'json'; + xhr.withCredentials = this.withCredentials; + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + + Object.entries(this.headers).forEach(([key, value]) => { + xhr.setRequestHeader(key, value); + }); + + xhr.upload.addEventListener('progress', (event) => { + if (!event.lengthComputable) { + return; + } + + this.loader.uploadTotal = event.total; + this.loader.uploaded = event.loaded; + }); + + xhr.addEventListener('error', () => { + reject('The file could not be uploaded.'); + }); + + xhr.addEventListener('abort', () => { + reject('The upload was aborted.'); + }); + + xhr.addEventListener('load', () => { + const response = xhr.response || parseResponse(xhr.responseText); + + if (!response) { + reject('The upload response was empty.'); + return; + } + + if (response.error?.message) { + reject(response.error.message); + return; + } + + if (typeof response.url === 'string' && response.url.length > 0) { + resolve({ + default: response.url, + }); + return; + } + + reject('The upload response did not include a file URL.'); + }); + + formData.append('upload', file); + formData.append('fileName', file.name); + + xhr.send(formData); + })); + } + + abort() { + if (this.xhr) { + this.xhr.abort(); + } + } +} + +function parseResponse(responseText) { + if (typeof responseText !== 'string' || responseText.trim() === '') { + return null; + } + + try { + return JSON.parse(responseText); + } catch (_error) { + return null; + } +} diff --git a/assets/vue/components/base/CkEditorField.vue b/assets/vue/components/base/CkEditorField.vue index 9a56ad6..2f8e3b3 100644 --- a/assets/vue/components/base/CkEditorField.vue +++ b/assets/vue/components/base/CkEditorField.vue @@ -1,5 +1,5 @@ diff --git a/composer.json b/composer.json index 2b1dd66..fdfa974 100755 --- a/composer.json +++ b/composer.json @@ -57,7 +57,8 @@ "symfony/security-bundle": "^6.4", "tatevikgr/rest-api-client": "dev-dev", "phplist/phplist-lan-texts": "^2021.05", - "psr/simple-cache": "^3.0" + "psr/simple-cache": "^3.0", + "ext-fileinfo": "*" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/config/services.yml b/config/services.yml index 76325ac..b27b172 100755 --- a/config/services.yml +++ b/config/services.yml @@ -10,6 +10,7 @@ services: public: false bind: $projectDir: '%kernel.project_dir%' + $editorImagesDir: '%phplist.editor_images_dir%' PhpList\WebFrontend\: resource: '../src/' diff --git a/src/Controller/EditorUploadController.php b/src/Controller/EditorUploadController.php new file mode 100644 index 0000000..fbf7ac1 --- /dev/null +++ b/src/Controller/EditorUploadController.php @@ -0,0 +1,61 @@ +files->get('upload'); + + if (!$uploadedFile) { + return new JsonResponse([ + 'error' => [ + 'message' => 'No file was provided.', + ], + ], Response::HTTP_BAD_REQUEST); + } + + try { + $result = $this->editorUploadService->storeImage($uploadedFile); + } catch (RuntimeException $exception) { + return new JsonResponse([ + 'error' => [ + 'message' => $exception->getMessage(), + ], + ], Response::HTTP_BAD_REQUEST); + } + + return new JsonResponse([ + 'url' => $result->relativeUrl, + 'fileName' => $result->fileName, + ]); + } + + #[Route('/assets', name: 'assets', methods: ['GET'])] + public function assets(): JsonResponse + { + return new JsonResponse([ + 'items' => array_map( + static fn ($asset) => $asset->toArray(), + $this->editorUploadService->listAssets() + ), + ]); + } +} diff --git a/src/Dto/EditorAssetItem.php b/src/Dto/EditorAssetItem.php new file mode 100644 index 0000000..ef010d6 --- /dev/null +++ b/src/Dto/EditorAssetItem.php @@ -0,0 +1,30 @@ + $this->fileName, + 'url' => $this->url, + 'mimeType' => $this->mimeType, + 'size' => $this->size, + 'modifiedAt' => $this->modifiedAt, + 'isImage' => $this->isImage, + ]; + } +} diff --git a/src/Dto/EditorUploadResult.php b/src/Dto/EditorUploadResult.php new file mode 100644 index 0000000..c2aa731 --- /dev/null +++ b/src/Dto/EditorUploadResult.php @@ -0,0 +1,14 @@ +isValid()) { + throw new RuntimeException($uploadedFile->getErrorMessage()); + } + + $mimeType = (string) $uploadedFile->getMimeType(); + if (!str_starts_with($mimeType, 'image/')) { + throw new RuntimeException('Only image uploads are supported.'); + } + + $targetDirectory = $this->getTargetDirectory(); + $this->filesystem->mkdir($targetDirectory, 0755); + + $fileName = $this->buildFileName($uploadedFile); + $uploadedFile->move($targetDirectory, $fileName); + + return new EditorUploadResult( + fileName: $fileName, + relativeUrl: $this->buildRelativeUrl($fileName), + ); + } + + /** + * @return array + */ + public function listAssets(): array + { + $directory = $this->getTargetDirectory(); + + if (!is_dir($directory)) { + return []; + } + + $items = []; + foreach (new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS) as $fileInfo) { + if (!$fileInfo->isFile()) { + continue; + } + + $fileName = $fileInfo->getBasename(); + if ($fileName === '' || str_starts_with($fileName, '.')) { + continue; + } + + $mimeType = (string) (mime_content_type($fileInfo->getPathname()) ?: 'application/octet-stream'); + + $items[] = new EditorAssetItem( + fileName: $fileName, + url: $this->buildRelativeUrl($fileName), + mimeType: $mimeType, + size: (int) $fileInfo->getSize(), + modifiedAt: (int) $fileInfo->getMTime(), + isImage: str_starts_with($mimeType, 'image/'), + ); + } + + usort( + $items, + static fn (EditorAssetItem $left, EditorAssetItem $right): int => $right->modifiedAt <=> $left->modifiedAt + ); + + return $items; + } + + public function buildRelativeUrl(string $fileName): string + { + return sprintf( + '/%s/%s/%s', + trim($this->editorImagesDir, '/'), + self::STORAGE_SUBDIRECTORY, + ltrim($fileName, '/') + ); + } + + public function getTargetDirectory(): string + { + return rtrim($this->projectDir, '/') + . '/public/' + . trim($this->editorImagesDir, '/') + . '/' + . self::STORAGE_SUBDIRECTORY; + } + + private function buildFileName(UploadedFile $uploadedFile): string + { + $originalName = pathinfo((string) $uploadedFile->getClientOriginalName(), PATHINFO_FILENAME); + $safeName = (new AsciiSlugger())->slug($originalName)->lower()->toString(); + $safeName = $safeName !== '' ? $safeName : 'image'; + + $extension = $uploadedFile->guessExtension(); + if (!is_string($extension) || $extension === '') { + $extension = $this->guessExtensionFromMime((string) $uploadedFile->getMimeType()); + } + + if ($extension === '') { + $extension = 'bin'; + } + + return sprintf('%s-%s.%s', $safeName, bin2hex(random_bytes(6)), $extension); + } + + private function guessExtensionFromMime(string $mimeType): string + { + return match ($mimeType) { + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + 'image/bmp', 'image/x-ms-bmp' => 'bmp', + 'image/svg+xml' => 'svg', + default => '', + }; + } +} diff --git a/tests/Integration/Controller/EditorUploadControllerTest.php b/tests/Integration/Controller/EditorUploadControllerTest.php new file mode 100644 index 0000000..6b9f2ab --- /dev/null +++ b/tests/Integration/Controller/EditorUploadControllerTest.php @@ -0,0 +1,129 @@ +get('router'); + + self::assertSame('/editor/upload', $router->generate('editor_upload')); + } + + public function testUploadReturnsAJsonUrl(): void + { + self::bootKernel(); + + $projectDir = sys_get_temp_dir() . '/phplist-editor-upload-' . bin2hex(random_bytes(4)); + $service = new EditorUploadService($projectDir, 'uploadimages'); + $controller = new EditorUploadController($service); + + $sourceFile = tempnam(sys_get_temp_dir(), 'editor-upload-'); + self::assertIsString($sourceFile); + file_put_contents($sourceFile, base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3Z4foAAAAASUVORK5CYII=' + )); + + $uploadedFile = new UploadedFile( + $sourceFile, + 'newsletter-image.jpg', + 'image/jpeg', + null, + true + ); + + $request = Request::create('/editor/upload', 'POST', [], [], [ + 'upload' => $uploadedFile, + ]); + $session = new Session(new MockArraySessionStorage()); + $session->set('auth_token', 'integration-token'); + $request->setSession($session); + + $response = $controller->upload($request); + + self::assertSame(200, $response->getStatusCode()); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertArrayHasKey('url', $payload); + self::assertArrayHasKey('fileName', $payload); + self::assertSame('/uploadimages/ckeditor5/' . $payload['fileName'], $payload['url']); + self::assertFileExists($projectDir . '/public/uploadimages/ckeditor5/' . $payload['fileName']); + + if (is_file($sourceFile)) { + unlink($sourceFile); + } + $uploadedFile = $projectDir . '/public/uploadimages/ckeditor5/' . $payload['fileName']; + if (is_file($uploadedFile)) { + unlink($uploadedFile); + } + $this->removePath($projectDir . '/public/uploadimages/ckeditor5'); + $this->removePath($projectDir . '/public/uploadimages'); + $this->removePath($projectDir . '/public'); + $this->removePath($projectDir); + } + + public function testAssetsRouteReturnsExistingFiles(): void + { + self::bootKernel(); + + $projectDir = sys_get_temp_dir() . '/phplist-editor-assets-' . bin2hex(random_bytes(4)); + $service = new EditorUploadService($projectDir, 'uploadimages'); + $controller = new EditorUploadController($service); + + $directory = $projectDir . '/public/uploadimages/ckeditor5'; + mkdir($directory, 0755, true); + file_put_contents($directory . '/asset.txt', 'asset'); + + $response = $controller->assets(); + self::assertSame(200, $response->getStatusCode()); + + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertArrayHasKey('items', $payload); + self::assertCount(1, $payload['items']); + self::assertSame('asset.txt', $payload['items'][0]['fileName']); + + $this->removePath($directory . '/asset.txt'); + $this->removePath($directory); + $this->removePath($projectDir . '/public/uploadimages'); + $this->removePath($projectDir . '/public'); + $this->removePath($projectDir); + } + + private function removePath(string $path): void + { + if (is_file($path) || is_link($path)) { + unlink($path); + + return; + } + + if (!is_dir($path)) { + return; + } + + foreach (scandir($path) as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $this->removePath($path . DIRECTORY_SEPARATOR . $item); + } + + rmdir($path); + } +} diff --git a/tests/Unit/Service/EditorUploadServiceTest.php b/tests/Unit/Service/EditorUploadServiceTest.php new file mode 100644 index 0000000..6615bcf --- /dev/null +++ b/tests/Unit/Service/EditorUploadServiceTest.php @@ -0,0 +1,101 @@ +storeImage($uploadedFile); + + self::assertStringEndsWith('.png', $result->fileName); + self::assertSame('/uploadimages/ckeditor5/' . $result->fileName, $result->relativeUrl); + self::assertFileExists($projectDir . '/public/uploadimages/ckeditor5/' . $result->fileName); + + $this->removePath($projectDir . '/public/uploadimages/ckeditor5/' . $result->fileName); + $this->removePath($projectDir . '/public/uploadimages/ckeditor5'); + $this->removePath($projectDir . '/public/uploadimages'); + $this->removePath($projectDir . '/public'); + $this->removePath($projectDir); + } + + public function testListAssetsReturnsUploadedFilesSortedByNewestFirst(): void + { + $projectDir = sys_get_temp_dir() . '/phplist-editor-assets-' . bin2hex(random_bytes(4)); + $service = new EditorUploadService($projectDir, 'uploadimages'); + + $directory = $projectDir . '/public/uploadimages/ckeditor5'; + mkdir($directory, 0755, true); + + $imagePath = $directory . '/image-one.png'; + file_put_contents($imagePath, base64_decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3Z4foAAAAASUVORK5CYII=' + )); + touch($imagePath, time() - 60); + + $filePath = $directory . '/notes.txt'; + file_put_contents($filePath, 'notes'); + + $assets = $service->listAssets(); + + self::assertCount(2, $assets); + self::assertSame('notes.txt', $assets[0]->fileName); + self::assertFalse($assets[0]->isImage); + self::assertSame('/uploadimages/ckeditor5/notes.txt', $assets[0]->url); + self::assertSame('image-one.png', $assets[1]->fileName); + self::assertTrue($assets[1]->isImage); + + $this->removePath($imagePath); + $this->removePath($filePath); + $this->removePath($directory); + $this->removePath($projectDir . '/public/uploadimages'); + $this->removePath($projectDir . '/public'); + $this->removePath($projectDir); + } + + private function removePath(string $path): void + { + if (is_file($path) || is_link($path)) { + unlink($path); + + return; + } + + if (!is_dir($path)) { + return; + } + + foreach (scandir($path) as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $this->removePath($path . DIRECTORY_SEPARATOR . $item); + } + + rmdir($path); + } +} diff --git a/tests/Unit/assets/editor/CkEditor.spec.js b/tests/Unit/assets/editor/CkEditor.spec.js new file mode 100644 index 0000000..1848b12 --- /dev/null +++ b/tests/Unit/assets/editor/CkEditor.spec.js @@ -0,0 +1,140 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import CkEditor from '../../../../assets/editor/CkEditor.vue' + +vi.mock('ckeditor5', () => ({ + ClassicEditor: {}, + Alignment: {}, + AutoImage: {}, + AutoLink: {}, + Autosave: {}, + Essentials: {}, + Paragraph: {}, + Bold: {}, + Italic: {}, + Heading: {}, + Code: {}, + FontBackgroundColor: {}, + FontColor: {}, + FontFamily: {}, + FontSize: {}, + Link: {}, + List: {}, + ListProperties: {}, + BlockQuote: {}, + Highlight: {}, + Table: {}, + TableCaption: {}, + TableCellProperties: {}, + TableColumnResize: {}, + TableLayout: {}, + TableProperties: {}, + TableToolbar: {}, + HorizontalLine: {}, + HtmlEmbed: {}, + SourceEditing: {}, + RemoveFormat: {}, + Strikethrough: {}, + Subscript: {}, + Superscript: {}, + Underline: {}, + TextTransformation: {}, + TodoList: {}, + Indent: {}, + IndentBlock: {}, + MediaEmbed: {}, + Image: {}, + ImageToolbar: {}, + ImageCaption: {}, + ImageStyle: {}, + ImageResize: {}, + ImageUpload: {}, + ImageInsert: {}, + ImageInsertUI: {}, + ImageInsertViaUrl: {}, + ImageInline: {}, + ImageBlock: {}, + LinkImage: {}, + PictureEditing: {}, + PlainTableOutput: {}, + FileRepository: {}, + GeneralHtmlSupport: {}, + Plugin: class {}, + ButtonView: class {}, +})) + +vi.mock('@ckeditor/ckeditor5-vue', () => ({ + Ckeditor: { + name: 'ckeditor', + props: [ + 'modelValue', + 'editor', + 'config', + 'id', + 'disabled', + ], + emits: ['update:modelValue', 'ready'], + template: ` +
+ + +
+ `, + }, +})) + +describe('CkEditor', () => { + const createWrapper = (props = {}) => + mount(CkEditor, { + props: { + ...props, + }, + }) + + it('syncs v-model updates', async () => { + const wrapper = createWrapper({ modelValue: '

Initial

' }) + + await wrapper.find('.change-value').trigger('click') + + expect(wrapper.emitted('update:modelValue')).toEqual([ + ['

Updated

'], + ]) + }) + + it('passes the expected editor config', () => { + const wrapper = createWrapper() + + const editor = wrapper.findComponent({ name: 'ckeditor' }) + const config = editor.props('config') + + expect(config.licenseKey).toBe('GPL') + expect(config.toolbar).toContain('insertImage') + expect(config.toolbar).toContain('assetBrowser') + expect(config.htmlSupport.allow[0].name).toBeDefined() + }) + + it('exposes an asset picker hook in the editor config', () => { + const wrapper = createWrapper() + + const editor = wrapper.findComponent({ name: 'ckeditor' }) + const config = editor.props('config') + + expect(typeof config.openAssetPicker).toBe('function') + }) + + it('disables the editor when readonly is set', () => { + const wrapper = createWrapper({ readonly: true }) + + const editor = wrapper.findComponent({ name: 'ckeditor' }) + expect(editor.props('disabled')).toBe(true) + }) +}) diff --git a/tests/Unit/assets/editor/uploadAdapter.spec.js b/tests/Unit/assets/editor/uploadAdapter.spec.js new file mode 100644 index 0000000..7f60758 --- /dev/null +++ b/tests/Unit/assets/editor/uploadAdapter.spec.js @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import EditorUploadAdapter from '../../../../assets/editor/uploadAdapter.ts' + +const originalXhr = global.XMLHttpRequest + +describe('EditorUploadAdapter', () => { + let listeners + let sendMock + + beforeEach(() => { + listeners = {} + sendMock = vi.fn() + + global.XMLHttpRequest = class { + constructor() { + this.upload = { + addEventListener: (event, handler) => { + listeners[`upload:${event}`] = handler + }, + } + this.addEventListener = (event, handler) => { + listeners[event] = handler + } + this.open = vi.fn() + this.setRequestHeader = vi.fn() + this.send = sendMock + this.abort = vi.fn() + this.response = null + this.responseText = '' + this.responseType = '' + this.withCredentials = false + } + } + }) + + afterEach(() => { + global.XMLHttpRequest = originalXhr + }) + + it('uploads files and resolves the returned URL', async () => { + const loader = { + file: Promise.resolve(new File(['image'], 'banner.png', { type: 'image/png' })), + } + + const adapter = new EditorUploadAdapter(loader, { + endpoint: '/editor/upload', + }) + + const uploadPromise = adapter.upload() + await Promise.resolve() + const xhr = adapter.xhr + + xhr.response = { url: '/uploadimages/ckeditor5/banner.png' } + listeners.load() + + await expect(uploadPromise).resolves.toEqual({ + default: '/uploadimages/ckeditor5/banner.png', + }) + + expect(sendMock).toHaveBeenCalledOnce() + expect(xhr.open).toHaveBeenCalledWith('POST', '/editor/upload', true) + }) + + it('rejects when the backend returns an error message', async () => { + const loader = { + file: Promise.resolve(new File(['image'], 'banner.png', { type: 'image/png' })), + } + + const adapter = new EditorUploadAdapter(loader) + const uploadPromise = adapter.upload() + await Promise.resolve() + + adapter.xhr.response = { error: { message: 'Upload failed.' } } + listeners.load() + + await expect(uploadPromise).rejects.toBe('Upload failed.') + }) +}) diff --git a/tests/Unit/assets/vue/components/base/CkEditorField.spec.js b/tests/Unit/assets/vue/components/base/CkEditorField.spec.js index 25047ce..21bf85a 100644 --- a/tests/Unit/assets/vue/components/base/CkEditorField.spec.js +++ b/tests/Unit/assets/vue/components/base/CkEditorField.spec.js @@ -1,50 +1,30 @@ -// CkEditorField.spec.js - import { describe, it, expect, vi } from 'vitest' import { mount } from '@vue/test-utils' import CkEditorField from '../../../../../../assets/vue/components/base/CkEditorField.vue' -vi.mock('ckeditor5', () => ({ - ClassicEditor: {}, - Essentials: {}, - Paragraph: {}, - Bold: {}, - Italic: {}, - Heading: {}, - Link: {}, - List: {}, - BlockQuote: {}, - Table: {}, - TableToolbar: {}, - HorizontalLine: {}, - Image: {}, - ImageToolbar: {}, - ImageCaption: {}, - ImageStyle: {}, - ImageResize: {}, - AutoImage: {}, - PictureEditing: {}, -})) - -const CkeditorStub = { - name: 'ckeditor', +const CkEditorStub = { + name: 'CkEditor', props: [ 'modelValue', - 'editor', - 'config', 'id', + 'readonly', + 'disabled', + 'minHeight', + 'uploadEndpoint', + 'uploadHeaders', + 'withCredentials', + 'toolbar', + 'plugins', + 'config', ], emits: ['update:modelValue'], template: ` -
- -
- `, +
+ +
+ `, } describe('CkEditorField', () => { @@ -55,7 +35,7 @@ describe('CkEditorField', () => { }, global: { stubs: { - ckeditor: CkeditorStub, + CkEditor: CkEditorStub, }, }, }) @@ -80,8 +60,7 @@ describe('CkEditorField', () => { modelValue: '

Hello

', }) - const editor = - wrapper.findComponent(CkeditorStub) + const editor = wrapper.findComponent(CkEditorStub) expect(editor.props('modelValue')) .toBe('

Hello

') @@ -130,55 +109,33 @@ describe('CkEditorField', () => { id: 'editor-1', }) - const editor = - wrapper.findComponent(CkeditorStub) + const editor = wrapper.findComponent(CkEditorStub) expect(editor.props('id')) .toBe('editor-1') }) - it('passes editor instance to ckeditor', () => { - const wrapper = createWrapper() - - const editor = - wrapper.findComponent(CkeditorStub) - - expect(editor.props('editor')) - .toBeDefined() - }) - - it('passes editor config to ckeditor', () => { + it('passes editor options through to the editor component', () => { const wrapper = createWrapper() - const editor = - wrapper.findComponent(CkeditorStub) + const editor = wrapper.findComponent(CkEditorStub) - const config = editor.props('config') + expect(editor.props('uploadEndpoint')) + .toBe('/editor/upload') - expect(config.licenseKey) - .toBe('GPL') - - expect(config.toolbar) - .toContain('bold') - - expect(config.toolbar) - .toContain('italic') - - expect(config.toolbar) - .toContain('insertTable') + expect(editor.props('minHeight')) + .toBe(300) }) - it('contains image toolbar configuration', () => { - const wrapper = createWrapper() - - const config = - wrapper.findComponent(CkeditorStub) - .props('config') - - expect(config.image.toolbar) - .toContain('toggleImageCaption') + it('forwards custom config and toolbar props', () => { + const toolbar = ['undo', 'bold'] + const wrapper = createWrapper({ + toolbar, + config: { placeholder: 'Write here' }, + }) - expect(config.image.toolbar) - .toContain('imageTextAlternative') + const editor = wrapper.findComponent(CkEditorStub) + expect(editor.props('toolbar')).toEqual(toolbar) + expect(editor.props('config')).toEqual({ placeholder: 'Write here' }) }) }) diff --git a/vitest.config.mjs b/vitest.config.mjs index df36cd0..6bd1776 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -6,6 +6,10 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, - include: ['assets/vue/**/*.spec.js', 'tests/Unit/assets/vue/**/*.spec.js'], + include: [ + 'assets/vue/**/*.spec.js', + 'tests/Unit/assets/vue/**/*.spec.js', + 'tests/Unit/assets/editor/**/*.spec.js', + ], }, })