Skip to content

Commit 4954069

Browse files
authored
Merge pull request #329 from IFRCGo/hotfix/WN-411
Add full-text user search and search UI
2 parents 1d7214d + 2af1e80 commit 4954069

30 files changed

Lines changed: 239 additions & 7 deletions

File tree

app/Http/Requests/UserListRequest.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ public function rules()
2121
{
2222
return [
2323
'orderBy' => 'in:first_name,last_name,organisation,industry_type,created_at,last_logged_in_at',
24-
'sort' => 'in:asc,desc'
24+
'sort' => 'in:asc,desc',
25+
'filters.search' => 'nullable|string|min:3|max:191'
2526
];
2627
}
2728

@@ -36,8 +37,10 @@ public function getUserQuery(): UserQuery
3637
$userQuery->setOrderBy($orderBy);
3738
$userQuery->setSort($sort);
3839

39-
foreach ($this->get('filters', []) as $column => $value) {
40-
$userQuery->addFilter($column, $value);
40+
$filters = $this->get('filters', $this->get('filters\\', []));
41+
42+
foreach ($filters as $column => $value) {
43+
$userQuery->addFilter(rtrim($column, '\\'), $value);
4144
}
4245

4346
return $userQuery;

app/Repositories/Access/UserRepository.php

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,11 @@ public function queryUsers(UserQuery $userQuery): Builder
186186
});
187187
});
188188

189+
$search = trim($userQuery->getFilters()->get('search', ''));
190+
if (mb_strlen($search) >= 3) {
191+
$this->applySearchFilter($builder, $search);
192+
}
193+
189194
$society = $userQuery->getFilters()->only(['society'])->first();
190195
if ($society) {
191196
$builder->whereHas('organisations', function ($query) use ($society) {
@@ -214,12 +219,25 @@ public function queryUsers(UserQuery $userQuery): Builder
214219
}
215220

216221
if (in_array($userQuery->getOrderBy(), ['first_name', 'last_name', 'organisation', 'industry_type'])) {
217-
$builder->orderByRaw('(SELECT ' . $userQuery->getOrderBy() . ' FROM user_profiles WHERE user_profiles.user_id = users.id) ' . $userQuery->getSort());
222+
$builder->orderByRaw('(SELECT ' . $userQuery->getOrderBy() . ' FROM user_profiles WHERE user_profiles.user_id = users.id AND user_profiles.deleted_at IS NULL ORDER BY user_profiles.id DESC LIMIT 1) ' . $userQuery->getSort());
218223
}
219224

220225
return $builder;
221226
}
222227

228+
private function applySearchFilter(Builder $builder, string $search)
229+
{
230+
$profileMatch = 'MATCH(user_profiles.first_name, user_profiles.last_name) AGAINST (? IN NATURAL LANGUAGE MODE)';
231+
$emailMatch = 'MATCH(users.email) AGAINST (? IN NATURAL LANGUAGE MODE)';
232+
$profileScore = "(SELECT {$profileMatch} FROM user_profiles WHERE user_profiles.user_id = users.id AND user_profiles.deleted_at IS NULL ORDER BY user_profiles.id DESC LIMIT 1)";
233+
$score = "({$emailMatch} + COALESCE({$profileScore}, 0))";
234+
235+
$builder->select('users.*')
236+
->selectRaw("{$score} as search_score", [$search, $search])
237+
->whereRaw("({$emailMatch} > 0 OR EXISTS (SELECT 1 FROM user_profiles WHERE user_profiles.user_id = users.id AND user_profiles.deleted_at IS NULL AND {$profileMatch} > 0))", [$search, $search])
238+
->orderBy('search_score', 'desc');
239+
}
240+
223241
public function deactivate(User $user)
224242
{
225243
$user->activated = false;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Support\Facades\DB;
5+
6+
class AddFulltextIndexesForUserSearch extends Migration
7+
{
8+
9+
public function up()
10+
{
11+
if (env('DB_CONNECTION') !== 'mysql') {
12+
return;
13+
}
14+
15+
if (!$this->indexExists('users', 'users_email_fulltext_search')) {
16+
DB::statement('ALTER TABLE users ADD FULLTEXT users_email_fulltext_search(email)');
17+
}
18+
19+
$this->dropIndexIfExists('user_profiles', 'user_profiles_search_fulltext');
20+
DB::statement('ALTER TABLE user_profiles ADD FULLTEXT user_profiles_search_fulltext(first_name, last_name)');
21+
}
22+
23+
24+
public function down()
25+
{
26+
if (env('DB_CONNECTION') !== 'mysql') {
27+
return;
28+
}
29+
30+
$this->dropIndexIfExists('users', 'users_email_fulltext_search');
31+
$this->dropIndexIfExists('user_profiles', 'user_profiles_search_fulltext');
32+
}
33+
34+
private function dropIndexIfExists(string $table, string $index)
35+
{
36+
if ($this->indexExists($table, $index)) {
37+
DB::statement("ALTER TABLE {$table} DROP INDEX {$index}");
38+
}
39+
}
40+
41+
private function indexExists(string $table, string $index): bool
42+
{
43+
return (bool) DB::select(
44+
DB::raw(
45+
"SHOW KEYS
46+
FROM {$table}
47+
WHERE Key_name='{$index}'"
48+
)
49+
);
50+
}
51+
}

resources/assets/js/pages/users/list.vue

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@
6767
:staynull="true"
6868
v-if="!apiUsers"/>
6969
</b-col>
70+
<b-col cols="12" md="6" lg="4" xl="3" class="ml-lg-auto">
71+
<p class="select-header"> {{ $t('users.list.search') }}</p>
72+
<b-form-input
73+
v-model.trim="searchFilter"
74+
class="search-filter-input"
75+
type="text"
76+
:disabled="fetchingUsers"
77+
:placeholder="$t('users.list.search_placeholder')">
78+
</b-form-input>
79+
</b-col>
7080
<b-col class="text-right">
7181
<b-button @click="clearFilters" :disabled="noFilters" class="btn-outline-primary clear-filter-btn">
7282
{{ $t('users.list.clear_filters') }}
@@ -226,6 +236,7 @@ export default {
226236
fetchingUsers: false,
227237
roles: null,
228238
locationOptions: null,
239+
searchDebounce: null,
229240
countries: require('country-list')()
230241
}
231242
},
@@ -239,6 +250,21 @@ export default {
239250
deep: true
240251
},
241252
activatedFilter: fetchHandler,
253+
searchFilter: {
254+
handler (val, oldVal) {
255+
if (val !== oldVal) {
256+
clearTimeout(this.searchDebounce)
257+
const search = val ? val.trim() : ''
258+
if (search.length > 0 && search.length < 3) {
259+
return
260+
}
261+
this.searchDebounce = setTimeout(() => {
262+
this.currentPage = 1
263+
this.fetchUsers()
264+
}, 350)
265+
}
266+
}
267+
},
242268
roleFilter: fetchHandler,
243269
countryFilter: fetchHandler,
244270
termsFilter: fetchHandler,
@@ -259,6 +285,9 @@ export default {
259285
this.fetchUsers()
260286
this.fetchTerms()
261287
},
288+
beforeDestroy () {
289+
clearTimeout(this.searchDebounce)
290+
},
262291
metaInfo () {
263292
return { title: this.$t('users.list.manage') }
264293
},
@@ -268,6 +297,7 @@ export default {
268297
this.roleFilter = null
269298
this.countryFilter = null
270299
this.selectedSoc = null
300+
this.searchFilter = ''
271301
this.termsFilter = termsDefault
272302
},
273303
getSocietyByCode (code) {
@@ -310,12 +340,14 @@ export default {
310340
},
311341
async fetchUsers () {
312342
this.fetchingUsers = true
313-
await this.fetchRoles()
343+
if (this.rolesEmpty) {
344+
await this.fetchRoles()
345+
}
314346
315-
let apiUserRole = this.roleOptions.find(role => role.name === 'API User')
347+
const apiUserRole = this.roleOptions.find(role => role.name === 'API User')
316348
let filterRoleId = this.roleFilter
317349
if (filterRoleId === null) {
318-
filterRoleId = this.apiUsers ? apiUserRole.id : null
350+
filterRoleId = this.apiUsers && apiUserRole ? apiUserRole.id : null
319351
}
320352
321353
if (!apiUserRole && this.apiUsers) {
@@ -330,6 +362,7 @@ export default {
330362
role: filterRoleId,
331363
society: this.selectedSoc ? this.selectedSoc.countryCode : null,
332364
country_code: this.countryFilter,
365+
search: this.searchFilter,
333366
terms_version: this.termsFilter === termsDefault ? null : this.termsFilter
334367
},
335368
admin: !this.apiUsers && filterRoleId === null,
@@ -408,6 +441,14 @@ export default {
408441
return this.$store.state.users.filters.roleFilter
409442
}
410443
},
444+
searchFilter: {
445+
set: function (newVal) {
446+
this.$store.dispatch('users/setFilter', { searchFilter: newVal })
447+
},
448+
get: function () {
449+
return this.$store.state.users.filters.searchFilter
450+
}
451+
},
411452
countryFilter: {
412453
set: function(newVal) {
413454
this.$store.dispatch('users/setFilter', { countryFilter: newVal })
@@ -451,6 +492,7 @@ export default {
451492
this.roleFilter === null &&
452493
this.countryFilter === null &&
453494
this.selectedSoc === null &&
495+
!this.searchFilter &&
454496
this.termsFilter === termsDefault
455497
},
456498
...mapGetters({
@@ -487,5 +529,11 @@ export default {
487529
.text-nowrap {
488530
white-space: nowrap;
489531
}
532+
.search-filter-input {
533+
background: #E9E9E9;
534+
border: none;
535+
border-radius: 10px;
536+
height: 2.45rem;
537+
}
490538
491539
</style>

resources/assets/js/plugins/axios.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ axios.interceptors.request.use(request => {
2323

2424
// Response interceptor
2525
axios.interceptors.response.use(response => response, error => {
26+
if (axios.isCancel(error)) {
27+
return Promise.reject(error)
28+
}
29+
2630
if (error.response && error.response.status) {
2731
const { status } = error.response
2832

resources/assets/js/store/modules/users.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import axios from 'axios'
22
import * as types from '../mutation-types'
33
import querystring from 'querystring'
44

5+
let latestFetchUsersRequest = 0
6+
57
// state
68
export const state = {
79
users: {
@@ -24,6 +26,7 @@ export const state = {
2426
roleFilter: null,
2527
countryFilter: null,
2628
selectedSoc: null,
29+
searchFilter: '',
2730
termsFilter: 'Terms and Conditions'
2831
}
2932
}
@@ -104,6 +107,7 @@ export const actions = {
104107
roleFilter: null,
105108
countryFilter: null,
106109
selectedSoc: null,
110+
searchFilter: '',
107111
termsFilter: 'Terms and Conditions'
108112
})
109113
commit(types.SET_ORDER_BY, null)
@@ -120,6 +124,7 @@ export const actions = {
120124
commit(types.SET_SORT_DESC, sortDesc)
121125
},
122126
async fetchUsers ({ commit }, { page, filters, excludes, admin, orderBy, sort }) {
127+
const requestId = ++latestFetchUsersRequest
123128
const queryOptions = { page }
124129
if (orderBy !== null) {
125130
queryOptions.orderBy = orderBy
@@ -131,11 +136,19 @@ export const actions = {
131136
filterString += filters.society !== null ? `&filters[society]=${filters.society}` : ''
132137
filterString += filters.country_code !== null ? `&filters[country_code]=${filters.country_code}` : ''
133138
filterString += filters.terms_version !== null ? `&filters[terms_version]=${filters.terms_version}` : ''
139+
const search = filters.search ? filters.search.trim() : ''
140+
filterString += search.length >= 3 ? `&filters[search]=${encodeURIComponent(search)}` : ''
134141

135142
const url = admin ? '/api/users/admins' : '/api/users'
136143
const { data } = await axios.get(`${url}?${querystring.stringify(queryOptions)}${filterString}`)
144+
if (requestId !== latestFetchUsersRequest) {
145+
return
146+
}
137147
commit(types.FETCH_USERS_SUCCESS, { users: data })
138148
} catch (e) {
149+
if (requestId !== latestFetchUsersRequest) {
150+
return
151+
}
139152
commit(types.FETCH_USERS_FAILURE, { error: e })
140153
}
141154
},

resources/lang/am.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,8 @@
343343
"select_country": "ሀገር ምረጥ",
344344
"select_terms": "የውሎች ስሪት ምረጥ",
345345
"select_society": "የብሔራዊ ማህበር ምረጥ",
346+
"search": "ፈልግ",
347+
"search_placeholder": "ስም ወይም ኢሜይል (ቢያንስ 3 ቁምፊዎች)",
346348
"create": "አዲስ መጠቀሚያ ይፍጠሩ፤",
347349
"download_report": "ሪፖርት ያውርዱ፤",
348350
"empty": "ምንም መጠቀሚያ ማግኘት አልተቻለም፤",

resources/lang/ar.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,8 @@
349349
"select_country": "اختر بلدًا",
350350
"select_terms": "اختر إصدار الشروط",
351351
"select_society": "اختر جمعية وطنية",
352+
"search": "بحث",
353+
"search_placeholder": "الاسم أو البريد الإلكتروني (3 أحرف على الأقل)",
352354
"create": "إنشاء مستخدم جديد",
353355
"download_report": "تنزيل التقرير",
354356
"empty": "لم يتم العثور على أي مستخدمين",

resources/lang/bn.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,8 @@
343343
"select_country": "একটি দেশ নির্বাচন করুন",
344344
"select_terms": "একটি শর্তাবলী সংস্করণ নির্বাচন করুন",
345345
"select_society": "জাতীয় সমিতি নির্বাচন করুন",
346+
"search": "অনুসন্ধান",
347+
"search_placeholder": "নাম বা ইমেল (কমপক্ষে ৩ অক্ষর)",
346348
"create": "নতুন ব্যবহারকারী তৈরি করুন",
347349
"download_report": "রিপোর্ট ডাউনলোড করুন",
348350
"empty": "কোন ব্যবহারকারী খুঁজে পাইনি",

resources/lang/de.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,8 @@
343343
"select_country": "Land auswählen",
344344
"select_terms": "Version der Bedingungen auswählen",
345345
"select_society": "Nationale Gesellschaft auswählen",
346+
"search": "Suchen",
347+
"search_placeholder": "Name oder E-Mail (mind. 3 Zeichen)",
346348
"create": "Neuen Benutzer anlegen",
347349
"download_report": "Bericht herunterladen",
348350
"empty": "Es konnten keine Benutzer gefunden werden",

0 commit comments

Comments
 (0)