Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2513092
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
24 KB
Referenced Files
None
Subscribers
None
View Options
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
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Apr 18, 8:25 AM (2 h, 52 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
435502
Default Alt Text
(24 KB)
Attached To
Mode
R2 kolab
Attached
Detach File
Event Timeline
Log In to Comment