Skip to content

Commit 244be0d

Browse files
TatevikGrtatevikg1github-actions
authored
release: dev → main (settings, analytics, and editor enhancements) (#86)
* dev * Update openapi.json from web frontend workflow 2026-06-10T11:45:36Z * Subscribe page (#87) New Features Public "Subscribe Pages" area: directory, editor, create/edit views, and public subscribe/unsubscribe pages with customizable forms and templates Language translation support and a publish flow for phpList texts Bug Fixes / UI Consistent checkbox accent colors and updated border styling for action and pagination buttons Safer login redirects that validate in-site targets Tests Expanded unit, component and integration (Panther) test coverage for UI and public routes --------- Co-authored-by: Tatevik <tatevikg1@gmail.com> Co-authored-by: github-actions <github-actions@web-frontend.workflow> * Update openapi.json from web frontend workflow 2026-06-16T06:14:10Z * Update openapi.json from web frontend workflow 2026-06-16T14:47:35Z * Update openapi.json from web frontend workflow 2026-06-26T10:20:04Z * Feat: Settings page (#88) * Feat: settings page * Settings page with tabs * Feat: add ConfigClient for configuration management * Feat: admin configs * RedirectValidationTrait * Feat: enhance dashboard error handling and display * Feat: show statistics only to authorized admins * Feat: admin attributes configs * After review 0 * SettingsSubscriberAttributes * Feat: EditSubscriberAttributeModal with options * Fix: client --------- Co-authored-by: Tatevik <tatevikg1@gmail.com> * Feat: Analytics (#89) * Feat: statistics * Feat: statistics * After review 0 * Add tests * Add tests 1 * Fix workflow file, add more tests --------- Co-authored-by: Tatevik <tatevikg1@gmail.com> * Update openapi.json from web frontend workflow 2026-07-13T08:39:38Z * Update openapi.json from web frontend workflow 2026-07-13T09:59:12Z * Update openapi.json from web frontend workflow 2026-07-13T12:15:36Z * Update openapi.json from web frontend workflow 2026-07-15T12:08:42Z * Update openapi.json from web frontend workflow 2026-07-21T10:06:49Z * Ckeditor (#90) * Ckeditor * Integrate CKEditor 5 editor uploads * Add richer CKEditor asset browser * Fix: AuthenticationException handling * init * fix * esc key event * no assets found message * set max upload file size to 10 MB * introduce UpstreamServiceException for handling API errors and update response status codes * ApiExceptionSubscriber * sort uploaded assets by modification date to display newest first * sort uploaded assets by modification date to display newest first * make campagn edit responsive * update rest-api-client dependency to version 2.1.16 and adjust uploadsClient method call --------- Co-authored-by: Tatevik <tatevikg1@gmail.com> * use dev-main branch for core and rest-api-client --------- Co-authored-by: Tatevik <tatevikg1@gmail.com> Co-authored-by: github-actions <github-actions@web-frontend.workflow>
1 parent d75af71 commit 244be0d

173 files changed

Lines changed: 19866 additions & 310 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ jobs:
5656
run: |
5757
curl -sSL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor | sudo tee /usr/share/keyrings/google.gpg > /dev/null
5858
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google-chrome.list
59-
sudo apt-get update
59+
sudo apt-get update --allow-releaseinfo-change-label
6060
sudo apt-get install -y google-chrome-stable
6161
sudo apt install socat
6262
@@ -87,7 +87,7 @@ jobs:
8787
- name: Run PHPMD
8888
run: vendor/bin/phpmd src/ text vendor/phplist/core/config/PHPMD/rules.xml
8989
- name: Run PHP_CodeSniffer
90-
run: vendor/bin/phpcs --standard=vendor/phplist/core/config/PhpCodeSniffer/ src/ tests/
90+
run: vendor/bin/phpcs --ignore=tests/Unit/assets --standard=vendor/phplist/core/config/PhpCodeSniffer/ src/ tests/
9191

9292
- name: Install Prism
9393
run: npm install -g @stoplight/prism-cli
@@ -145,6 +145,9 @@ jobs:
145145
- name: Run tests with phpunit
146146
run: vendor/bin/phpunit tests
147147

148+
- name: Run tests with vitest
149+
run: node node_modules/vitest/vitest.mjs --run
150+
148151
- name: Upload Panther screenshots
149152
if: failure()
150153
uses: actions/upload-artifact@v4

.husky/pre-commit

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,7 @@ echo "📏 Running PHPMD..."
88
php vendor/bin/phpmd src/ text vendor/phplist/core/config/PHPMD/rules.xml || exit 1
99

1010
echo "🧹 Running PHPCS..."
11-
php vendor/bin/phpcs --standard=vendor/phplist/core/config/PhpCodeSniffer/ src/ tests/ || exit 1
11+
php vendor/bin/phpcs \
12+
--standard=vendor/phplist/core/config/PhpCodeSniffer/ \
13+
--ignore=tests/Unit/assets \
14+
src/ tests/ || exit 1

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,56 @@ To add new Vue components:
6767
1. Create the component in the `assets/vue/` directory
6868
2. Import and mount it in `assets/app.js`
6969
3. Add a mount point in the appropriate template
70+
71+
## CKEditor 5 Integration
72+
73+
The rich text editor lives in `assets/editor/` and is exposed to the existing forms through `assets/vue/components/base/CkEditorField.vue`.
74+
75+
### Architecture
76+
77+
- `assets/editor/CkEditor.vue` is the reusable Vue 3 editor component.
78+
- `assets/editor/uploadAdapter.ts` provides CKEditor upload support against a Symfony endpoint.
79+
- `assets/editor/assetBrowserPlugin.js` adds a toolbar button that opens the native asset browser.
80+
- `assets/editor/EditorAssetPicker.vue` shows existing uploaded files and inserts them as images or links.
81+
- `assets/editor/plugins.ts` and `assets/editor/toolbar.ts` keep the editor configuration isolated and reusable.
82+
- `src/Service/EditorUploadService.php` stores uploads in the public filesystem and returns a browser URL.
83+
- `src/Controller/EditorUploadController.php` accepts authenticated uploads and lists existing assets.
84+
85+
### Reused from the legacy plugin
86+
87+
- upload path conventions
88+
- image validation rules
89+
- public-file URL generation strategy
90+
- file storage separation for editor content
91+
- asset browsing behavior, expressed as a native Vue modal instead of a popup window
92+
93+
### New behavior
94+
95+
- Vue component with `v-model`
96+
- configurable toolbar and CKEditor options
97+
- upload adapter with progress support
98+
- cleanup on unmount
99+
- read-only mode support
100+
- existing file browser for reusing uploaded content
101+
102+
### Build and test
103+
104+
```bash
105+
yarn encore dev
106+
yarn test:vue
107+
vendor/bin/phpunit
108+
```
109+
110+
### Configuration
111+
112+
The backend uses the existing phpList upload directory parameters:
113+
114+
- `phplist.upload_images_dir`
115+
116+
Uploads are stored below `public/<phplist.upload_images_dir>/ckeditor5/`.
117+
118+
### Limitations
119+
120+
- This integration only covers image uploads from CKEditor.
121+
- elFinder is not embedded in the frontend UI; the frontend now provides a native asset browser instead.
122+
- Existing legacy HTML content is preserved as-is, but custom HTML support still depends on CKEditor 5 output rules.

assets/editor/CkEditor.vue

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
<template>
2+
<ckeditor
3+
:id="fieldId"
4+
v-model="localValue"
5+
:editor="ClassicEditor"
6+
:config="editorConfig"
7+
:disabled="isDisabled"
8+
:style="editorStyle"
9+
@ready="handleReady"
10+
/>
11+
12+
<EditorAssetPicker
13+
:open="assetPickerOpen"
14+
:items="assetItems"
15+
:query="assetQuery"
16+
:loading="assetLoading"
17+
:error="assetError"
18+
@close="closeAssetPicker"
19+
@refresh="loadAssets"
20+
@select="insertAsset"
21+
@update:query="assetQuery = $event"
22+
/>
23+
</template>
24+
25+
<script setup>
26+
import { computed, onBeforeUnmount, ref, shallowRef, useId, watch } from 'vue';
27+
import { Ckeditor } from '@ckeditor/ckeditor5-vue';
28+
import { ClassicEditor } from 'ckeditor5';
29+
30+
import { DEFAULT_PLUGINS } from './plugins.ts';
31+
import { DEFAULT_IMAGE_TOOLBAR, DEFAULT_TOOLBAR } from './toolbar.ts';
32+
import EditorUploadAdapter from './uploadAdapter.ts';
33+
import EditorAssetPicker from './EditorAssetPicker.vue';
34+
35+
import 'ckeditor5/ckeditor5.css';
36+
37+
const props = defineProps({
38+
modelValue: {
39+
type: String,
40+
default: '',
41+
},
42+
id: {
43+
type: String,
44+
default: '',
45+
},
46+
readonly: {
47+
type: Boolean,
48+
default: false,
49+
},
50+
disabled: {
51+
type: Boolean,
52+
default: false,
53+
},
54+
minHeight: {
55+
type: [Number, String],
56+
default: 300,
57+
},
58+
uploadEndpoint: {
59+
type: String,
60+
default: '/editor/upload',
61+
},
62+
assetsEndpoint: {
63+
type: String,
64+
default: '/editor/assets',
65+
},
66+
uploadHeaders: {
67+
type: Object,
68+
default: () => ({}),
69+
},
70+
withCredentials: {
71+
type: Boolean,
72+
default: true,
73+
},
74+
toolbar: {
75+
type: Array,
76+
default: null,
77+
},
78+
plugins: {
79+
type: Array,
80+
default: () => [],
81+
},
82+
config: {
83+
type: Object,
84+
default: () => ({}),
85+
},
86+
});
87+
88+
const emit = defineEmits(['update:modelValue', 'ready', 'error']);
89+
90+
const generatedId = useId();
91+
const fieldId = computed(() => props.id || `ckeditor-${generatedId}`);
92+
93+
const localValue = computed({
94+
get: () => props.modelValue,
95+
set: (value) => emit('update:modelValue', value),
96+
});
97+
98+
const editorRef = shallowRef(null);
99+
const assetPickerOpen = ref(false);
100+
const assetLoading = ref(false);
101+
const assetError = ref('');
102+
const assetItems = ref([]);
103+
const assetQuery = ref('');
104+
105+
const isDisabled = computed(() => props.disabled || props.readonly);
106+
const editorStyle = computed(() => ({
107+
'--editor-min-height': typeof props.minHeight === 'number'
108+
? `${props.minHeight}px`
109+
: String(props.minHeight),
110+
}));
111+
112+
const editorConfig = computed(() => {
113+
const config = props.config || {};
114+
const extraPlugins = Array.isArray(config.plugins) ? config.plugins : [];
115+
const toolbar = Array.isArray(props.toolbar) && props.toolbar.length > 0
116+
? props.toolbar
117+
: Array.isArray(config.toolbar) && config.toolbar.length > 0
118+
? config.toolbar
119+
: DEFAULT_TOOLBAR;
120+
121+
const imageConfig = {
122+
toolbar: DEFAULT_IMAGE_TOOLBAR,
123+
...(config.image || {}),
124+
};
125+
const htmlSupportConfig = config.htmlSupport || {};
126+
const htmlSupport = {
127+
allow: htmlSupportConfig.allow || [
128+
{
129+
name: /.*/,
130+
attributes: true,
131+
classes: true,
132+
styles: true,
133+
},
134+
],
135+
disallow: [
136+
{ name: 'script' },
137+
{ name: 'iframe' },
138+
{ name: /.*/, attributes: { key: /^on.*/ } },
139+
...(Array.isArray(htmlSupportConfig.disallow) ? htmlSupportConfig.disallow : []),
140+
],
141+
};
142+
143+
return {
144+
...config,
145+
licenseKey: config.licenseKey || 'GPL',
146+
plugins: [
147+
...DEFAULT_PLUGINS,
148+
...(Array.isArray(props.plugins) ? props.plugins : []),
149+
...extraPlugins,
150+
],
151+
toolbar,
152+
image: imageConfig,
153+
htmlSupport,
154+
openAssetPicker: openAssetPicker,
155+
};
156+
});
157+
158+
const installUploadAdapter = (editor) => {
159+
const fileRepository = editor.plugins.get('FileRepository');
160+
161+
fileRepository.createUploadAdapter = (loader) => new EditorUploadAdapter(loader, {
162+
endpoint: props.uploadEndpoint,
163+
headers: props.uploadHeaders,
164+
withCredentials: props.withCredentials,
165+
});
166+
};
167+
168+
const syncReadOnlyState = (editor) => {
169+
if (props.readonly) {
170+
editor.enableReadOnlyMode('ckeditor-field');
171+
return;
172+
}
173+
174+
editor.disableReadOnlyMode('ckeditor-field');
175+
};
176+
177+
const loadAssets = async () => {
178+
assetLoading.value = true;
179+
assetError.value = '';
180+
181+
try {
182+
const response = await fetch(props.assetsEndpoint, {
183+
headers: {
184+
'X-Requested-With': 'XMLHttpRequest',
185+
},
186+
credentials: props.withCredentials ? 'include' : 'same-origin',
187+
});
188+
189+
if (!response.ok) {
190+
throw new Error(`Failed to load assets (${response.status})`);
191+
}
192+
193+
const payload = await response.json();
194+
assetItems.value = Array.isArray(payload?.items) ? payload.items : [];
195+
} catch (error) {
196+
assetError.value = error?.message || 'Failed to load assets.';
197+
} finally {
198+
assetLoading.value = false;
199+
}
200+
};
201+
202+
const openAssetPicker = async () => {
203+
assetPickerOpen.value = true;
204+
205+
if (assetItems.value.length === 0 && !assetLoading.value) {
206+
await loadAssets();
207+
}
208+
};
209+
210+
const closeAssetPicker = () => {
211+
assetPickerOpen.value = false;
212+
};
213+
214+
const getSelectedEditor = () => editorRef.value;
215+
216+
const insertAsset = (asset) => {
217+
if (!asset) {
218+
return;
219+
}
220+
221+
const editor = getSelectedEditor();
222+
if (!editor) {
223+
return;
224+
}
225+
226+
editor.model.change((writer) => {
227+
if (asset.isImage) {
228+
const imageElement = writer.createElement('imageBlock', {
229+
src: asset.url,
230+
alt: asset.fileName,
231+
});
232+
editor.model.insertContent(imageElement, editor.model.document.selection);
233+
return;
234+
}
235+
236+
const linkText = asset.fileName || asset.url;
237+
const textNode = writer.createText(linkText, { linkHref: asset.url });
238+
editor.model.insertContent(textNode, editor.model.document.selection);
239+
});
240+
241+
closeAssetPicker();
242+
};
243+
244+
const handleReady = (editor) => {
245+
editorRef.value = editor;
246+
installUploadAdapter(editor);
247+
syncReadOnlyState(editor);
248+
emit('ready', editor);
249+
};
250+
251+
watch(
252+
() => props.readonly,
253+
() => {
254+
if (!editorRef.value) {
255+
return;
256+
}
257+
258+
syncReadOnlyState(editorRef.value);
259+
}
260+
);
261+
262+
onBeforeUnmount(() => {
263+
if (!editorRef.value) {
264+
return;
265+
}
266+
267+
const editor = editorRef.value;
268+
editorRef.value = null;
269+
editor.destroy();
270+
});
271+
</script>
272+
273+
<style scoped>
274+
:deep(.ck-editor__editable_inline) {
275+
min-height: var(--editor-min-height, 300px);
276+
overflow-y: auto;
277+
}
278+
</style>

0 commit comments

Comments
 (0)