From 8e4ad4b9195f168d66d3ce09a12afe736ceb481c Mon Sep 17 00:00:00 2001
From: Satsuki Yanagi <17376330+u1-liquid@users.noreply.github.com>
Date: Wed, 17 Jul 2019 18:59:10 +0900
Subject: [PATCH] Improve usability of users view (#5176)

* Improve usability of users view

Resolve #5173

* Fix query

* Follow review and fix

* Follow review
---
 locales/ja-JP.yml                            |  2 ++
 src/client/app/admin/views/users.user.vue    |  4 +--
 src/client/app/admin/views/users.vue         | 33 +++++++++++++++++---
 src/server/api/endpoints/admin/show-users.ts | 18 +++++++++++
 4 files changed, 50 insertions(+), 7 deletions(-)

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 1c3f22dc2c..76fc26381f 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1502,6 +1502,8 @@ admin/views/users.vue:
   remote-user-updated: "リモートユーザー情報を更新しました"
   delete-all-files: "すべてのファイルを削除"
   delete-all-files-confirm: "すべてのファイルを削除しますか?"
+  username: "ユーザー名"
+  host: "ホスト"
   users:
     title: "ユーザー"
     sort:
diff --git a/src/client/app/admin/views/users.user.vue b/src/client/app/admin/views/users.user.vue
index 929fc8f4b3..25ae0f3f52 100644
--- a/src/client/app/admin/views/users.user.vue
+++ b/src/client/app/admin/views/users.user.vue
@@ -1,5 +1,5 @@
 <template>
-<div class="kofvwchc">
+<div class="kofvwchc" @click="click(user.id)">
 	<div>
 		<a :href="user | userPage(null, true)">
 			<mk-avatar class="avatar" :user="user" :disable-link="true"/>
@@ -32,7 +32,7 @@ import { faSnowflake } from '@fortawesome/free-regular-svg-icons';
 
 export default Vue.extend({
 	i18n: i18n('admin/views/users.vue'),
-	props: ['user'],
+	props: ['user', 'click'],
 	data() {
 		return {
 			faSnowflake, faMicrophoneSlash
diff --git a/src/client/app/admin/views/users.vue b/src/client/app/admin/views/users.vue
index fd9f0dd8b2..57377c7cee 100644
--- a/src/client/app/admin/views/users.vue
+++ b/src/client/app/admin/views/users.vue
@@ -8,7 +8,7 @@
 			</ui-input>
 			<ui-button @click="showUser"><fa :icon="faSearch"/> {{ $t('lookup') }}</ui-button>
 
-			<div class="user" v-if="user">
+			<div ref="user" class="user" v-if="user" :key="user.id">
 				<x-user :user="user"/>
 				<div class="actions">
 					<ui-button v-if="user.host != null" @click="updateRemoteUser"><fa :icon="faSync"/> {{ $t('update-remote-user') }}</ui-button>
@@ -54,8 +54,16 @@
 					<option value="remote">{{ $t('users.origin.remote') }}</option>
 				</ui-select>
 			</ui-horizon-group>
+			<ui-horizon-group searchboxes>
+				<ui-input v-model="searchUsername" type="text" spellcheck="false" @input="fetchUsers(true)">
+					<span>{{ $t('username') }}</span>
+				</ui-input>
+				<ui-input v-model="searchHost" type="text" spellcheck="false" @input="fetchUsers(true)" :readonly="origin === 'local'">
+					<span>{{ $t('host') }}</span>
+				</ui-input>
+			</ui-horizon-group>
 			<sequential-entrance animation="entranceFromTop" delay="25">
-				<x-user v-for="user in users" :user='user' :key="user.id"/>
+				<x-user v-for="user in users" :key="user.id" :user='user' :click="showUserOnClick"/>
 			</sequential-entrance>
 			<ui-button v-if="existMore" @click="fetchUsers">{{ $t('@.load-more') }}</ui-button>
 		</section>
@@ -85,6 +93,8 @@ export default Vue.extend({
 			sort: '+createdAt',
 			state: 'all',
 			origin: 'local',
+			searchUsername: '',
+			searchHost: '',
 			limit: 10,
 			offset: 0,
 			users: [],
@@ -107,6 +117,7 @@ export default Vue.extend({
 		},
 
 		origin() {
+			if (this.origin === 'local') this.searchHost = '';
 			this.users = [];
 			this.offset = 0;
 			this.fetchUsers();
@@ -157,6 +168,15 @@ export default Vue.extend({
 			this.target = '';
 		},
 
+		async showUserOnClick(userId: string) {
+			this.$root.api('admin/show-user', { userId: userId }).then(info => {
+				this.user = info;
+				this.$nextTick(() => {
+					this.$refs.user.scrollIntoView();
+				});
+			});
+		},
+
 		/** 処理対象ユーザーの情報を更新する */
 		async refreshUser() {
 			this.$root.api('admin/show-user', { userId: this.user.id }).then(info => {
@@ -308,13 +328,16 @@ export default Vue.extend({
 			return !confirm.canceled;
 		},
 
-		fetchUsers() {
+		fetchUsers(truncate?: boolean) {
+			if (truncate) this.offset = 0;
 			this.$root.api('admin/show-users', {
 				state: this.state,
 				origin: this.origin,
 				sort: this.sort,
 				offset: this.offset,
-				limit: this.limit + 1
+				limit: this.limit + 1,
+				username: this.searchUsername,
+				hostname: this.searchHost
 			}).then(users => {
 				if (users.length == this.limit + 1) {
 					users.pop();
@@ -322,7 +345,7 @@ export default Vue.extend({
 				} else {
 					this.existMore = false;
 				}
-				this.users = this.users.concat(users);
+				this.users = truncate ? users : this.users.concat(users);
 				this.offset += this.limit;
 			});
 		}
diff --git a/src/server/api/endpoints/admin/show-users.ts b/src/server/api/endpoints/admin/show-users.ts
index 8733d87a38..89e0cf1e2a 100644
--- a/src/server/api/endpoints/admin/show-users.ts
+++ b/src/server/api/endpoints/admin/show-users.ts
@@ -49,6 +49,16 @@ export const meta = {
 				'remote',
 			]),
 			default: 'local'
+		},
+
+		username: {
+			validator: $.optional.str,
+			default: null
+		},
+
+		hostname: {
+			validator: $.optional.str,
+			default: null
 		}
 	}
 };
@@ -70,6 +80,14 @@ export default define(meta, async (ps, me) => {
 		case 'remote': query.andWhere('user.host IS NOT NULL'); break;
 	}
 
+	if (ps.username) {
+		query.andWhere('user.usernameLower like :username', { username: ps.username.toLowerCase() + '%' });
+	}
+
+	if (ps.hostname) {
+		query.andWhere('user.host like :hostname', { hostname: '%' + ps.hostname.toLowerCase() + '%' });
+	}
+
 	switch (ps.sort) {
 		case '+follower': query.orderBy('user.followersCount', 'DESC'); break;
 		case '-follower': query.orderBy('user.followersCount', 'ASC'); break;