Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
API_BASE_URL=http://127.0.0.1:4010/
74 changes: 55 additions & 19 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,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
Expand All @@ -47,10 +60,11 @@ jobs:
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
echo "PANTHER_ERROR_SCREENSHOT_DIR=./var/screenshots" >> .env.test

- 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)"
Expand All @@ -60,21 +74,43 @@ 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 ./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 TCP-LISTEN:80,fork TCP:127.0.0.1:4010 &

- name: Run tests with phpunit
env:
PANTHER_NO_HEADLESS: 0
PANTHER_CHROME_BINARY: /usr/bin/google-chrome
PANTHER_WEB_SERVER_ROUTER: public/app.php
PANTHER_ERROR_SCREENSHOT_DIR: ./var/screenshots
run: vendor/bin/phpunit tests

- name: Upload Panther screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: panther-screenshots
path: var/screenshots
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ Please install this package via Composer from within the
[phpList base distribution](https://github.com/phpList/base-distribution),
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

Expand Down
5 changes: 5 additions & 0 deletions assets/router/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ 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(),
routes: [
{ 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: '/' },
],
Expand Down
38 changes: 37 additions & 1 deletion assets/vue/api.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
33 changes: 33 additions & 0 deletions assets/vue/components/base/BaseBadge.spec.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
47 changes: 47 additions & 0 deletions assets/vue/components/base/BaseButton.spec.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
36 changes: 36 additions & 0 deletions assets/vue/components/base/BaseIcon.spec.js
Original file line number Diff line number Diff line change
@@ -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('<svg')
expect(wrapper.html()).toContain('lucide-users')
})

it('renders empty icon content for unknown icon name', () => {
const wrapper = mount(BaseIcon, {
props: {
name: 'does-not-exist',
},
})

expect(wrapper.html()).not.toContain('<svg')
})

it('uses active color class when active', () => {
const wrapper = mount(BaseIcon, {
props: {
name: 'users',
active: true,
},
})

expect(wrapper.get('span').classes()).toContain('text-ext-wf3')
})
})
32 changes: 32 additions & 0 deletions assets/vue/components/base/BaseProgressBar.spec.js
Original file line number Diff line number Diff line change
@@ -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;')
})
})
Loading
Loading