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 @@
{{ $t('user.list-title') }} - - {{ $t('user.create') }} -
- +
+
+ + + +
+ + {{ $t('user.create') }} + +
+
+
@@ -29,6 +37,9 @@
{{ $t('form.primary-email') }}
+
+ +
@@ -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 }) + } } } 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 @@ +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 } /**