Page MenuHomePhorge

No OneTemporary

Size
24 KB
Referenced Files
None
Subscribers
None
diff --git a/src/resources/vue/Reseller/Invitations.vue b/src/resources/vue/Reseller/Invitations.vue
index d8a892d2..49be5ccd 100644
--- a/src/resources/vue/Reseller/Invitations.vue
+++ b/src/resources/vue/Reseller/Invitations.vue
@@ -1,279 +1,280 @@
<template>
<div class="container">
<div class="card" id="invitations">
<div class="card-body">
<div class="card-title">
{{ $t('invitation.title') }}
</div>
<div class="card-text">
<div class="mb-2 d-flex">
<form @submit.prevent="searchInvitations" id="search-form" class="input-group" style="flex:1">
<input class="form-control" type="text" :placeholder="$t('invitation.search')" v-model="search">
<button type="submit" class="btn btn-primary"><svg-icon icon="search"></svg-icon> {{ $t('btn.search') }}</button>
</form>
<div>
<button class="btn btn-success create-invite ms-1" @click="inviteUserDialog">
<svg-icon icon="envelope-open-text"></svg-icon> {{ $t('invitation.create') }}
</button>
</div>
</div>
<table id="invitations-list" class="table table-sm table-hover">
<thead>
<tr>
<th scope="col">{{ $t('user.ext-email') }}</th>
<th scope="col">{{ $t('form.created') }}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
<tr v-for="inv in invitations" :id="'i' + inv.id" :key="inv.id">
<td class="email">
<svg-icon icon="envelope-open-text" :class="statusClass(inv)" :title="$t('invitation.status-' + statusLabel(inv))"></svg-icon>
<span>{{ inv.email }}</span>
</td>
<td class="datetime">
{{ inv.created }}
</td>
<td class="buttons">
<button class="btn text-danger button-delete p-0 ms-1" @click="deleteInvite(inv.id)">
<svg-icon icon="trash-alt"></svg-icon>
<span class="btn-label">{{ $t('btn.delete') }}</span>
</button>
<button class="btn button-resend p-0 ms-1" :disabled="inv.isNew || inv.isCompleted" @click="resendInvite(inv.id)">
<svg-icon icon="redo"></svg-icon>
<span class="btn-label">{{ $t('btn.resend') }}</span>
</button>
</td>
</tr>
</tbody>
<tfoot class="table-fake-body">
<tr>
<td colspan="3">{{ $t('invitation.empty-list') }}</td>
</tr>
</tfoot>
</table>
<div class="text-center p-3" id="more-loader" v-if="hasMore">
<button class="btn btn-secondary" @click="loadInvitations(true)">{{ $t('nav.more') }}</button>
</div>
</div>
</div>
</div>
<div id="invite-create" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ $t('invitation.create-title') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('btn.close')"></button>
</div>
<div class="modal-body">
<form>
<p>{{ $t('invitation.create-email') }}</p>
<div>
<input id="email" type="text" class="form-control" name="email">
</div>
<div class="form-separator"><hr><span>{{ $t('form.or') }}</span></div>
<p>{{ $t('invitation.create-csv') }}</p>
<div>
<input id="file" type="file" class="form-control" name="csv">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel" data-bs-dismiss="modal">{{ $t('btn.cancel') }}</button>
<button type="button" class="btn btn-primary modal-action" @click="inviteUser()">
<svg-icon icon="paper-plane"></svg-icon> {{ $t('invitation.send') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Modal } from 'bootstrap'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faEnvelopeOpenText, faPaperPlane, faRedo } from '@fortawesome/free-solid-svg-icons'
library.add(faEnvelopeOpenText, faPaperPlane, faRedo)
export default {
data() {
return {
invitations: [],
hasMore: false,
page: 1,
search: ''
}
},
mounted() {
this.$root.startLoading()
this.loadInvitations(null, () => this.$root.stopLoading())
$('#invite-create')[0].addEventListener('shown.bs.modal', event => {
$('input', event.target).first().focus()
})
},
methods: {
deleteInvite(id) {
axios.delete('/api/v4/invitations/' + id)
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
// Remove the invitation record from the list
const index = this.invitations.findIndex(item => item.id == id)
this.invitations.splice(index, 1)
}
})
},
fileChange(e) {
let label = this.$t('btn.file')
let files = e.target.files
if (files.length) {
label = files[0].name
if (files.length > 1) {
label += ', ...'
}
}
$(e.target).next().text(label)
},
inviteUser() {
let dialog = $('#invite-create')
let post = new FormData()
let params = { headers: { 'Content-Type': 'multipart/form-data' } }
post.append('email', dialog.find('#email').val())
this.$root.clearFormValidation(dialog.find('form'))
// Append the file to POST data
let files = dialog.find('#file').get(0).files
if (files.length) {
post.append('file', files[0])
}
axios.post('/api/v4/invitations', post, params)
.then(response => {
if (response.data.status == 'success') {
this.dialog.hide()
this.$toast.success(response.data.message)
if (response.data.count) {
this.loadInvitations({ reset: true })
}
}
})
},
inviteUserDialog() {
const dialog = $('#invite-create')[0]
const form = $('form', dialog)
form.get(0).reset()
this.fileChange({ target: form.find('#file')[0] }) // resets file input label
this.$root.clearFormValidation(form)
this.dialog = new Modal(dialog)
this.dialog.show()
},
loadInvitations(params, callback) {
let loader
let get = {}
if (params) {
if (params.reset) {
this.invitations = []
this.page = 0
}
get.page = params.page || (this.page + 1)
if (typeof params === 'object' && 'search' in params) {
get.search = params.search
this.currentSearch = params.search
} else {
get.search = this.currentSearch
}
loader = $(get.page > 1 ? '#more-loader' : '#invitations-list tfoot td')
} else {
this.currentSearch = null
}
this.$root.addLoader(loader)
axios.get('/api/v4/invitations', { params: get })
.then(response => {
this.$root.removeLoader(loader)
// Note: In Vue we can't just use .concat()
for (let i in response.data.list) {
this.$set(this.invitations, this.invitations.length, response.data.list[i])
}
this.hasMore = response.data.hasMore
this.page = response.data.page || 1
if (callback) {
callback()
}
})
.catch(error => {
this.$root.removeLoader(loader)
if (callback) {
callback()
}
})
},
resendInvite(id) {
axios.post('/api/v4/invitations/' + id + '/resend')
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
// Update the invitation record
const index = this.invitations.findIndex(item => item.id == id)
- this.invitations.splice(index, 1)
- this.$set(this.invitations, index, response.data.invitation)
+ if (index > -1) {
+ this.$set(this.invitations, index, response.data.invitation)
+ }
}
})
},
searchInvitations() {
this.loadInvitations({ reset: true, search: this.search })
},
statusClass(invitation) {
if (invitation.isCompleted) {
return 'text-success'
}
if (invitation.isFailed) {
return 'text-danger'
}
if (invitation.isSent) {
return 'text-primary'
}
return ''
},
statusLabel(invitation) {
if (invitation.isCompleted) {
return 'completed'
}
if (invitation.isFailed) {
return 'failed'
}
if (invitation.isSent) {
return 'sent'
}
return 'new'
}
}
}
</script>
diff --git a/src/tests/Browser/Reseller/InvitationsTest.php b/src/tests/Browser/Reseller/InvitationsTest.php
index 7e24b764..5f96dc50 100644
--- a/src/tests/Browser/Reseller/InvitationsTest.php
+++ b/src/tests/Browser/Reseller/InvitationsTest.php
@@ -1,221 +1,225 @@
<?php
namespace Tests\Browser\Reseller;
use App\SignupInvitation;
use Illuminate\Support\Facades\Queue;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Menu;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\Reseller\Invitations;
use Tests\TestCaseDusk;
class InvitationsTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useResellerUrl();
SignupInvitation::truncate();
}
/**
* Test invitations page (unauthenticated)
*/
public function testInvitationsUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit('/invitations')->on(new Home());
});
}
/**
* Test Invitations creation
*/
public function testInvitationCreate(): void
{
$this->browse(function (Browser $browser) {
$date_regexp = '/^20[0-9]{2}-/';
$browser->visit(new Home())
->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true)
->on(new Dashboard())
->assertSeeIn('@links .link-invitations', 'Invitations')
->click('@links .link-invitations')
->on(new Invitations())
->assertElementsCount('@table tbody tr', 0)
->assertMissing('#more-loader')
->assertSeeIn('@table tfoot td', "There are no invitations in the database.")
->assertSeeIn('@create-button', 'Create invite(s)');
// Create a single invite with email address input
$browser->click('@create-button')
->with(new Dialog('#invite-create'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Invite for a signup')
->assertFocused('@body input#email')
->assertValue('@body input#email', '')
->type('@body input#email', 'test')
->assertSeeIn('@button-action', 'Send invite(s)')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, "Form validation error")
->waitFor('@body input#email.is-invalid')
->assertSeeIn(
'@body input#email.is-invalid + .invalid-feedback',
"The email must be a valid email address."
)
->type('@body input#email', 'test@domain.tld')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, "The invitation has been created.")
->waitUntilMissing('#invite-create')
->waitUntilMissing('@table .app-loader')
->assertElementsCount('@table tbody tr', 1)
->assertMissing('@table tfoot')
->assertSeeIn('@table tbody tr td.email', 'test@domain.tld')
->assertText('@table tbody tr td.email title', 'Not sent yet')
->assertTextRegExp('@table tbody tr td.datetime', $date_regexp)
->assertVisible('@table tbody tr td.buttons button.button-delete')
->assertVisible('@table tbody tr td.buttons button.button-resend:disabled');
sleep(1);
// Create invites from a file
$browser->click('@create-button')
->with(new Dialog('#invite-create'), function (Browser $browser) {
$browser->assertFocused('@body input#email')
->assertValue('@body input#email', '')
->assertMissing('@body input#email.is-invalid')
// Submit an empty file
->attach('@body input#file', __DIR__ . '/../../data/empty.csv')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, "Form validation error")
// ->waitFor('input#file.is-invalid')
->assertSeeIn(
'@body input#file.is-invalid + .invalid-feedback',
"Failed to find any valid email addresses in the uploaded file."
)
// Submit non-empty file
->attach('@body input#file', __DIR__ . '/../../data/email.csv')
->click('@button-action');
})
->assertToast(Toast::TYPE_SUCCESS, "2 invitations has been created.")
->waitUntilMissing('#invite-create')
->waitUntilMissing('@table .app-loader')
->assertElementsCount('@table tbody tr', 3)
->assertTextRegExp('@table tbody tr:nth-child(1) td.email', '/email[12]@test\.com$/')
->assertTextRegExp('@table tbody tr:nth-child(2) td.email', '/email[12]@test\.com$/');
});
}
/**
* Test Invitations deletion and resending
*/
public function testInvitationDeleteAndResend(): void
{
$this->browse(function (Browser $browser) {
Queue::fake();
$i1 = SignupInvitation::create(['email' => 'test1@domain.org']);
$i2 = SignupInvitation::create(['email' => 'test2@domain.org']);
- SignupInvitation::where('id', $i2->id)
- ->update(['created_at' => now()->subHours('2'), 'status' => SignupInvitation::STATUS_FAILED]);
+ SignupInvitation::where('id', $i1->id)->update(['status' => SignupInvitation::STATUS_FAILED]);
+ SignupInvitation::where('id', $i2->id)->update(['created_at' => now()->subHours('2')]);
- // Test deleting
$browser->visit(new Invitations())
- // ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true)
- ->assertElementsCount('@table tbody tr', 2)
- ->click('@table tbody tr:first-child button.button-delete')
- ->assertToast(Toast::TYPE_SUCCESS, "Invitation deleted successfully.")
- ->assertElementsCount('@table tbody tr', 1);
+ ->assertElementsCount('@table tbody tr', 2);
// Test resending
- $browser->click('@table tbody tr:first-child button.button-resend')
+ $browser->assertSeeIn('@table tbody tr:first-child td.email', 'test1@domain.org')
+ ->click('@table tbody tr:first-child button.button-resend')
->assertToast(Toast::TYPE_SUCCESS, "Invitation added to the sending queue successfully.")
- ->assertElementsCount('@table tbody tr', 1);
+ ->assertVisible('@table tbody tr:first-child button.button-resend:disabled')
+ ->assertElementsCount('@table tbody tr', 2);
+
+ // Test deleting
+ $browser->assertSeeIn('@table tbody tr:last-child td.email', 'test2@domain.org')
+ ->click('@table tbody tr:last-child button.button-delete')
+ ->assertToast(Toast::TYPE_SUCCESS, "Invitation deleted successfully.")
+ ->assertElementsCount('@table tbody tr', 1)
+ ->assertSeeIn('@table tbody tr:first-child td.email', 'test1@domain.org');
});
}
/**
* Test Invitations list (paging and searching)
*/
public function testInvitationsList(): void
{
$this->browse(function (Browser $browser) {
Queue::fake();
$i1 = SignupInvitation::create(['email' => 'email1@ext.com']);
$i2 = SignupInvitation::create(['email' => 'email2@ext.com']);
$i3 = SignupInvitation::create(['email' => 'email3@ext.com']);
$i4 = SignupInvitation::create(['email' => 'email4@other.com']);
$i5 = SignupInvitation::create(['email' => 'email5@other.com']);
$i6 = SignupInvitation::create(['email' => 'email6@other.com']);
$i7 = SignupInvitation::create(['email' => 'email7@other.com']);
$i8 = SignupInvitation::create(['email' => 'email8@other.com']);
$i9 = SignupInvitation::create(['email' => 'email9@other.com']);
$i10 = SignupInvitation::create(['email' => 'email10@other.com']);
$i11 = SignupInvitation::create(['email' => 'email11@other.com']);
SignupInvitation::query()->update(['created_at' => now()->subDays('1')]);
SignupInvitation::where('id', $i1->id)
->update(['created_at' => now()->subHours('2'), 'status' => SignupInvitation::STATUS_FAILED]);
SignupInvitation::where('id', $i2->id)
->update(['created_at' => now()->subHours('3'), 'status' => SignupInvitation::STATUS_SENT]);
SignupInvitation::where('id', $i3->id)
->update(['created_at' => now()->subHours('4'), 'status' => SignupInvitation::STATUS_COMPLETED]);
SignupInvitation::where('id', $i11->id)->update(['created_at' => now()->subDays('3')]);
// Test paging (load more) feature
$browser->visit(new Invitations())
// ->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true)
->assertElementsCount('@table tbody tr', 10)
->assertSeeIn('#more-loader button', 'Load more')
->with('@table tbody', function ($browser) use ($i1, $i2, $i3) {
$browser->assertSeeIn('tr:nth-child(1) td.email', $i1->email)
->assertText('tr:nth-child(1) td.email svg.text-danger title', 'Sending failed')
->assertVisible('tr:nth-child(1) td.buttons button.button-delete')
->assertVisible('tr:nth-child(1) td.buttons button.button-resend:not(:disabled)')
->assertSeeIn('tr:nth-child(2) td.email', $i2->email)
->assertText('tr:nth-child(2) td.email svg.text-primary title', 'Sent')
->assertVisible('tr:nth-child(2) td.buttons button.button-delete')
->assertVisible('tr:nth-child(2) td.buttons button.button-resend:not(:disabled)')
->assertSeeIn('tr:nth-child(3) td.email', $i3->email)
->assertText('tr:nth-child(3) td.email svg.text-success title', 'User signed up')
->assertVisible('tr:nth-child(3) td.buttons button.button-delete')
->assertVisible('tr:nth-child(3) td.buttons button.button-resend:disabled')
->assertText('tr:nth-child(4) td.email svg title', 'Not sent yet')
->assertVisible('tr:nth-child(4) td.buttons button.button-delete')
->assertVisible('tr:nth-child(4) td.buttons button.button-resend:disabled');
})
->click('#more-loader button')
->whenAvailable('@table tbody tr:nth-child(11)', function ($browser) use ($i11) {
$browser->assertSeeIn('td.email', $i11->email);
})
->assertMissing('#more-loader button');
// Test searching (by domain)
$browser->type('@search-input', 'ext.com')
->click('@search-button')
->waitUntilMissing('@table .app-loader')
->assertElementsCount('@table tbody tr', 3)
->assertMissing('#more-loader button')
// search by full email
->type('@search-input', 'email7@other.com')
->keys('@search-input', '{enter}')
->waitUntilMissing('@table .app-loader')
->assertElementsCount('@table tbody tr', 1)
->assertSeeIn('@table tbody tr:nth-child(1) td.email', 'email7@other.com')
->assertMissing('#more-loader button')
// reset search
->vueClear('#search-form input')
->keys('@search-input', '{enter}')
->waitUntilMissing('@table .app-loader')
->assertElementsCount('@table tbody tr', 10)
->assertVisible('#more-loader button');
});
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Apr 18, 8:25 AM (1 h, 13 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
435502
Default Alt Text
(24 KB)

Event Timeline