diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e5f449..77bcc3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,8 @@ jobs: DB_USERNAME: root DB_PASSWORD: phplist BROADCAST_DRIVER: log + API_BASE_URL: http://api.phplist.local/ + REST_API_BASE_URL: http://api.phplist.local/api/v2 services: mysql: image: mysql:5.7 @@ -31,7 +33,20 @@ jobs: with: php-version: ${{ matrix.php-versions }} extensions: mbstring, dom, fileinfo, mysql - coverage: xdebug #optional + coverage: xdebug + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + + - name: Install JS dependencies + run: yarn install --frozen-lockfile + + - name: Build frontend assets + run: yarn encore dev + - name: Install Symfony CLI run: | curl -sS https://get.symfony.com/cli/installer | bash @@ -43,14 +58,11 @@ jobs: 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 sudo apt-get update sudo apt-get install -y google-chrome-stable - - name: Set Panther to use Chrome - run: | - echo "PANTHER_NO_HEADLESS=0" >> .env.test - echo "PANTHER_CHROME_BINARY=/usr/bin/google-chrome" >> .env.test - - name: Start mysql service - run: sudo /etc/init.d/mysql start + sudo apt install socat + - name: Verify MySQL connection on host run: mysql --host 127.0.0.1 --port ${{ job.services.mysql.ports['3306'] }} -u${{ env.DB_USERNAME }} -p${{ env.DB_PASSWORD }} -e "SHOW DATABASES" + - name: Get composer cache directory id: composer-cache run: echo "::set-output name=dir::$(composer config cache-files-dir)" @@ -60,21 +72,82 @@ jobs: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: ${{ runner.os }}-composer- - - name: Install the latest dependencies - run: composer install + - name: Install PHP dependencies + run: composer install --no-interaction --prefer-dist + - name: Set up database schema run: mysql --host 127.0.0.1 --port ${{ job.services.mysql.ports['3306'] }} -u${{ env.DB_USERNAME }} -p${{ env.DB_PASSWORD }} ${{ env.DB_DATABASE }} < vendor/phplist/core/resources/Database/Schema.sql - - name: Validating composer.json - run: composer validate --no-check-all --no-check-lock --strict; - - name: Linting all php files - run: find src/ tests/ public/ -name ''*.php'' -print0 | xargs -0 -n 1 -P 4 php -l; php -l; - - name: Running integration tests with phpunit - run: vendor/bin/phpunit tests/Integration/; - - name: Running the system tests - run: vendor/bin/phpunit tests/System/; - - name: Running static analysis - run: vendor/bin/phpstan analyse -l 5 src/ tests/; - - name: Running PHPMD - run: vendor/bin/phpmd src/ text vendor/phplist/core/config/PHPMD/rules.xml; - - name: Running PHP_CodeSniffer - run: vendor/bin/phpcs --standard=vendor/phplist/core/config/PhpCodeSniffer/ src/ tests/; + - name: Validate composer.json + run: composer validate --no-check-all --no-check-lock --strict + - name: Lint PHP files + run: find src/ tests/ public/ -name '*.php' -print0 | xargs -0 -n 1 -P 4 php -l + + - name: Run static analysis + run: vendor/bin/phpstan analyse -l 5 src/ tests/ + - name: Run PHPMD + run: vendor/bin/phpmd src/ text vendor/phplist/core/config/PHPMD/rules.xml + - name: Run PHP_CodeSniffer + run: vendor/bin/phpcs --standard=vendor/phplist/core/config/PhpCodeSniffer/ src/ tests/ + + - name: Install Prism + run: npm install -g @stoplight/prism-cli + - name: Start Prism Mock Server + run: | + prism mock --host 127.0.0.1 --port 4010 ./openapi.json & + - name: Add local hostname + run: echo "127.0.0.1 api.phplist.local" | sudo tee -a /etc/hosts + - name: Proxy port 80 to 4010 + run: | + sudo socat -d -d TCP-LISTEN:80,reuseaddr,fork TCP:127.0.0.1:4010 & + - name: Wait for Prism and proxy + run: | + set -euo pipefail + prism_ready=0 + for i in $(seq 1 30); do + if curl -sS -o /tmp/prism-health-body.txt -w "%{http_code}" \ + -H 'Content-Type: application/json' \ + -X POST http://127.0.0.1:4010/api/v2/sessions \ + --data '{"login_name":"healthcheck","password":"healthcheck"}' > /tmp/prism-health-code.txt; then + code=$(cat /tmp/prism-health-code.txt) + if [ "$code" != "000" ]; then + echo "Prism is reachable on 127.0.0.1:4010 with HTTP ${code}" + prism_ready=1 + break + fi + fi + sleep 1 + done + if [ "$prism_ready" -ne 1 ]; then + echo "Prism did not become reachable in time." + exit 1 + fi + + proxy_ready=0 + for i in $(seq 1 30); do + if curl -sS -o /tmp/proxy-health-body.txt -w "%{http_code}" \ + -H 'Content-Type: application/json' \ + -X POST http://api.phplist.local/api/v2/sessions \ + --data '{"login_name":"healthcheck","password":"healthcheck"}' > /tmp/proxy-health-code.txt; then + code=$(cat /tmp/proxy-health-code.txt) + if [ "$code" != "000" ]; then + echo "Proxy is reachable on api.phplist.local with HTTP ${code}" + proxy_ready=1 + break + fi + fi + sleep 1 + done + if [ "$proxy_ready" -ne 1 ]; then + echo "Proxy did not become reachable in time." + exit 1 + fi + + - name: Run tests with phpunit + run: vendor/bin/phpunit tests + + - name: Upload Panther screenshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: panther-screenshots + path: var/screenshots diff --git a/README.md b/README.md index f7a88b2..185b65d 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,14 @@ Please install this package via Composer from within the which also has more detailed installation instructions in the README. +### When this module is installed as a dependency, publish bundle assets to the host application's `public/` directory: +```bash +php bin/console assets:install public --symlink --relative +``` + +This module serves its frontend files from `/`. + + ## Contributing to this package Please read the [contribution guide](.github/CONTRIBUTING.md) on how to diff --git a/assets/router/index.js b/assets/router/index.js index 231642c..9cdbb48 100644 --- a/assets/router/index.js +++ b/assets/router/index.js @@ -3,6 +3,8 @@ import DashboardView from '../vue/views/DashboardView.vue' import SubscribersView from '../vue/views/SubscribersView.vue' import ListsView from '../vue/views/ListsView.vue' import ListSubscribersView from '../vue/views/ListSubscribersView.vue' +import CampaignsView from '../vue/views/CampaignsView.vue' +import CampaignEditView from '../vue/views/CampaignEditView.vue' export const router = createRouter({ history: createWebHistory(), @@ -10,6 +12,9 @@ export const router = createRouter({ { path: '/', name: 'dashboard', component: DashboardView, meta: { title: 'Dashboard' } }, { path: '/subscribers', name: 'subscribers', component: SubscribersView, meta: { title: 'Subscribers' } }, { path: '/lists', name: 'lists', component: ListsView, meta: { title: 'Lists' } }, + { path: '/campaigns', name: 'campaigns', component: CampaignsView, meta: { title: 'Campaigns' } }, + { path: '/campaigns/create', name: 'campaign-create', component: CampaignEditView, meta: { title: 'Create Campaign' } }, + { path: '/campaigns/:campaignId/edit', name: 'campaign-edit', component: CampaignEditView, meta: { title: 'Edit Campaign' } }, { path: '/lists/:listId/subscribers', name: 'list-subscribers', component: ListSubscribersView, meta: { title: 'List Subscribers' } }, { path: '/:pathMatch(.*)*', redirect: '/' }, ], diff --git a/assets/vue/api.js b/assets/vue/api.js index 95c3cbc..7cc9e77 100644 --- a/assets/vue/api.js +++ b/assets/vue/api.js @@ -1,4 +1,14 @@ -import {Client, ListClient, SubscribersClient, SubscriptionClient, SubscriberAttributesClient} from '@tatevikgr/rest-api-client'; +import { + CampaignClient, + Client, + ListMessagesClient, + ListClient, + StatisticsClient, + SubscribersClient, + SubscriptionClient, + SubscriberAttributesClient, + TemplatesClient +} from '@tatevikgr/rest-api-client'; const appElement = document.getElementById('vue-app'); const apiToken = appElement?.dataset.apiToken; @@ -16,7 +26,33 @@ if (apiToken) { export const subscribersClient = new SubscribersClient(client); export const listClient = new ListClient(client); +export const campaignClient = new CampaignClient(client); +export const listMessagesClient = new ListMessagesClient(client); +export const statisticsClient = new StatisticsClient(client); export const subscriptionClient = new SubscriptionClient(client); export const subscriberAttributesClient = new SubscriberAttributesClient(client); +export const templateClient = new TemplatesClient(client); + +export const fetchAllLists = async ({ limit = 100, maxPages = 100 } = {}) => { + const lists = []; + let afterId = null; + + for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { + const response = await listClient.getLists(afterId, limit); + const items = Array.isArray(response?.items) ? response.items : []; + lists.push(...items); + + const hasMore = response?.pagination?.hasMore === true; + const nextCursor = response?.pagination?.nextCursor; + + if (!hasMore || !Number.isFinite(nextCursor) || nextCursor === afterId) { + break; + } + + afterId = nextCursor; + } + + return lists; +}; export default client; diff --git a/assets/vue/components/base/BaseBadge.spec.js b/assets/vue/components/base/BaseBadge.spec.js new file mode 100644 index 0000000..df0507a --- /dev/null +++ b/assets/vue/components/base/BaseBadge.spec.js @@ -0,0 +1,33 @@ +import { mount } from '@vue/test-utils' +import BaseBadge from './BaseBadge.vue' + +describe('BaseBadge', () => { + it('renders neutral variant by default', () => { + const wrapper = mount(BaseBadge, { + slots: { + default: 'All', + }, + }) + + const classes = wrapper.get('span').classes() + expect(wrapper.text()).toContain('All') + expect(classes).toContain('bg-gray-100') + expect(classes).toContain('text-gray-800') + }) + + it('renders counter variant styles', () => { + const wrapper = mount(BaseBadge, { + props: { + variant: 'counter', + }, + slots: { + default: '10', + }, + }) + + const classes = wrapper.get('span').classes() + expect(classes).toContain('bg-indigo-50') + expect(classes).toContain('text-ext-wf3') + expect(wrapper.text()).toContain('10') + }) +}) diff --git a/assets/vue/components/base/BaseButton.spec.js b/assets/vue/components/base/BaseButton.spec.js new file mode 100644 index 0000000..5a0106b --- /dev/null +++ b/assets/vue/components/base/BaseButton.spec.js @@ -0,0 +1,47 @@ +import { mount } from '@vue/test-utils' +import BaseButton from './BaseButton.vue' + +describe('BaseButton', () => { + it('renders slot content', () => { + const wrapper = mount(BaseButton, { + slots: { + default: 'Save', + }, + }) + + expect(wrapper.text()).toContain('Save') + }) + + it('uses primary styles by default', () => { + const wrapper = mount(BaseButton) + const classes = wrapper.get('button').classes() + + expect(classes).toContain('text-white') + expect(classes).toContain('bg-blue-600') + }) + + it('uses secondary styles when variant is secondary', () => { + const wrapper = mount(BaseButton, { + props: { + variant: 'secondary', + }, + }) + + const classes = wrapper.get('button').classes() + expect(classes).toContain('text-gray-700') + expect(classes).toContain('bg-white') + }) + + it('forwards attributes to button', () => { + const wrapper = mount(BaseButton, { + attrs: { + disabled: true, + 'data-testid': 'submit-button', + }, + }) + + const button = wrapper.get('button') + expect(button.attributes('disabled')).toBeDefined() + expect(button.attributes('data-testid')).toBe('submit-button') + }) +}) diff --git a/assets/vue/components/base/BaseIcon.spec.js b/assets/vue/components/base/BaseIcon.spec.js new file mode 100644 index 0000000..047d803 --- /dev/null +++ b/assets/vue/components/base/BaseIcon.spec.js @@ -0,0 +1,36 @@ +import { mount } from '@vue/test-utils' +import BaseIcon from './BaseIcon.vue' + +describe('BaseIcon', () => { + it('renders icon svg for known icon name', () => { + const wrapper = mount(BaseIcon, { + props: { + name: 'users', + }, + }) + + expect(wrapper.html()).toContain(' { + const wrapper = mount(BaseIcon, { + props: { + name: 'does-not-exist', + }, + }) + + expect(wrapper.html()).not.toContain(' { + const wrapper = mount(BaseIcon, { + props: { + name: 'users', + active: true, + }, + }) + + expect(wrapper.get('span').classes()).toContain('text-ext-wf3') + }) +}) diff --git a/assets/vue/components/base/BaseProgressBar.spec.js b/assets/vue/components/base/BaseProgressBar.spec.js new file mode 100644 index 0000000..bc4a6fd --- /dev/null +++ b/assets/vue/components/base/BaseProgressBar.spec.js @@ -0,0 +1,32 @@ +import { mount } from '@vue/test-utils' +import BaseProgressBar from './BaseProgressBar.vue' + +describe('BaseProgressBar', () => { + it('applies default height and progress attributes', () => { + const wrapper = mount(BaseProgressBar, { + props: { + value: 35, + }, + }) + + const wrapperStyle = wrapper.get('.progress').attributes('style') + const bar = wrapper.get('[role="progressbar"]') + + expect(wrapperStyle).toContain('height: 6px;') + expect(bar.attributes('style')).toContain('width: 35%;') + expect(bar.attributes('aria-valuenow')).toBe('35') + expect(bar.attributes('aria-valuemin')).toBe('0') + expect(bar.attributes('aria-valuemax')).toBe('100') + }) + + it('uses custom height when provided', () => { + const wrapper = mount(BaseProgressBar, { + props: { + value: 80, + height: '10px', + }, + }) + + expect(wrapper.get('.progress').attributes('style')).toContain('height: 10px;') + }) +}) diff --git a/assets/vue/components/base/CkEditorField.vue b/assets/vue/components/base/CkEditorField.vue new file mode 100644 index 0000000..9a56ad6 --- /dev/null +++ b/assets/vue/components/base/CkEditorField.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/assets/vue/components/campaigns/CampaignDirectory.vue b/assets/vue/components/campaigns/CampaignDirectory.vue new file mode 100644 index 0000000..7683a3c --- /dev/null +++ b/assets/vue/components/campaigns/CampaignDirectory.vue @@ -0,0 +1,961 @@ + + + diff --git a/assets/vue/components/campaigns/ViewCampaignModal.vue b/assets/vue/components/campaigns/ViewCampaignModal.vue new file mode 100644 index 0000000..f74cfb2 --- /dev/null +++ b/assets/vue/components/campaigns/ViewCampaignModal.vue @@ -0,0 +1,217 @@ + + + diff --git a/assets/vue/components/lists/ListDirectory.vue b/assets/vue/components/lists/ListDirectory.vue index 5c94021..5bf294b 100644 --- a/assets/vue/components/lists/ListDirectory.vue +++ b/assets/vue/components/lists/ListDirectory.vue @@ -232,7 +232,7 @@ import BaseIcon from '../base/BaseIcon.vue' import CreateListModal from './CreateListModal.vue' import EditListModal from './EditListModal.vue' import AddSubscribersModal from './AddSubscribersModal.vue' -import { listClient } from '../../api' +import { fetchAllLists, listClient } from '../../api' const router = useRouter() @@ -245,44 +245,11 @@ const isEditModalOpen = ref(false) const isAddSubscribersModalOpen = ref(false) const fetchMailingLists = async () => { - const url = new URL('/lists', window.location.origin) - isLoading.value = true loadError.value = '' try { - const response = await fetch(url, { - headers: { - Accept: 'application/json', - 'X-Requested-With': 'XMLHttpRequest' - } - }) - - const contentType = response.headers.get('content-type') || '' - - if (response.status === 401) { - if (contentType.includes('application/json')) { - const data = await response.json() - - if (data?.redirect) { - window.location.href = data.redirect - return - } - } - - throw new Error('Authentication required. Please sign in again.') - } - - if (!response.ok) { - throw new Error(`Failed to load mailing lists (${response.status}).`) - } - - if (!contentType.includes('application/json')) { - throw new Error('Server returned an unexpected response format.') - } - - const data = await response.json() - mailingLists.value = Array.isArray(data?.items) ? data.items : [] + mailingLists.value = await fetchAllLists() } catch (error) { console.error('Failed to fetch mailing lists:', error) mailingLists.value = [] diff --git a/assets/vue/components/sidebar/AppSidebar.vue b/assets/vue/components/sidebar/AppSidebar.vue index 773879c..ca607e5 100644 --- a/assets/vue/components/sidebar/AppSidebar.vue +++ b/assets/vue/components/sidebar/AppSidebar.vue @@ -8,7 +8,7 @@ >