diff --git a/assets/router/index.js b/assets/router/index.js index 0ca5d30..702e426 100644 --- a/assets/router/index.js +++ b/assets/router/index.js @@ -8,6 +8,7 @@ import CampaignEditView from '../vue/views/CampaignEditView.vue' import TemplatesView from '../vue/views/TemplatesView.vue' import TemplateEditView from '../vue/views/TemplateEditView.vue' import BouncesView from '../vue/views/BouncesView.vue' +import AnalyticsView from '../vue/views/AnalyticsView.vue' import PublicPagesView from '../vue/views/PublicPagesView.vue' import PublicPageEditView from '../vue/views/PublicPageEditView.vue' import SettingsView from '../vue/views/SettingsView.vue' @@ -26,6 +27,7 @@ export const router = createRouter({ { 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: '/bounces', name: 'bounces', component: BouncesView, meta: { title: 'Bounces' } }, + { path: '/analytics', name: 'analytics', component: AnalyticsView, meta: { title: 'Analytics' } }, { path: '/public', name: 'public-pages', component: PublicPagesView, meta: { title: 'Public Pages' } }, { path: '/public/create', name: 'public-page-create', component: PublicPageEditView, meta: { title: 'Create Public Page' } }, { path: '/public/:pageId/edit', name: 'public-page-edit', component: PublicPageEditView, meta: { title: 'Edit Public Page' } }, diff --git a/assets/vue/api.js b/assets/vue/api.js index 31d22f8..f4e44ba 100644 --- a/assets/vue/api.js +++ b/assets/vue/api.js @@ -11,7 +11,8 @@ import { SubscriberAttributesClient, TemplatesClient, BouncesClient, - ConfigClient, AdminAttributeClient, + ConfigClient, + AdminAttributeClient, } from '@tatevikgr/rest-api-client'; const AUTHENTICATION_REDIRECT_PATH = '/login'; diff --git a/assets/vue/components/settings/CreateAdminAttributeModal.spec.js b/assets/vue/components/settings/CreateAdminAttributeModal.spec.js new file mode 100644 index 0000000..e658cfa --- /dev/null +++ b/assets/vue/components/settings/CreateAdminAttributeModal.spec.js @@ -0,0 +1,119 @@ +import { flushPromises, mount } from '@vue/test-utils' +import CreateAdminAttributeModal from './CreateAdminAttributeModal.vue' + +const { + createAttributeDefinitionMock, +} = vi.hoisted(() => ({ + createAttributeDefinitionMock: vi.fn(), +})) + +vi.mock('../../api', () => ({ + adminAttributeClient: { + createAttributeDefinition: createAttributeDefinitionMock, + }, +})) + +const mountComponent = (props = {}) => + mount(CreateAdminAttributeModal, { + props: { + isOpen: true, + ...props, + }, + global: { + stubs: { + teleport: true, + }, + }, + }) + +describe('CreateAdminAttributeModal', () => { + beforeEach(() => { + vi.clearAllMocks() + + createAttributeDefinitionMock.mockResolvedValue({ + id: 1, + }) + }) + + it('renders when open', () => { + const wrapper = mountComponent() + + expect(wrapper.text()).toContain('Create Attribute') + expect(wrapper.find('input').exists()).toBe(true) + expect(wrapper.find('select').exists()).toBe(true) + expect(wrapper.find('input[type="checkbox"]').exists()).toBe(true) + }) + + it('resets form each time the modal opens', async () => { + const wrapper = mountComponent({ + isOpen: false, + }) + + await wrapper.setProps({ + isOpen: true, + }) + + await flushPromises() + + expect(wrapper.find('input').element.value).toBe('') + expect(wrapper.find('select').element.value).toBe('textline') + expect(wrapper.find('input[type="checkbox"]').element.checked).toBe(false) + }) + + it('creates an attribute and emits created', async () => { + const wrapper = mountComponent() + + await wrapper.find('input').setValue('Email') + await wrapper.find('select').setValue('hidden') + await wrapper.find('input[type="checkbox"]').setValue(true) + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(createAttributeDefinitionMock).toHaveBeenCalledWith({ + name: 'Email', + type: 'hidden', + required: true, + }) + + expect(wrapper.emitted('created')).toHaveLength(1) + }) + + it('shows an error when creation fails', async () => { + createAttributeDefinitionMock.mockRejectedValueOnce( + new Error('Unable to save') + ) + + const wrapper = mountComponent() + + await wrapper.find('input').setValue('Email') + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(createAttributeDefinitionMock).toHaveBeenCalled() + expect(wrapper.text()).toContain('Unable to save') + expect(wrapper.emitted('created')).toBeUndefined() + }) + + it('emits close when cancel is clicked', async () => { + const wrapper = mountComponent() + + const cancelButton = wrapper + .findAll('button') + .find((button) => button.text() === 'Cancel') + + await cancelButton.trigger('click') + + expect(wrapper.emitted('close')).toHaveLength(1) + }) + + it('emits close when the X button is clicked', async () => { + const wrapper = mountComponent() + + const closeButton = wrapper.findAll('button')[0] + + await closeButton.trigger('click') + + expect(wrapper.emitted('close')).toHaveLength(1) + }) +}) diff --git a/assets/vue/components/settings/CreateAdminModal.spec.js b/assets/vue/components/settings/CreateAdminModal.spec.js new file mode 100644 index 0000000..cec4ebb --- /dev/null +++ b/assets/vue/components/settings/CreateAdminModal.spec.js @@ -0,0 +1,180 @@ +import { flushPromises, mount } from '@vue/test-utils' +import CreateAdminModal from './CreateAdminModal.vue' + +const { + createAdministratorMock, +} = vi.hoisted(() => ({ + createAdministratorMock: vi.fn(), +})) + +vi.mock('@tatevikgr/rest-api-client', () => ({ + Requests: { + CreateAdministratorRequest: class { + constructor(login_name, password, email, super_user, privileges) { + this.login_name = login_name + this.password = password + this.email = email + this.super_user = super_user + this.privileges = privileges + } + }, + }, +})) + +vi.mock('../../api', () => ({ + adminClient: { + createAdministrator: createAdministratorMock, + }, +})) + +vi.mock('../base/BaseIcon.vue', () => ({ + default: { + template: '', + }, +})) + +const mountComponent = (props = {}) => + mount(CreateAdminModal, { + props: { + isOpen: true, + ...props, + }, + }) + +describe('CreateAdminModal', () => { + beforeEach(() => { + vi.clearAllMocks() + + createAdministratorMock.mockResolvedValue({ + id: 1, + login_name: 'admin', + }) + + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('renders when open', () => { + const wrapper = mountComponent() + + expect(wrapper.text()).toContain('Create New Administrator') + expect(wrapper.find('#admin-login-name').exists()).toBe(true) + expect(wrapper.find('#admin-email').exists()).toBe(true) + expect(wrapper.find('#admin-password').exists()).toBe(true) + }) + + it('resets form whenever opened', async () => { + const wrapper = mountComponent({ + isOpen: false, + }) + + await wrapper.setProps({ + isOpen: true, + }) + + await flushPromises() + + expect(wrapper.find('#admin-login-name').element.value).toBe('') + expect(wrapper.find('#admin-email').element.value).toBe('') + expect(wrapper.find('#admin-password').element.value).toBe('') + expect(wrapper.find('#admin-super-user').element.checked).toBe(false) + }) + + it('creates an administrator', async () => { + const wrapper = mountComponent() + + await wrapper.find('#admin-login-name').setValue('admin') + await wrapper.find('#admin-email').setValue('admin@example.com') + await wrapper.find('#admin-password').setValue('password123') + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(createAdministratorMock).toHaveBeenCalledTimes(1) + + const request = createAdministratorMock.mock.calls[0][0] + + expect(request.login_name).toBe('admin') + expect(request.email).toBe('admin@example.com') + expect(request.password).toBe('password123') + expect(request.super_user).toBe(false) + expect(request.privileges).toEqual({ + subscribers: false, + campaigns: false, + statistics: false, + settings: false, + }) + + expect(wrapper.emitted('created')).toHaveLength(1) + expect(wrapper.emitted('close')).toHaveLength(1) + }) + + it('creates a super user without privileges', async () => { + const wrapper = mountComponent() + + await wrapper.find('#admin-login-name').setValue('admin') + await wrapper.find('#admin-email').setValue('admin@example.com') + await wrapper.find('#admin-password').setValue('password123') + await wrapper.find('#admin-super-user').setValue(true) + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + const request = createAdministratorMock.mock.calls[0][0] + + expect(request.super_user).toBe(true) + expect(request.privileges).toBeUndefined() + }) + + it('does not submit when form is invalid', async () => { + const wrapper = mountComponent() + + await wrapper.find('#admin-login-name').setValue('ab') + await wrapper.find('#admin-email').setValue('invalid') + await wrapper.find('#admin-password').setValue('123') + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(createAdministratorMock).not.toHaveBeenCalled() + }) + + it('shows an error when creation fails', async () => { + createAdministratorMock.mockRejectedValueOnce( + new Error('Create failed') + ) + + const wrapper = mountComponent() + + await wrapper.find('#admin-login-name').setValue('admin') + await wrapper.find('#admin-email').setValue('admin@example.com') + await wrapper.find('#admin-password').setValue('password123') + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(createAdministratorMock).toHaveBeenCalled() + expect(wrapper.text()).toContain('Create failed') + expect(console.error).toHaveBeenCalled() + expect(wrapper.emitted('created')).toBeUndefined() + }) + + it('emits close when cancel is clicked', async () => { + const wrapper = mountComponent() + + const cancelButton = wrapper + .findAll('button') + .find((button) => button.text() === 'Cancel') + + await cancelButton.trigger('click') + + expect(wrapper.emitted('close')).toHaveLength(1) + }) + + it('emits close when the close button is clicked', async () => { + const wrapper = mountComponent() + + await wrapper.find('button[type="button"]').trigger('click') + + expect(wrapper.emitted('close')).toHaveLength(1) + }) +}) diff --git a/assets/vue/components/settings/CreateSubscriberAttributeModal.spec.js b/assets/vue/components/settings/CreateSubscriberAttributeModal.spec.js new file mode 100644 index 0000000..089a47e --- /dev/null +++ b/assets/vue/components/settings/CreateSubscriberAttributeModal.spec.js @@ -0,0 +1,189 @@ +import { flushPromises, mount } from '@vue/test-utils' +import CreateSubscriberAttributeModal from './CreateSubscriberAttributeModal.vue' + +const { + createAttributeDefinitionMock, +} = vi.hoisted(() => ({ + createAttributeDefinitionMock: vi.fn(), +})) + +vi.mock('../../api', () => ({ + subscriberAttributesClient: { + createAttributeDefinition: createAttributeDefinitionMock, + }, +})) + +vi.mock('../base/BaseIcon.vue', () => ({ + default: { + template: '', + }, +})) + +const mountComponent = (props = {}) => + mount(CreateSubscriberAttributeModal, { + props: { + isOpen: true, + ...props, + }, + global: { + stubs: { + teleport: true, + }, + }, + }) + +describe('CreateSubscriberAttributeModal', () => { + beforeEach(() => { + vi.clearAllMocks() + + createAttributeDefinitionMock.mockResolvedValue({ + id: 1, + }) + }) + + it('renders when open', () => { + const wrapper = mountComponent() + + expect(wrapper.text()).toContain('Create Subscriber Attribute') + expect(wrapper.findAll('input')[0].exists()).toBe(true) + expect(wrapper.find('select').exists()).toBe(true) + }) + + it('resets the form when reopened', async () => { + const wrapper = mountComponent({ + isOpen: false, + }) + + await wrapper.setProps({ + isOpen: true, + }) + + await flushPromises() + + const inputs = wrapper.findAll('input') + + expect(inputs[0].element.value).toBe('') + expect(wrapper.find('select').element.value).toBe('textline') + expect(inputs[2].element.checked).toBe(false) + }) + + it('creates a subscriber attribute', async () => { + const wrapper = mountComponent() + + const inputs = wrapper.findAll('input') + + await inputs[0].setValue('Country') + await wrapper.find('select').setValue('textline') + await inputs[1].setValue('10') + await inputs[2].setValue('Default') + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(createAttributeDefinitionMock).toHaveBeenCalledWith({ + name: 'Country', + type: 'textline', + order: 10, + default_value: 'Default', + required: false, + options: [], + }) + + expect(wrapper.emitted('created')).toHaveLength(1) + expect(wrapper.emitted('close')).toHaveLength(1) + }) + + it('adds and removes options', async () => { + const wrapper = mountComponent() + + await wrapper.find('select').setValue('select') + + const addButton = wrapper + .findAll('button') + .find(button => button.text() === 'Add') + + await addButton.trigger('click') + + expect(wrapper.findAll('input').length).toBeGreaterThan(4) + + const removeButton = wrapper + .findAll('button') + .find(button => !button.text().trim()) + + await removeButton.trigger('click') + + expect(wrapper.findAll('input').length).toBe(4) + }) + + it('submits options for selectable types', async () => { + const wrapper = mountComponent() + + await wrapper.findAll('input')[0].setValue('Status') + await wrapper.find('select').setValue('select') + + const addButton = wrapper + .findAll('button') + .find(button => button.text() === 'Add') + + await addButton.trigger('click') + + const inputs = wrapper.findAll('input') + + await inputs[4].setValue('Active') + await inputs[5].setValue('1') + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(createAttributeDefinitionMock).toHaveBeenCalledWith({ + name: 'Status', + type: 'select', + order: null, + default_value: '', + required: false, + options: [ + { + name: 'Active', + list_order: 1, + }, + ], + }) + }) + + it('shows an error when creation fails', async () => { + createAttributeDefinitionMock.mockRejectedValueOnce( + new Error('Unable to save') + ) + + const wrapper = mountComponent() + + await wrapper.findAll('input')[0].setValue('Country') + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(createAttributeDefinitionMock).toHaveBeenCalled() + expect(wrapper.text()).toContain('Unable to save') + expect(wrapper.emitted('created')).toBeUndefined() + }) + + it('emits close when cancel is clicked', async () => { + const wrapper = mountComponent() + + const cancelButton = wrapper + .findAll('button') + .find(button => button.text() === 'Cancel') + + await cancelButton.trigger('click') + + expect(wrapper.emitted('close')).toHaveLength(1) + }) + + it('emits close when the X button is clicked', async () => { + const wrapper = mountComponent() + + await wrapper.findAll('button')[0].trigger('click') + + expect(wrapper.emitted('close')).toHaveLength(1) + }) +}) diff --git a/assets/vue/components/settings/EditAdminAttributeModal.spec.js b/assets/vue/components/settings/EditAdminAttributeModal.spec.js new file mode 100644 index 0000000..8bd3920 --- /dev/null +++ b/assets/vue/components/settings/EditAdminAttributeModal.spec.js @@ -0,0 +1,140 @@ +import { flushPromises, mount } from '@vue/test-utils' +import EditAdminAttributeModal from './EditAdminAttributeModal.vue' + +const { + updateAttributeDefinitionMock, +} = vi.hoisted(() => ({ + updateAttributeDefinitionMock: vi.fn(), +})) + +vi.mock('../../api', () => ({ + adminAttributeClient: { + updateAttributeDefinition: updateAttributeDefinitionMock, + }, +})) + +const attribute = { + id: 1, + name: 'Email', + type: 'textline', + required: true, +} + +const mountComponent = (props = {}) => + mount(EditAdminAttributeModal, { + props: { + isOpen: true, + attribute, + ...props, + }, + global: { + stubs: { + teleport: true, + }, + }, + }) + +describe('EditAdminAttributeModal', () => { + beforeEach(() => { + vi.clearAllMocks() + + updateAttributeDefinitionMock.mockResolvedValue({ + id: 1, + }) + }) + + it('renders when open', () => { + const wrapper = mountComponent() + + expect(wrapper.text()).toContain('Edit Attribute') + expect(wrapper.find('input').exists()).toBe(true) + expect(wrapper.find('select').exists()).toBe(true) + }) + + it('populates the form from the attribute prop', () => { + const wrapper = mountComponent() + + const inputs = wrapper.findAll('input') + + expect(inputs[0].element.value).toBe('Email') + expect(wrapper.find('select').element.value).toBe('textline') + expect(inputs[1].element.checked).toBe(true) + }) + + it('updates the form when the attribute prop changes', async () => { + const wrapper = mountComponent() + + await wrapper.setProps({ + attribute: { + id: 2, + name: 'Country', + type: 'hidden', + required: false, + }, + }) + + await flushPromises() + + const inputs = wrapper.findAll('input') + + expect(inputs[0].element.value).toBe('Country') + expect(wrapper.find('select').element.value).toBe('hidden') + expect(inputs[1].element.checked).toBe(false) + }) + + it('updates an attribute and emits updated', async () => { + const wrapper = mountComponent() + + const inputs = wrapper.findAll('input') + + await inputs[0].setValue('Full Name') + await wrapper.find('select').setValue('hidden') + await inputs[1].setValue(false) + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(updateAttributeDefinitionMock).toHaveBeenCalledWith(1, { + name: 'Full Name', + type: 'hidden', + required: false, + }) + + expect(wrapper.emitted('updated')).toHaveLength(1) + }) + + it('shows an error when update fails', async () => { + updateAttributeDefinitionMock.mockRejectedValueOnce( + new Error('Unable to update') + ) + + const wrapper = mountComponent() + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(updateAttributeDefinitionMock).toHaveBeenCalled() + expect(wrapper.text()).toContain('Unable to update') + expect(wrapper.emitted('updated')).toBeUndefined() + }) + + it('emits close when cancel is clicked', async () => { + const wrapper = mountComponent() + + const cancelButton = wrapper + .findAll('button') + .find(button => button.text() === 'Cancel') + + await cancelButton.trigger('click') + + expect(wrapper.emitted('close')).toHaveLength(1) + }) + + it('emits close when the X button is clicked', async () => { + const wrapper = mountComponent() + + await wrapper.findAll('button')[0].trigger('click') + + expect(wrapper.emitted('close')).toHaveLength(1) + }) +}) diff --git a/assets/vue/components/settings/EditAdminModal.spec.js b/assets/vue/components/settings/EditAdminModal.spec.js new file mode 100644 index 0000000..de82e94 --- /dev/null +++ b/assets/vue/components/settings/EditAdminModal.spec.js @@ -0,0 +1,196 @@ +import { flushPromises, mount } from '@vue/test-utils' +import EditAdminModal from './EditAdminModal.vue' + +const { + updateAdministratorMock, +} = vi.hoisted(() => ({ + updateAdministratorMock: vi.fn(), +})) + +vi.mock('@tatevikgr/rest-api-client', () => ({ + Requests: { + UpdateAdministratorRequest: class { + constructor(loginName, password, email, superUser, privileges) { + this.loginName = loginName + this.password = password + this.email = email + this.superUser = superUser + this.privileges = privileges + } + }, + }, +})) + +vi.mock('../../api', () => ({ + adminClient: { + updateAdministrator: updateAdministratorMock, + }, +})) + +vi.mock('../base/BaseIcon.vue', () => ({ + default: { + template: '', + }, +})) + +const admin = { + id: 7, + loginName: 'admin', + email: 'admin@example.com', + superUser: false, + privileges: { + subscribers: true, + campaigns: false, + statistics: true, + settings: false, + }, +} + +const mountComponent = (props = {}) => + mount(EditAdminModal, { + props: { + isOpen: true, + admin, + ...props, + }, + }) + +describe('EditAdminModal', () => { + beforeEach(() => { + vi.clearAllMocks() + + updateAdministratorMock.mockResolvedValue({ + id: 7, + }) + + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + it('renders when open', () => { + const wrapper = mountComponent() + + expect(wrapper.text()).toContain('Edit Administrator') + expect(wrapper.text()).toContain('7') + expect(wrapper.find('#edit-admin-login-name').exists()).toBe(true) + expect(wrapper.find('#edit-admin-email').exists()).toBe(true) + expect(wrapper.find('#edit-admin-password').exists()).toBe(true) + }) + + it('populates the form from the admin prop', async () => { + const wrapper = mountComponent() + + await flushPromises() + + expect(wrapper.find('#edit-admin-login-name').element.value).toBe('admin') + expect(wrapper.find('#edit-admin-email').element.value).toBe('admin@example.com') + expect(wrapper.find('#edit-admin-password').element.value).toBe('') + expect(wrapper.find('#edit-admin-super-user').element.checked).toBe(false) + expect(wrapper.find('#edit-priv-subscribers').element.checked).toBe(true) + expect(wrapper.find('#edit-priv-statistics').element.checked).toBe(true) + }) + + it('updates the form when the admin prop changes', async () => { + const wrapper = mountComponent() + + await wrapper.setProps({ + admin: { + id: 9, + loginName: 'root', + email: 'root@example.com', + superUser: true, + privileges: { + subscribers: false, + campaigns: true, + statistics: false, + settings: true, + }, + }, + }) + + await flushPromises() + + expect(wrapper.find('#edit-admin-login-name').element.value).toBe('root') + expect(wrapper.find('#edit-admin-email').element.value).toBe('root@example.com') + expect(wrapper.find('#edit-admin-super-user').element.checked).toBe(true) + expect(wrapper.find('#edit-priv-campaigns').element.checked).toBe(true) + expect(wrapper.find('#edit-priv-settings').element.checked).toBe(true) + }) + + it('updates an administrator', async () => { + const wrapper = mountComponent() + + await wrapper.find('#edit-admin-login-name').setValue('new-admin') + await wrapper.find('#edit-admin-email').setValue('new@example.com') + await wrapper.find('#edit-admin-password').setValue('password123') + await wrapper.find('#edit-admin-super-user').setValue(true) + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(updateAdministratorMock).toHaveBeenCalledTimes(1) + expect(updateAdministratorMock.mock.calls[0][0]).toBe(7) + + const request = updateAdministratorMock.mock.calls[0][1] + + expect(request.loginName).toBe('new-admin') + expect(request.email).toBe('new@example.com') + expect(request.password).toBe('password123') + expect(request.superUser).toBe(true) + expect(request.privileges).toEqual({ + subscribers: true, + campaigns: false, + statistics: true, + settings: false, + }) + + expect(wrapper.emitted('updated')).toHaveLength(1) + expect(wrapper.emitted('close')).toHaveLength(1) + }) + + it('does not submit without an admin', async () => { + const wrapper = mountComponent({ + admin: null, + }) + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(updateAdministratorMock).not.toHaveBeenCalled() + }) + + it('shows an error when update fails', async () => { + updateAdministratorMock.mockRejectedValueOnce( + new Error('Update failed') + ) + + const wrapper = mountComponent() + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(updateAdministratorMock).toHaveBeenCalled() + expect(wrapper.text()).toContain('Update failed') + expect(console.error).toHaveBeenCalled() + expect(wrapper.emitted('updated')).toBeUndefined() + }) + + it('emits close when cancel is clicked', async () => { + const wrapper = mountComponent() + + const cancelButton = wrapper + .findAll('button') + .find(button => button.text() === 'Cancel') + + await cancelButton.trigger('click') + + expect(wrapper.emitted('close')).toHaveLength(1) + }) + + it('emits close when the close button is clicked', async () => { + const wrapper = mountComponent() + + await wrapper.find('button[aria-label="Close edit administrator modal"]').trigger('click') + + expect(wrapper.emitted('close')).toHaveLength(1) + }) +}) diff --git a/assets/vue/components/settings/EditAdminModal.vue b/assets/vue/components/settings/EditAdminModal.vue index ccce1a7..f989f61 100644 --- a/assets/vue/components/settings/EditAdminModal.vue +++ b/assets/vue/components/settings/EditAdminModal.vue @@ -212,10 +212,11 @@ const resetForm = () => { watch( () => [props.isOpen, props.admin?.id], ([isOpen]) => { - if (isOpen) { - resetForm() - } - } + if (isOpen) { + resetForm() + } + }, + { immediate: true } ) const close = () => { diff --git a/assets/vue/components/settings/EditSubscriberAttributeModal.spec.js b/assets/vue/components/settings/EditSubscriberAttributeModal.spec.js new file mode 100644 index 0000000..90e023a --- /dev/null +++ b/assets/vue/components/settings/EditSubscriberAttributeModal.spec.js @@ -0,0 +1,185 @@ +import { flushPromises, mount } from '@vue/test-utils' +import EditSubscriberAttributeModal from './EditSubscriberAttributeModal.vue' + +const { + updateAttributeDefinitionMock, +} = vi.hoisted(() => ({ + updateAttributeDefinitionMock: vi.fn(), +})) + +vi.mock('../../api', () => ({ + subscriberAttributesClient: { + updateAttributeDefinition: updateAttributeDefinitionMock, + }, +})) + +vi.mock('../base/BaseIcon.vue', () => ({ + default: { + template: '', + }, +})) + +const attribute = { + id: 1, + name: 'Country', + type: 'select', + order: 5, + default_value: 'USA', + required: true, + options: [ + { + name: 'USA', + list_order: 1, + }, + { + name: 'Canada', + list_order: 2, + }, + ], +} + +const mountComponent = (props = {}) => + mount(EditSubscriberAttributeModal, { + props: { + isOpen: true, + attribute, + ...props, + }, + global: { + stubs: { + teleport: true, + }, + }, + }) + +describe('EditSubscriberAttributeModal', () => { + beforeEach(() => { + vi.clearAllMocks() + + updateAttributeDefinitionMock.mockResolvedValue({ + id: 1, + }) + }) + + it('renders when open', () => { + const wrapper = mountComponent() + + expect(wrapper.text()).toContain('Edit Subscriber Attribute') + expect(wrapper.find('select').exists()).toBe(true) + }) + + it('populates the form from the attribute prop', () => { + const wrapper = mountComponent() + + const inputs = wrapper.findAll('input') + + expect(inputs[0].element.value).toBe('Country') + expect(wrapper.find('select').element.value).toBe('select') + expect(inputs[1].element.value).toBe('5') + expect(inputs[2].element.value).toBe('USA') + expect(inputs[3].element.checked).toBe(true) + }) + + it('updates the attribute', async () => { + const wrapper = mountComponent() + + const inputs = wrapper.findAll('input') + + await inputs[0].setValue('Status') + await inputs[1].setValue('10') + await inputs[2].setValue('Active') + await inputs[3].setValue(false) + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(updateAttributeDefinitionMock).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + id: 1, + name: 'Status', + type: 'select', + order: 10, + default_value: 'Active', + required: false, + }) + ) + + expect(wrapper.emitted('updated')).toHaveLength(1) + expect(wrapper.emitted('close')).toHaveLength(1) + }) + + it('adds and removes options', async () => { + const wrapper = mountComponent() + + const addButton = wrapper + .findAll('button') + .find(button => button.text() === 'Add') + + await addButton.trigger('click') + + expect(wrapper.findAll('input').length).toBe(10) + + const removeButtons = wrapper + .findAll('button') + .filter(button => button.text().trim() === '') + + await removeButtons[0].trigger('click') + + expect(wrapper.findAll('input').length).toBe(8) + }) + + it('updates option values', async () => { + const wrapper = mountComponent() + + const inputs = wrapper.findAll('input') + + await inputs[4].setValue('UK') + await inputs[5].setValue('3') + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + const payload = updateAttributeDefinitionMock.mock.calls[0][1] + + expect(payload.options[0]).toEqual({ + name: 'UK', + list_order: 3, + }) + }) + + it('shows an error when update fails', async () => { + updateAttributeDefinitionMock.mockRejectedValueOnce( + new Error('Update failed') + ) + + const wrapper = mountComponent() + + await wrapper.find('form').trigger('submit.prevent') + await flushPromises() + + expect(updateAttributeDefinitionMock).toHaveBeenCalled() + expect(wrapper.text()).toContain('Update failed') + expect(wrapper.emitted('updated')).toBeUndefined() + }) + + it('emits close when cancel is clicked', async () => { + const wrapper = mountComponent() + + const cancelButton = wrapper + .findAll('button') + .find(button => button.text() === 'Cancel') + + await cancelButton.trigger('click') + + expect(wrapper.emitted('close')).toHaveLength(1) + }) + + it('emits close when the X button is clicked', async () => { + const wrapper = mountComponent() + + await wrapper.findAll('button')[0].trigger('click') + + expect(wrapper.emitted('close')).toHaveLength(1) + }) +}) diff --git a/assets/vue/components/settings/SettingsActionsPanel.spec.js b/assets/vue/components/settings/SettingsActionsPanel.spec.js new file mode 100644 index 0000000..d2deeb7 --- /dev/null +++ b/assets/vue/components/settings/SettingsActionsPanel.spec.js @@ -0,0 +1,163 @@ +import { flushPromises, mount } from '@vue/test-utils' +import SettingsActionsPanel from './SettingsActionsPanel.vue' + +const { + routeMock, + replaceMock, +} = vi.hoisted(() => ({ + routeMock: { + query: {}, + }, + replaceMock: vi.fn(() => Promise.resolve()), +})) + +vi.mock('vue-router', () => ({ + useRoute: () => routeMock, + useRouter: () => ({ + replace: replaceMock, + }), +})) + +vi.mock('./SettingsConfigs.vue', () => ({ + default: { + template: '
+ {{ metric.label }} +
++ {{ metric.value }} +
++ {{ metric.description }} +
++ Top campaigns ranked by unique views and click volume. +
++ {{ formatCount(campaignStatistics.length) }} campaigns +
++ Confirmation coverage for the configured sending domain. +
+Domain
++ {{ domainConfirmation.domain || 'Unknown domain' }} +
+Confirmed
++ {{ formatCount(domainConfirmation.confirmed) }} +
+Unconfirmed
++ {{ formatCount(domainConfirmation.unconfirmed) }} +
++ Subscriber counts by email domain. +
++ {{ formatCount(topDomains.length) }} domains +
+| Domain | +Subscribers | +
|---|---|
| + No domain statistics found. + | +|
| + {{ domain.domain || 'Unknown domain' }} + | ++ {{ formatCount(domain.subscribers) }} + | +
+ Most common subscriber usernames. +
++ {{ formatCount(topLocalParts.length) }} values +
+| Local part | +Count | +Share | +
|---|---|---|
| + No local-part statistics found. + | +||
| + {{ part.localPart || 'Unknown' }} + | ++ {{ formatCount(part.count) }} + | ++ {{ formatPercentage(part.percentage) }} + | +
+ Open and click activity by campaign. +
++ {{ formatCount(campaignStatistics.length) }} records +
+| Campaign | +Sent | +Views | +Open rate | +Clicks | +Bounce | +
|---|---|---|---|---|---|
| + No campaign statistics found. + | +|||||
|
+
+ {{ campaign.subject || `Campaign #${campaign.campaignId}` }}
+
+
+ Sent {{ campaign.dateSent ? formatDate(campaign.dateSent) : 'unknown date' }}
+
+ |
+ + {{ formatCount(campaign.sent) }} + | ++ {{ formatCount(campaign.uniqueViews) }} + | ++ {{ formatPercentage(calcRate(campaign.uniqueViews, campaign.sent)) }} + | ++ {{ formatCount(campaign.totalClicks) }} + | ++ {{ formatCount(campaign.bounces) }} + | +