From 0e57640d120ae4ae61b77eac149810b60c264805 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 29 Jul 2026 21:34:37 +0400 Subject: [PATCH 1/3] Refactor: env (#91) * Refactor: env * fix test * app.api_base_url * Remove api_base_url from controller render methods and add it as a global variable in twig configuration * Remove api_token from controller render methods and update spa.html.twig to use app.session for auth_token * Update API base URL configuration in templates and JavaScript --------- Co-authored-by: Tatevik --- assets/vue/api.js | 3 ++- composer.json | 3 ++- config/packages/twig.yaml | 2 ++ config/services.yml | 6 +----- phpunit.xml.dist | 4 ++-- src/Controller/AnalyticsController.php | 2 -- src/Controller/BouncesController.php | 2 -- src/Controller/CampaignsController.php | 6 ------ src/Controller/DashboardController.php | 2 -- src/Controller/ListsController.php | 4 ---- src/Controller/PublicPagesController.php | 6 ------ src/Controller/SettingsController.php | 2 -- src/Controller/SubscribersController.php | 2 -- src/Controller/TemplatesController.php | 6 ------ templates/base.html.twig | 1 + templates/spa.html.twig | 3 +-- .../Controller/AnalyticsControllerTest.php | 4 +++- .../Integration/Controller/BouncesControllerTest.php | 4 +++- .../Controller/CampaignsControllerTest.php | 2 ++ .../Controller/DashboardControllerTest.php | 4 +++- tests/Integration/Controller/ListsControllerTest.php | 4 +++- tests/bootstrap.php | 11 +++++++++++ 22 files changed, 36 insertions(+), 47 deletions(-) create mode 100644 tests/bootstrap.php diff --git a/assets/vue/api.js b/assets/vue/api.js index be71e85..3ac00be 100644 --- a/assets/vue/api.js +++ b/assets/vue/api.js @@ -38,7 +38,8 @@ const redirectToLogin = () => { const appElement = document.getElementById('vue-app'); const apiToken = appElement?.dataset.apiToken; -const apiBaseUrl = appElement?.dataset.apiBaseUrl; +const apiElement = document.getElementById('api-config'); +const apiBaseUrl = apiElement?.dataset.apiBaseUrl; if (!apiBaseUrl) { console.error('API Base URL is not configured.'); diff --git a/composer.json b/composer.json index f46ae4e..38bab7a 100755 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ }, "require": { "php": "^8.1", - "phplist/core": "dev-main", + "phplist/core": "dev-env", "symfony/twig-bundle": "^6.4", "symfony/webpack-encore-bundle": "^2.2", "symfony/security-bundle": "^6.4", @@ -96,6 +96,7 @@ "PhpList\\Core\\Composer\\ScriptHandler::createBundleConfiguration", "PhpList\\Core\\Composer\\ScriptHandler::createRoutesConfiguration", "PhpList\\Core\\Composer\\ScriptHandler::createParametersConfiguration", + "PhpList\\Core\\Composer\\ScriptHandler::createDotenvConfiguration", "PhpList\\Core\\Composer\\ScriptHandler::clearAllCaches" ], "publish-phplist-texts": [ diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 0e4fd73..070f0e2 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -3,3 +3,5 @@ twig: strict_variables: '%kernel.debug%' default_path: '%kernel.application_dir%/templates' auto_reload: true + globals: + api_base_url: '%app.api_base_url%' diff --git a/config/services.yml b/config/services.yml index 76325ac..9be8e4c 100755 --- a/config/services.yml +++ b/config/services.yml @@ -1,8 +1,4 @@ # config/services.yaml -parameters: - api_base_url: '%env(API_BASE_URL)%' - env(API_BASE_URL): 'http://api.phplist.local/' - services: _defaults: autowire: true @@ -27,7 +23,7 @@ services: Symfony\Component\HttpFoundation\Session\SessionInterface: '@session' PhpList\RestApiClient\Client: - $baseUrl: '%api_base_url%' + $baseUrl: '%app.api_base_url%' PhpList\WebFrontend\EventListener\ApiSessionListener: tags: diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4e0d445..692cf4d 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -5,11 +5,11 @@ xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/6.2/phpunit.xsd" backupGlobals="false" colors="true" - bootstrap="vendor/autoload.php" + bootstrap="tests/bootstrap.php" > - + diff --git a/src/Controller/AnalyticsController.php b/src/Controller/AnalyticsController.php index d8486ff..407f256 100644 --- a/src/Controller/AnalyticsController.php +++ b/src/Controller/AnalyticsController.php @@ -17,8 +17,6 @@ public function index(Request $request): Response { return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => 'Analytics', - 'api_token' => $request->getSession()->get('auth_token'), - 'api_base_url' => $this->getParameter('api_base_url'), ]); } } diff --git a/src/Controller/BouncesController.php b/src/Controller/BouncesController.php index 14fb3f2..126db67 100644 --- a/src/Controller/BouncesController.php +++ b/src/Controller/BouncesController.php @@ -23,8 +23,6 @@ public function index(Request $request): Response return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => 'Bounces', - 'api_token' => $request->getSession()->get('auth_token'), - 'api_base_url' => $this->getParameter('api_base_url'), 'bounce_actions' => $bounceActions, ]); } diff --git a/src/Controller/CampaignsController.php b/src/Controller/CampaignsController.php index dbf9d6a..7d21668 100644 --- a/src/Controller/CampaignsController.php +++ b/src/Controller/CampaignsController.php @@ -17,8 +17,6 @@ public function index(Request $request): Response { return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => 'Campaigns', - 'api_token' => $request->getSession()->get('auth_token'), - 'api_base_url' => $this->getParameter('api_base_url'), ]); } @@ -27,8 +25,6 @@ public function create(Request $request): Response { return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => 'Create Campaign', - 'api_token' => $request->getSession()->get('auth_token'), - 'api_base_url' => $this->getParameter('api_base_url'), ]); } @@ -37,8 +33,6 @@ public function edit(Request $request, int $campaignId): Response { return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => sprintf('Edit Campaign #%d', $campaignId), - 'api_token' => $request->getSession()->get('auth_token'), - 'api_base_url' => $this->getParameter('api_base_url'), ]); } } diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php index aea6970..edfb1ef 100755 --- a/src/Controller/DashboardController.php +++ b/src/Controller/DashboardController.php @@ -32,8 +32,6 @@ public function index(Request $request): Response return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => 'Dashboard', - 'api_token' => $request->getSession()->get('auth_token'), - 'api_base_url' => $this->getParameter('api_base_url'), 'dashboard_stats' => $dashboardStats, 'dashboard_error' => $dashboardError, ]); diff --git a/src/Controller/ListsController.php b/src/Controller/ListsController.php index 94e63f7..3dfb1ba 100644 --- a/src/Controller/ListsController.php +++ b/src/Controller/ListsController.php @@ -26,8 +26,6 @@ public function index(Request $request): JsonResponse|Response if (! $wantsJson) { return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => 'Lists', - 'api_token' => $request->getSession()->get('auth_token'), - 'api_base_url' => $this->getParameter('api_base_url'), ]); } $initialData = $this->listClient->getLists(); @@ -40,8 +38,6 @@ public function view(Request $request, int $listId): JsonResponse|Response { return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => 'List Subscribers', - 'api_token' => $request->getSession()->get('auth_token'), - 'api_base_url' => $this->getParameter('api_base_url'), ]); } } diff --git a/src/Controller/PublicPagesController.php b/src/Controller/PublicPagesController.php index 1a6d80c..06064d9 100644 --- a/src/Controller/PublicPagesController.php +++ b/src/Controller/PublicPagesController.php @@ -17,8 +17,6 @@ public function index(Request $request): Response { return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => 'Subscribe Pages', - 'api_token' => $request->getSession()->get('auth_token'), - 'api_base_url' => $this->getParameter('api_base_url'), ]); } @@ -27,8 +25,6 @@ public function create(Request $request): Response { return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => 'Create Subscribe Page', - 'api_token' => $request->getSession()->get('auth_token'), - 'api_base_url' => $this->getParameter('api_base_url'), ]); } @@ -37,8 +33,6 @@ public function edit(Request $request, int $pageId): Response { return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => sprintf('Edit Subscribe Page #%d', $pageId), - 'api_token' => $request->getSession()->get('auth_token'), - 'api_base_url' => $this->getParameter('api_base_url'), ]); } } diff --git a/src/Controller/SettingsController.php b/src/Controller/SettingsController.php index 02d8684..a41bfc1 100644 --- a/src/Controller/SettingsController.php +++ b/src/Controller/SettingsController.php @@ -17,8 +17,6 @@ public function index(Request $request): Response { return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => 'Settings', - 'api_token' => $request->getSession()->get('auth_token'), - 'api_base_url' => $this->getParameter('api_base_url'), ]); } } diff --git a/src/Controller/SubscribersController.php b/src/Controller/SubscribersController.php index 8d542ed..331da62 100644 --- a/src/Controller/SubscribersController.php +++ b/src/Controller/SubscribersController.php @@ -37,8 +37,6 @@ public function index(Request $request): JsonResponse|Response if (! $wantsJson) { return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => 'Subscribers', - 'api_token' => $request->getSession()->get('auth_token'), - 'api_base_url' => $this->getParameter('api_base_url'), ]); } diff --git a/src/Controller/TemplatesController.php b/src/Controller/TemplatesController.php index 5d0cb29..c9a1650 100644 --- a/src/Controller/TemplatesController.php +++ b/src/Controller/TemplatesController.php @@ -17,8 +17,6 @@ public function index(Request $request): Response { return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => 'Templates', - 'api_token' => $request->getSession()->get('auth_token'), - 'api_base_url' => $this->getParameter('api_base_url'), ]); } @@ -27,8 +25,6 @@ public function create(Request $request): Response { return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => 'Create Template', - 'api_token' => $request->getSession()->get('auth_token'), - 'api_base_url' => $this->getParameter('api_base_url'), ]); } @@ -37,8 +33,6 @@ public function edit(Request $request, int $templateId): Response { return $this->render('@PhpListFrontend/spa.html.twig', [ 'page' => sprintf('Edit Template #%d', $templateId), - 'api_token' => $request->getSession()->get('auth_token'), - 'api_base_url' => $this->getParameter('api_base_url'), ]); } } diff --git a/templates/base.html.twig b/templates/base.html.twig index 7274070..43f3a68 100755 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -4,6 +4,7 @@ {% block title %}phpList{% endblock %} + diff --git a/templates/spa.html.twig b/templates/spa.html.twig index a0fda5a..244a6d1 100644 --- a/templates/spa.html.twig +++ b/templates/spa.html.twig @@ -4,8 +4,7 @@ {% block body %}
diff --git a/tests/Integration/Controller/AnalyticsControllerTest.php b/tests/Integration/Controller/AnalyticsControllerTest.php index 5691010..8cea6ef 100644 --- a/tests/Integration/Controller/AnalyticsControllerTest.php +++ b/tests/Integration/Controller/AnalyticsControllerTest.php @@ -7,6 +7,7 @@ use PhpList\WebFrontend\Controller\AnalyticsController; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\Routing\RouterInterface; @@ -27,12 +28,13 @@ public function testAnalyticsPageRendersExpectedSpaPayload(): void self::bootKernel(); /** @var AnalyticsController $controller */ $controller = static::getContainer()->get(AnalyticsController::class); - $apiBaseUrl = (string) static::getContainer()->getParameter('api_base_url'); + $apiBaseUrl = (string) static::getContainer()->getParameter('app.api_base_url'); $request = Request::create('/analytics/'); $session = new Session(new MockArraySessionStorage()); $session->set('auth_token', 'integration-token'); $request->setSession($session); + static::getContainer()->get(RequestStack::class)->push($request); $response = $controller->index($request); $content = (string) $response->getContent(); diff --git a/tests/Integration/Controller/BouncesControllerTest.php b/tests/Integration/Controller/BouncesControllerTest.php index d832cc0..05d4eb7 100644 --- a/tests/Integration/Controller/BouncesControllerTest.php +++ b/tests/Integration/Controller/BouncesControllerTest.php @@ -7,6 +7,7 @@ use PhpList\WebFrontend\Controller\BouncesController; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\Routing\RouterInterface; @@ -27,12 +28,13 @@ public function testBouncesPageRendersExpectedSpaPayload(): void self::bootKernel(); /** @var BouncesController $controller */ $controller = static::getContainer()->get(BouncesController::class); - $apiBaseUrl = (string) static::getContainer()->getParameter('api_base_url'); + $apiBaseUrl = (string) static::getContainer()->getParameter('app.api_base_url'); $request = Request::create('/bounces/'); $session = new Session(new MockArraySessionStorage()); $session->set('auth_token', 'integration-token'); $request->setSession($session); + static::getContainer()->get(RequestStack::class)->push($request); $response = $controller->index($request); $content = (string) $response->getContent(); diff --git a/tests/Integration/Controller/CampaignsControllerTest.php b/tests/Integration/Controller/CampaignsControllerTest.php index d05fa00..8a66684 100644 --- a/tests/Integration/Controller/CampaignsControllerTest.php +++ b/tests/Integration/Controller/CampaignsControllerTest.php @@ -7,6 +7,7 @@ use PhpList\WebFrontend\Controller\CampaignsController; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\Routing\RouterInterface; @@ -41,6 +42,7 @@ public function testCampaignPagesRenderExpectedSpaPayload( $session = new Session(new MockArraySessionStorage()); $session->set('auth_token', 'integration-token'); $request->setSession($session); + static::getContainer()->get(RequestStack::class)->push($request); $response = $controller->{$action}($request, ...$actionArguments); diff --git a/tests/Integration/Controller/DashboardControllerTest.php b/tests/Integration/Controller/DashboardControllerTest.php index a24a786..872ef5a 100644 --- a/tests/Integration/Controller/DashboardControllerTest.php +++ b/tests/Integration/Controller/DashboardControllerTest.php @@ -11,6 +11,7 @@ use PhpList\WebFrontend\Controller\DashboardController; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\Routing\RouterInterface; @@ -29,7 +30,7 @@ public function testDashboardRouteIsRegistered(): void public function testDashboardRendersSpaPayloadWithStats(): void { self::bootKernel(); - $apiBaseUrl = (string) static::getContainer()->getParameter('api_base_url'); + $apiBaseUrl = (string) static::getContainer()->getParameter('app.api_base_url'); $statsClient = $this->createMock(StatisticsClient::class); $statsClient->expects(self::once()) @@ -43,6 +44,7 @@ public function testDashboardRendersSpaPayloadWithStats(): void $session = new Session(new MockArraySessionStorage()); $session->set('auth_token', 'integration-token'); $request->setSession($session); + static::getContainer()->get(RequestStack::class)->push($request); $response = $controller->index($request); $content = (string) $response->getContent(); diff --git a/tests/Integration/Controller/ListsControllerTest.php b/tests/Integration/Controller/ListsControllerTest.php index 801453c..72d997a 100644 --- a/tests/Integration/Controller/ListsControllerTest.php +++ b/tests/Integration/Controller/ListsControllerTest.php @@ -10,6 +10,7 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\Routing\RouterInterface; @@ -31,7 +32,7 @@ public function testListRoutesAreRegistered(string $routeName, array $parameters public function testListsIndexRendersSpaForHtmlRequests(): void { self::bootKernel(); - $apiBaseUrl = (string) static::getContainer()->getParameter('api_base_url'); + $apiBaseUrl = (string) static::getContainer()->getParameter('app.api_base_url'); $listClient = $this->createMock(ListClient::class); $listClient->expects(self::never())->method('getLists'); @@ -106,6 +107,7 @@ private function createRequestWithSession(string $path): Request $session = new Session(new MockArraySessionStorage()); $session->set('auth_token', 'integration-token'); $request->setSession($session); + static::getContainer()->get(RequestStack::class)->push($request); return $request; } diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..8a148f7 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,11 @@ +bootEnv(dirname(__DIR__) . '/.env'); +} From 88c00fca125819b679dbcb79ae5006580d1e5dea Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 29 Jul 2026 21:43:08 +0400 Subject: [PATCH 2/3] use dev branch for core --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 38bab7a..21baad0 100755 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ }, "require": { "php": "^8.1", - "phplist/core": "dev-env", + "phplist/core": "dev-dev", "symfony/twig-bundle": "^6.4", "symfony/webpack-encore-bundle": "^2.2", "symfony/security-bundle": "^6.4", From cbf56ab75e451aa92491f019dd7929626f449bb2 Mon Sep 17 00:00:00 2001 From: Tatevik Date: Wed, 29 Jul 2026 22:24:18 +0400 Subject: [PATCH 3/3] feat: enhance template error handling with field-specific validation messages --- assets/vue/views/TemplateEditView.vue | 62 ++++++++++++++++++- .../vue/views/TemplateEditorView.spec.js | 21 +++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/assets/vue/views/TemplateEditView.vue b/assets/vue/views/TemplateEditView.vue index 2ccac85..1e11dee 100644 --- a/assets/vue/views/TemplateEditView.vue +++ b/assets/vue/views/TemplateEditView.vue @@ -121,7 +121,13 @@
-
+
+

Please fix the following fields:

+
    +
  • {{ errorItem }}
  • +
+
+
{{ saveError }}
@@ -164,6 +170,7 @@ const isLoading = ref(false) const loadError = ref('') const isSaving = ref(false) const saveError = ref('') +const saveErrors = ref([]) const saveSuccess = ref('') const form = ref({ title: '', @@ -240,19 +247,63 @@ const handleFileChange = (event) => { form.value.file = file || null } +const validationFieldLabels = { + title: 'Title', + content: 'Content', + text: 'Text version', + file: 'Template file', + list_order: 'List order', + check_links: 'Check links', + check_images: 'Check images', + check_external_images: 'Check external images' +} + +const normalizeFieldName = (fieldPath = '') => { + if (validationFieldLabels[fieldPath]) return validationFieldLabels[fieldPath] + + const fallback = String(fieldPath) + .split('.') + .pop() + ?.replace(/\[\d+]/g, '') + ?.replace(/_/g, ' ') + ?.replace(/([a-z])([A-Z])/g, '$1 $2') + ?.trim() + + if (!fallback) return 'Field' + return fallback.charAt(0).toUpperCase() + fallback.slice(1) +} + +const formatValidationErrors = (error) => { + const responseData = error?.responseData + const messages = [] + + if (responseData && typeof responseData === 'object' && !Array.isArray(responseData)) { + Object.entries(responseData).forEach(([field, rawMessage]) => { + if (!rawMessage) return + const text = Array.isArray(rawMessage) ? rawMessage.join(' ') : String(rawMessage) + messages.push(`${normalizeFieldName(field)}: ${text}`) + }) + } + + return [...new Set(messages)] +} + const saveTemplate = async () => { if (!isCreateMode.value && (!Number.isFinite(templateId.value) || templateId.value <= 0)) { saveError.value = 'Template ID is invalid.' + saveErrors.value = [] return } if (!form.value.title) { saveError.value = 'Title is required.' + saveErrors.value = [] return } isSaving.value = true saveError.value = '' + saveErrors.value = [] saveSuccess.value = '' try { @@ -292,7 +343,14 @@ const saveTemplate = async () => { saveSuccess.value = 'Template updated successfully.' } catch (error) { console.error('Failed to save template:', error) - saveError.value = error?.message || 'Failed to save template.' + const formattedErrors = formatValidationErrors(error) + if (formattedErrors.length > 0) { + saveErrors.value = formattedErrors + saveError.value = '' + } else { + saveError.value = error?.message || 'Failed to save template.' + saveErrors.value = [] + } } finally { isSaving.value = false } diff --git a/tests/Unit/assets/vue/views/TemplateEditorView.spec.js b/tests/Unit/assets/vue/views/TemplateEditorView.spec.js index 4df6feb..444033c 100644 --- a/tests/Unit/assets/vue/views/TemplateEditorView.spec.js +++ b/tests/Unit/assets/vue/views/TemplateEditorView.spec.js @@ -97,6 +97,27 @@ describe('TemplateEditView', () => { ) }) + it('shows field-specific errors returned by the API', async () => { + vi.spyOn(api.templateClient, 'updateTemplate').mockRejectedValue({ + name: 'ValidationException', + message: 'Validation failed', + responseData: { + title: ['This value is too long.'], + list_order: ['This value should be a valid number.'], + }, + }) + + const wrapper = await mountComponent() + + await wrapper.find('#template-title').setValue('Updated Template') + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(wrapper.text()).toContain('Title: This value is too long.') + expect(wrapper.text()).toContain('List order: This value should be a valid number.') + expect(wrapper.text()).not.toContain('Validation failed') + }) + it('creates a template in create mode', async () => { mockRoute.name = 'template-create' mockRoute.params.templateId = ''