Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117753595
D2966.1775195032.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
19 KB
Referenced Files
None
Subscribers
None
D2966.1775195032.diff
View Options
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -76,12 +76,56 @@
public function index()
{
$user = $this->guard()->user();
+ $search = trim(request()->input('search'));
+ $page = intval(request()->input('page')) ?: 1;
+ $pageSize = 20;
+ $hasMore = false;
+ $result = collect([]);
+
+ $result = $user->users();
+
+ // Search by user email, alias or name
+ if (strlen($search) > 0) {
+ // thanks to cloning we skip some extra queries in $user->users()
+ $allUsers1 = clone $result;
+ $allUsers2 = clone $result;
+
+ $result->whereLike('email', $search)
+ ->union(
+ $allUsers1->join('user_aliases', 'users.id', '=', 'user_aliases.user_id')
+ ->whereLike('alias', $search)
+ )
+ ->union(
+ $allUsers2->join('user_settings', 'users.id', '=', 'user_settings.user_id')
+ ->whereLike('value', $search)
+ ->whereIn('key', ['first_name', 'last_name'])
+ );
+ }
+
+ $result = $result->orderBy('email')
+ ->limit($pageSize + 1)
+ ->offset($pageSize * ($page - 1))
+ ->get();
+
+ if (count($result) > $pageSize) {
+ $result->pop();
+ $hasMore = true;
+ }
+
+ // Process the result
+ $result = $result->map(
+ function ($user) {
+ $data = $user->toArray();
+ $data = array_merge($data, self::userStatuses($user));
+ return $data;
+ }
+ );
- $result = $user->users()->orderBy('email')->get()->map(function ($user) {
- $data = $user->toArray();
- $data = array_merge($data, self::userStatuses($user));
- return $data;
- });
+ $result = [
+ 'list' => $result,
+ 'count' => count($result),
+ 'hasMore' => $hasMore,
+ ];
return response()->json($result);
}
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -382,6 +382,7 @@
'reset-2fa' => "Reset 2-Factor Auth",
'reset-2fa-title' => "2-Factor Authentication Reset",
'title' => "User account",
+ 'search' => "User email address or name",
'search-pl' => "User ID, email or domain",
'skureq' => "{sku} requires {list}.",
'subscription' => "Subscription",
diff --git a/src/resources/vue/User/List.vue b/src/resources/vue/User/List.vue
--- a/src/resources/vue/User/List.vue
+++ b/src/resources/vue/User/List.vue
@@ -4,12 +4,20 @@
<div class="card-body">
<div class="card-title">
{{ $t('user.list-title') }}
- <router-link class="btn btn-success float-end create-user" :to="{ path: 'user/new' }" tag="button">
- <svg-icon icon="user"></svg-icon> {{ $t('user.create') }}
- </router-link>
</div>
<div class="card-text">
- <table class="table table-sm table-hover">
+ <div class="mb-2 d-flex">
+ <form @submit.prevent="searchUsers" id="search-form" class="input-group" style="flex:1">
+ <input class="form-control" type="text" :placeholder="$t('user.search')" v-model="search">
+ <button type="submit" class="btn btn-primary"><svg-icon icon="search"></svg-icon> {{ $t('btn.search') }}</button>
+ </form>
+ <div>
+ <router-link class="btn btn-success ms-1 create-user" :to="{ path: 'user/new' }" tag="button">
+ <svg-icon icon="user"></svg-icon> {{ $t('user.create') }}
+ </router-link>
+ </div>
+ </div>
+ <table id="users-list" class="table table-sm table-hover">
<thead>
<tr>
<th scope="col">{{ $t('form.primary-email') }}</th>
@@ -29,6 +37,9 @@
</tr>
</tfoot>
</table>
+ <div class="text-center p-3" id="more-loader" v-if="hasMore">
+ <button class="btn btn-secondary" @click="loadUsers(true)">{{ $t('nav.more') }}</button>
+ </div>
</div>
</div>
</div>
@@ -39,19 +50,69 @@
export default {
data() {
return {
- users: [],
- current_user: null
+ hasMore: false,
+ page: 1,
+ search: '',
+ users: []
}
},
- created() {
+ mounted() {
this.$root.startLoading()
+ this.loadUsers(null, () => this.$root.stopLoading())
+ },
+ methods: {
+ loadUsers(params, callback) {
+ let loader
+ let get = {}
+
+ if (params) {
+ if (params.reset) {
+ this.users = []
+ 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' : '#users-list tfoot td')
+ } else {
+ this.currentSearch = null
+ }
- axios.get('/api/v4/users')
- .then(response => {
- this.$root.stopLoading()
- this.users = response.data
- })
- .catch(this.$root.errorHandler)
+ this.$root.addLoader(loader)
+
+ axios.get('/api/v4/users', { 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.users, this.users.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()
+ }
+ })
+ },
+ searchUsers() {
+ this.loadUsers({ reset: true, search: this.search })
+ }
}
}
</script>
diff --git a/src/tests/Browser.php b/src/tests/Browser.php
--- a/src/tests/Browser.php
+++ b/src/tests/Browser.php
@@ -229,9 +229,12 @@
*/
public function vueClear($selector)
{
+/*
if ($this->resolver->prefix != 'body') {
$selector = $this->resolver->prefix . ' ' . $selector;
}
+*/
+ $selector = $this->resolver->format($selector);
// The existing clear(), and type() with empty string do not work.
// We have to clear the field and dispatch 'input' event programatically.
diff --git a/src/tests/Browser/Pages/UserList.php b/src/tests/Browser/Pages/UserList.php
--- a/src/tests/Browser/Pages/UserList.php
+++ b/src/tests/Browser/Pages/UserList.php
@@ -39,6 +39,7 @@
{
return [
'@app' => '#app',
+ '@search' => '#search-form',
'@table' => '#user-list table',
];
}
diff --git a/src/tests/Browser/UserListTest.php b/src/tests/Browser/UserListTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/UserListTest.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace Tests\Browser;
+
+use App\User;
+use Tests\Browser;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\UserInfo;
+use Tests\Browser\Pages\UserList;
+use Tests\TestCaseDusk;
+
+class UserListTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ parent::tearDown();
+ }
+
+ /**
+ * Test users list page (unauthenticated)
+ */
+ public function testListUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/users')->on(new Home());
+ });
+ }
+
+ /**
+ * Test users list page
+ */
+ public function testList(): void
+ {
+ $this->browse(function (Browser $browser) {
+ // Test that the page requires authentication
+ // Test the list
+ $browser->visit(new Home())
+ ->submitLogon('john@kolab.org', 'simple123', true)
+ ->on(new Dashboard())
+ ->assertSeeIn('@links .link-users', 'User accounts')
+ ->click('@links .link-users')
+ ->on(new UserList())
+ ->whenAvailable('@table', function (Browser $browser) {
+ $browser->waitFor('tbody tr')
+ ->assertElementsCount('tbody tr', 4)
+ ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
+ ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org')
+ ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org')
+ ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org')
+ ->assertMissing('tfoot');
+ });
+
+ // Test searching
+ $browser->assertValue('@search input', '')
+ ->assertAttribute('@search input', 'placeholder', 'User email address or name')
+ ->assertSeeIn('@search button', 'Search')
+ ->type('@search input', 'jo')
+ ->click('@search button')
+ ->waitUntilMissing('@app .app-loader')
+ ->whenAvailable('@table', function (Browser $browser) {
+ $browser->waitFor('tbody tr')
+ ->assertElementsCount('tbody tr', 2)
+ ->assertSeeIn('tbody tr:nth-child(1) a', 'joe@kolab.org')
+ ->assertSeeIn('tbody tr:nth-child(2) a', 'john@kolab.org')
+ ->assertMissing('tfoot');
+ })
+ // reset search
+ ->vueClear('@search input')
+ ->keys('@search input', '{enter}')
+ ->waitUntilMissing('@app .app-loader')
+ ->whenAvailable('@table', function (Browser $browser) {
+ $browser->waitFor('tbody tr')->assertElementsCount('tbody tr', 4);
+ });
+
+ // TODO: Test paging
+
+ $browser->click('@table tr:nth-child(3)')
+ ->on(new UserInfo())
+ ->assertSeeIn('#user-info .card-title', 'User account')
+ ->with('@general', function (Browser $browser) {
+ $browser->assertValue('#email', 'john@kolab.org');
+ });
+ });
+ }
+}
diff --git a/src/tests/Browser/UsersTest.php b/src/tests/Browser/UsersTest.php
--- a/src/tests/Browser/UsersTest.php
+++ b/src/tests/Browser/UsersTest.php
@@ -87,65 +87,19 @@
parent::tearDown();
}
- /**
- * Test user info page (unauthenticated)
- */
- public function testInfoUnauth(): void
- {
- // Test that the page requires authentication
- $this->browse(function (Browser $browser) {
- $user = User::where('email', 'john@kolab.org')->first();
-
- $browser->visit('/user/' . $user->id)->on(new Home());
- });
- }
-
- /**
- * Test users list page (unauthenticated)
- */
- public function testListUnauth(): void
- {
- // Test that the page requires authentication
- $this->browse(function (Browser $browser) {
- $browser->visit('/users')->on(new Home());
- });
- }
-
- /**
- * Test users list page
- */
- public function testList(): void
- {
- // Test that the page requires authentication
- $this->browse(function (Browser $browser) {
- $browser->visit(new Home())
- ->submitLogon('john@kolab.org', 'simple123', true)
- ->on(new Dashboard())
- ->assertSeeIn('@links .link-users', 'User accounts')
- ->click('@links .link-users')
- ->on(new UserList())
- ->whenAvailable('@table', function (Browser $browser) {
- $browser->waitFor('tbody tr')
- ->assertElementsCount('tbody tr', 4)
- ->assertSeeIn('tbody tr:nth-child(1) a', 'jack@kolab.org')
- ->assertSeeIn('tbody tr:nth-child(2) a', 'joe@kolab.org')
- ->assertSeeIn('tbody tr:nth-child(3) a', 'john@kolab.org')
- ->assertSeeIn('tbody tr:nth-child(4) a', 'ned@kolab.org')
- ->assertMissing('tfoot');
- });
- });
- }
-
/**
* Test user account editing page (not profile page)
- *
- * @depends testList
*/
public function testInfo(): void
{
$this->browse(function (Browser $browser) {
- $browser->on(new UserList())
- ->click('@table tr:nth-child(3) a')
+ $user = User::where('email', 'john@kolab.org')->first();
+
+ // Test that the page requires authentication
+ $browser->visit('/user/' . $user->id)
+ ->on(new Home())
+ ->screenshot('aa')
+ ->submitLogon('john@kolab.org', 'simple123', false)
->on(new UserInfo())
->assertSeeIn('#user-info .card-title', 'User account')
->with('@general', function (Browser $browser) {
@@ -366,7 +320,7 @@
/**
* Test user adding page
*
- * @depends testList
+ * @depends testInfo
*/
public function testNewUser(): void
{
@@ -746,7 +700,7 @@
/**
* Test beta entitlements
*
- * @depends testList
+ * @depends testInfo
*/
public function testBetaEntitlements(): void
{
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -196,35 +196,76 @@
$json = $response->json();
- $this->assertCount(0, $json);
+ $this->assertSame(false, $json['hasMore']);
+ $this->assertSame(0, $json['count']);
+ $this->assertCount(0, $json['list']);
$response = $this->actingAs($john)->get("/api/v4/users");
$response->assertStatus(200);
$json = $response->json();
- $this->assertCount(4, $json);
- $this->assertSame($jack->email, $json[0]['email']);
- $this->assertSame($joe->email, $json[1]['email']);
- $this->assertSame($john->email, $json[2]['email']);
- $this->assertSame($ned->email, $json[3]['email']);
+ $this->assertSame(false, $json['hasMore']);
+ $this->assertSame(4, $json['count']);
+ $this->assertCount(4, $json['list']);
+ $this->assertSame($jack->email, $json['list'][0]['email']);
+ $this->assertSame($joe->email, $json['list'][1]['email']);
+ $this->assertSame($john->email, $json['list'][2]['email']);
+ $this->assertSame($ned->email, $json['list'][3]['email']);
// Values below are tested by Unit tests
- $this->assertArrayHasKey('isDeleted', $json[0]);
- $this->assertArrayHasKey('isSuspended', $json[0]);
- $this->assertArrayHasKey('isActive', $json[0]);
- $this->assertArrayHasKey('isLdapReady', $json[0]);
- $this->assertArrayHasKey('isImapReady', $json[0]);
+ $this->assertArrayHasKey('isDeleted', $json['list'][0]);
+ $this->assertArrayHasKey('isSuspended', $json['list'][0]);
+ $this->assertArrayHasKey('isActive', $json['list'][0]);
+ $this->assertArrayHasKey('isLdapReady', $json['list'][0]);
+ $this->assertArrayHasKey('isImapReady', $json['list'][0]);
$response = $this->actingAs($ned)->get("/api/v4/users");
$response->assertStatus(200);
$json = $response->json();
- $this->assertCount(4, $json);
- $this->assertSame($jack->email, $json[0]['email']);
- $this->assertSame($joe->email, $json[1]['email']);
- $this->assertSame($john->email, $json[2]['email']);
- $this->assertSame($ned->email, $json[3]['email']);
+ $this->assertSame(false, $json['hasMore']);
+ $this->assertSame(4, $json['count']);
+ $this->assertCount(4, $json['list']);
+ $this->assertSame($jack->email, $json['list'][0]['email']);
+ $this->assertSame($joe->email, $json['list'][1]['email']);
+ $this->assertSame($john->email, $json['list'][2]['email']);
+ $this->assertSame($ned->email, $json['list'][3]['email']);
+
+ // Search by user email
+ $response = $this->actingAs($john)->get("/api/v4/users?search=jack@k");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(false, $json['hasMore']);
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($jack->email, $json['list'][0]['email']);
+
+ // Search by alias
+ $response = $this->actingAs($john)->get("/api/v4/users?search=monster");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(false, $json['hasMore']);
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($joe->email, $json['list'][0]['email']);
+
+ // Search by name
+ $response = $this->actingAs($john)->get("/api/v4/users?search=land");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(false, $json['hasMore']);
+ $this->assertSame(1, $json['count']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($ned->email, $json['list'][0]['email']);
+
+ // TODO: Test paging
}
/**
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Fri, Apr 3, 5:43 AM (2 d, 20 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18822740
Default Alt Text
D2966.1775195032.diff (19 KB)
Attached To
Mode
D2966: User list search/paging
Attached
Detach File
Event Timeline