Add filter component to quick search [SCI-10305]

This commit is contained in:
Anton 2024-03-26 15:07:23 +01:00
parent 6c5efb604f
commit c6f5997fda
13 changed files with 473 additions and 23 deletions

View file

@ -33,7 +33,7 @@
}
.btn.btn-xs.icon-btn {
@apply px-0.5;
@apply px-0.5 w-[30px];
}
.btn:hover {

View file

@ -9,9 +9,23 @@ class TeamsController < ApplicationController
before_action :load_vars, only: %i(sidebar export_projects export_projects_modal
disable_tasks_sharing_modal shared_tasks_toggle)
before_action :load_current_folder, only: :sidebar
before_action :check_read_permissions, except: :view_type
before_action :check_read_permissions, except: %i(view_type visible_teams visible_users)
before_action :check_export_projects_permissions, only: %i(export_projects_modal export_projects)
def visible_teams
teams = current_user.teams
render json: teams, each_serializer: TeamSerializer
end
def visible_users
teams = current_user.teams
if params[:teams].present?
teams = teams.where(id: params[:teams])
end
users = User.where(id: teams.joins(:users).select('users.id')).order(:full_name)
render json: users, each_serializer: UserSerializer, user: current_user
end
def sidebar
render json: {
html: render_to_string(

View file

@ -24,10 +24,21 @@
{{ i18n.t('search.index.task_results') }}
</button>
</div>
<button class="btn btn-light btn-sm">
<i class="sn-icon sn-icon-search-options"></i>
{{ i18n.t('search.index.more_search_options') }}
</button>
<GeneralDropdown ref="filterFlyout" position="right">
<template v-slot:field>
<button class="btn btn-light btn-sm">
<i class="sn-icon sn-icon-search-options"></i>
{{ i18n.t('search.index.more_search_options') }}
</button>
</template>
<template v-slot:flyout >
<SearchFilters
:teamsUrl="teamsUrl"
:usersUrl="usersUrl"
@cancel="this.$refs.container.close()"
></SearchFilters>
</template>
</GeneralDropdown>
<template v-if="activeGroup">
<div class="h-4 w-[1px] bg-sn-grey"></div>
<button class="btn btn-light btn-sm" @click="activeGroup = null">
@ -62,6 +73,8 @@ import RepositoryRowsComponent from './groups/repository_rows.vue';
import ProtocolsComponent from './groups/protocols.vue';
import LabelTemplatesComponent from './groups/label_templates.vue';
import ReportsComponent from './groups/reports.vue';
import SearchFilters from './filters.vue';
import GeneralDropdown from '../shared/general_dropdown.vue';
export default {
name: 'GlobalSearch',
@ -73,6 +86,14 @@ export default {
searchUrl: {
type: String,
required: true
},
teamsUrl: {
type: String,
required: true
},
usersUrl: {
type: String,
required: true
}
},
components: {
@ -86,7 +107,9 @@ export default {
RepositoryRowsComponent,
ProtocolsComponent,
LabelTemplatesComponent,
ReportsComponent
ReportsComponent,
SearchFilters,
GeneralDropdown
},
data() {
return {

View file

@ -0,0 +1,206 @@
<template>
<div class="max-w-[600px] p-3.5">
<div class="sci-label mb-2">{{ i18n.t('search.filters.by_type') }}</div>
<div class="flex items-center gap-2 flex-wrap mb-6">
<template v-for="group in searchGroups" :key="group.value">
<button class="btn btn-secondary btn-xs"
:class="{'active': activeGroup === group.value}"
@click="setActiveGroup(group.value)">
{{ group.label }}
</button>
</template>
</div>
<div class="sci-label mb-2">{{ i18n.t('search.filters.by_created_date') }}</div>
<DateFilter
:date="createdAt"
ref="createdAtComponent"
class="mb-6"
@change="(v) => {this.createdAt = v}"
></DateFilter>
<div class="sci-label mb-2">{{ i18n.t('search.filters.by_updated_date') }}</div>
<DateFilter
:date="updatedAt"
ref="updatedAtComponent"
class="mb-6"
@change="(v) => {this.updatedAt = v}"
></DateFilter>
<div class="sci-label mb-2">{{ i18n.t('search.filters.by_team') }}</div>
<SelectDropdown :options="teams"
class="mb-6"
:with-checkboxes="true"
:clearable="true"
:multiple="true"
:value="selectedTeams"
@change="(v) => {selectedTeams = v}" />
<div class="sci-label mb-2">{{ i18n.t('search.filters.by_user') }}</div>
<SelectDropdown :options="users"
class="mb-6"
:value="selectedUsers"
:optionRenderer="userRenderer"
:labelRenderer="userRenderer"
:clearable="true"
:with-checkboxes="true"
:multiple="true"
@change="(v) => {selectedUsers = v}" />
<div class="flex items-center gap-2">
<div class="sci-checkbox-container">
<input type="checkbox" v-model="includeArchived" class="sci-checkbox" />
<span class="sci-checkbox-label"></span>
</div>
{{ i18n.t('search.filters.include_archived') }}
</div>
<hr class="my-6">
<div class="flex items-center gap-6">
<button class="btn btn-light" @click="clearFilters">{{ i18n.t('search.filters.clear') }}</button>
<button class="btn btn-secondary ml-auto" @click="$emit('cancel')">{{ i18n.t('general.cancel') }}</button>
<button class="btn btn-primary" @click="search" >{{ i18n.t('general.search') }}</button>
</div>
</div>
</template>
<script>
import DateFilter from './filters/date.vue';
import SelectDropdown from '../shared/select_dropdown.vue';
import axios from '../../packs/custom_axios.js';
export default {
name: 'SearchFilters',
props: {
teamsUrl: {
type: String,
required: true
},
usersUrl: {
type: String,
required: true
},
currentTeam: Number,
searchUrl: String,
searchQuery: String
},
created() {
this.fetchTeams();
if (this.currentTeam) {
this.selectedTeams = [this.currentTeam];
}
},
watch: {
selectedTeams() {
this.selectedUsers = [];
this.fetchUsers();
}
},
data() {
return {
activeGroup: null,
createdAt: {
on: null,
from: null,
to: null
},
updatedAt: {
on: null,
from: null,
to: null
},
selectedTeams: [],
selectedUsers: [],
includeArchived: true,
teams: [],
users: [],
searchGroups: [
{ value: 'FoldersComponent', label: this.i18n.t('search.index.folders') },
{ value: 'ProjectsComponent', label: this.i18n.t('search.index.projects') },
{ value: 'ExperimentsComponent', label: this.i18n.t('search.index.experiments') },
{ value: 'MyModulesComponent', label: this.i18n.t('search.index.tasks') },
{ value: 'MyModuleProtocolsComponent', label: this.i18n.t('search.index.task_protocols') },
{ value: 'ResultsComponent', label: this.i18n.t('search.index.task_results') },
{ value: 'AssetsComponent', label: this.i18n.t('search.index.files') },
{ value: 'RepositoryRowsComponent', label: this.i18n.t('search.index.inventory_items') },
{ value: 'ProtocolsComponent', label: this.i18n.t('search.index.protocol_templates') },
{ value: 'LabelTemplatesComponent', label: this.i18n.t('search.index.label_templates') },
{ value: 'ReportsComponent', label: this.i18n.t('search.index.reports') }
]
};
},
components: {
DateFilter,
SelectDropdown
},
methods: {
userRenderer(option) {
return `<div class="flex items-center gap-2">
<img src="${option[2].avatar_url}" class="rounded-full w-6 h-6" />
<div title="${option[1]}" class="truncate">${option[1]}</div>
</div>`;
},
setActiveGroup(group) {
if (group === this.activeGroup) {
this.activeGroup = null;
} else {
this.activeGroup = group;
}
},
fetchTeams() {
axios.get(this.teamsUrl)
.then((response) => {
this.teams = response.data.data.map((team) => ([parseInt(team.id, 10), team.attributes.name]));
});
},
fetchUsers() {
axios.get(this.usersUrl, { params: { teams: this.selectedTeams } })
.then((response) => {
this.users = response.data.data.map((user) => ([parseInt(user.id, 10), user.attributes.name, { avatar_url: user.attributes.avatar_url }]));
});
},
clearFilters() {
this.createdAt = {
on: null,
from: null,
to: null
};
this.updatedAt = {
on: null,
from: null,
to: null
};
this.$refs.createdAtComponent.selectedOption = 'on';
this.$refs.updatedAtComponent.selectedOption = 'on';
this.selectedTeams = [];
this.selectedUsers = [];
this.includeArchived = false;
this.activeGroup = null;
},
search() {
if (this.searchUrl) {
this.openSearchPage();
}
},
openSearchPage() {
const params = {
'created_at[on]': this.createdAt.on || '',
'created_at[from]': this.createdAt.from || '',
'created_at[to]': this.createdAt.to || '',
'updated_at[on]': this.updatedAt.on || '',
'updated_at[from]': this.updatedAt.from || '',
'updated_at[to]': this.updatedAt.to || '',
include_archived: this.includeArchived,
group: this.activeGroup || '',
q: this.searchQuery
};
const searchParams = new URLSearchParams(params);
this.selectedTeams.forEach((team) => {
searchParams.append('teams[]', team);
});
this.selectedUsers.forEach((user) => {
searchParams.append('users[]', user);
});
window.location.href = `${this.searchUrl}?${searchParams.toString()}`;
}
}
};
</script>

View file

@ -0,0 +1,132 @@
<template>
<div class="flex gap-2">
<SelectDropdown class="!w-40"
:options="dateOptions"
:value="selectedOption"
@change="(v) => {selectedOption = v}" />
<div class="grow">
<DateTimePicker
v-if="selectedOption === 'on'"
@change="setOn"
mode="date"
size="mb"
placeholder="Enter date"
:defaultValue="date.on"
:clearable="true"/>
<DateTimePicker
v-if="selectedOption === 'custom'"
@change="setFrom"
class="mb-2"
mode="date"
size="mb"
placeholder="From date"
:defaultValue="date.from"
:clearable="true"/>
<DateTimePicker
v-if="selectedOption === 'custom'"
@change="setTo"
mode="date"
size="mb"
placeholder="To date"
:defaultValue="date.to"
:clearable="true"/>
</div>
</div>
</template>
<script>
import SelectDropdown from '../../shared/select_dropdown.vue';
import DateTimePicker from '../../shared/date_time_picker.vue';
export default {
name: 'DateFilter',
props: {
date: {
type: Object,
required: true
}
},
components: {
SelectDropdown,
DateTimePicker
},
watch: {
selectedOption() {
const today = new Date();
const yesterday = new Date(new Date().setDate(today.getDate() - 1));
const weekDay = today.getDay();
const monday = new Date(new Date()
.setDate(today.getDate() - weekDay - (weekDay === 0 ? 6 : -1)));
const lastWeekStart = new Date(monday.getTime() - (7 * 24 * 60 * 60 * 1000));
const lastWeekEnd = new Date(lastWeekStart.getTime() + (6 * 24 * 60 * 60 * 1000));
const firstMonthDay = new Date(today.getFullYear(), today.getMonth(), 1);
const firstYearDay = new Date(today.getFullYear(), 0, 1);
const lastYearEnd = new Date(today.getFullYear(), 0, 0);
const lastYearStart = new Date(today.getFullYear() - 1, 0, 1);
switch (this.selectedOption) {
case 'today':
this.newDate = { on: today, from: null, to: null };
break;
case 'yesterday':
this.newDate = { on: yesterday, from: null, to: null };
break;
case 'last_week':
this.newDate = { on: null, from: lastWeekStart, to: lastWeekEnd };
break;
case 'this_month':
this.newDate = { on: null, from: firstMonthDay, to: today };
break;
case 'this_year':
this.newDate = { on: null, from: firstYearDay, to: today };
break;
case 'last_year':
this.newDate = { on: null, from: lastYearStart, to: lastYearEnd };
break;
case 'on':
this.newDate = { on: null, from: null, to: null };
break;
case 'custom':
this.newDate = { on: null, from: null, to: null };
break;
default:
break;
}
this.$emit('change', this.newDate);
}
},
data() {
return {
newDate: this.date,
selectedOption: 'on',
dateOptions: [
['today', 'Today'],
['yesterday', 'Yesterday'],
['last_week', 'Last week'],
['this_month', 'This month'],
['this_year', 'This year'],
['last_year', 'Last year'],
['on', 'On'],
['custom', 'Custom']
]
};
},
methods: {
setOn(v) {
this.newDate = { on: v, from: null, to: null };
this.$emit('change', this.newDate);
},
setFrom(v) {
this.newDate.on = null;
this.newDate.from = v;
this.$emit('change', this.newDate);
},
setTo(v) {
this.newDate.on = null;
this.newDate.to = v;
this.$emit('change', this.newDate);
}
}
};
</script>

View file

@ -1,14 +1,30 @@
<template>
<GeneralDropdown ref="container" :canOpen="canOpen" :fieldOnlyOpen="true">
<GeneralDropdown ref="container" :canOpen="canOpen" :fieldOnlyOpen="true" @close="filtersOpened = false">
<template v-slot:field>
<div class="sci--navigation--top-menu-search left-icon sci-input-container-v2" :class="{'disabled' : !currentTeam}" :title="i18n.t('nav.search')">
<input ref="searchField" type="text" class="!pr-9" v-model="searchQuery" :placeholder="i18n.t('nav.search')" @keyup.enter="saveQuery"/>
<input ref="searchField" type="text" class="!pr-20" v-model="searchQuery" :placeholder="i18n.t('nav.search')" @keyup.enter="saveQuery"/>
<i class="sn-icon sn-icon-search"></i>
<i v-if="this.searchQuery.length > 0" class="sn-icon sn-icon-close absolute right-1 top-0.5" @click="this.searchQuery = ''"></i>
<div v-if="this.searchQuery.length > 1" class="flex items-center gap-1 absolute right-2 top-1.5">
<div class="btn btn-light icon-btn btn-xs" @click="this.searchQuery = ''">
<i class="sn-icon sn-icon-close m-0"></i>
</div>
<div class="btn btn-light icon-btn btn-xs" :class="{'active': filtersOpened}" @click="filtersOpened = !filtersOpened">
<i class="sn-icon sn-icon-search-options m-0"></i>
</div>
</div>
</div>
</template>
<template v-slot:flyout >
<div v-if="showHistory" class="max-w-[600px]">
<SearchFilters
v-if="filtersOpened"
:teamsUrl="teamsUrl"
:usersUrl="usersUrl"
:currentTeam="currentTeam"
:searchUrl="searchUrl"
:searchQuery="searchQuery"
@cancel="filtersOpened = false"
></SearchFilters>
<div v-else-if="showHistory" class="max-w-[600px]">
<div v-for="(query, i) in previousQueries.reverse()" @click="setQuery(query)" :key="i"
class="flex px-3 h-11 items-center gap-2 hover:bg-sn-super-light-grey cursor-pointer">
<i class="sn-icon sn-icon-history-search"></i>
@ -17,16 +33,24 @@
</div>
<div v-else class="max-w-[600px]">
<div class="flex items-center gap-2">
<button class="btn btn-secondary btn-xs" @click="setQuickFilter('experiments')">
<button class="btn btn-secondary btn-xs"
:class="{'active': quickFilter === 'experiments'}"
@click="setQuickFilter('experiments')">
{{ i18n.t('search.quick_search.experiments') }}
</button>
<button class="btn btn-secondary btn-xs" @click="setQuickFilter('my_modules')">
<button class="btn btn-secondary btn-xs"
:class="{'active': quickFilter === 'my_modules'}"
@click="setQuickFilter('my_modules')">
{{ i18n.t('search.quick_search.tasks') }}
</button>
<button class="btn btn-secondary btn-xs" @click="setQuickFilter('results')">
<button class="btn btn-secondary btn-xs"
:class="{'active': quickFilter === 'results'}"
@click="setQuickFilter('results')">
{{ i18n.t('search.quick_search.results') }}
</button>
<button class="btn btn-secondary btn-xs" @click="setQuickFilter('repository_rows')">
<button class="btn btn-secondary btn-xs"
:class="{'active': quickFilter === 'repository_rows'}"
@click="setQuickFilter('repository_rows')">
{{ i18n.t('search.quick_search.inventory_items') }}
</button>
</div>
@ -84,6 +108,7 @@
<script>
import GeneralDropdown from '../shared/general_dropdown.vue';
import StringWithEllipsis from '../shared/string_with_ellipsis.vue';
import SearchFilters from '../global_search/filters.vue';
import axios from '../../packs/custom_axios.js';
export default {
@ -99,11 +124,20 @@ export default {
searchUrl: {
type: String,
required: true
},
teamsUrl: {
type: String,
required: true
},
usersUrl: {
type: String,
required: true
}
},
components: {
GeneralDropdown,
StringWithEllipsis
StringWithEllipsis,
SearchFilters
},
computed: {
canOpen() {
@ -128,7 +162,8 @@ export default {
previousQueries: [],
quickFilter: null,
results: [],
loading: false
loading: false,
filtersOpened: false
};
},
mounted() {

View file

@ -7,7 +7,15 @@
@change="switchTeam"
></SelectDropdown>
</div>
<QuickSearch v-if="user && !hideSearch" :quickSearchUrl="quickSearchUrl" :searchUrl="searchUrl" :currentTeam="currentTeam"></QuickSearch>
<QuickSearch
v-if="user"
:class="{'hidden': hideSearch}"
:quickSearchUrl="quickSearchUrl"
:searchUrl="searchUrl"
:currentTeam="currentTeam"
:teamsUrl="teamsUrl"
:usersUrl="usersUrl"
></QuickSearch>
<MenuDropdown
class="ml-auto"
v-if="settingsMenu && settingsMenu.length > 0"
@ -84,7 +92,9 @@ export default {
url: String,
notificationsUrl: String,
unseenNotificationsUrl: String,
quickSearchUrl: String
quickSearchUrl: String,
teamsUrl: String,
usersUrl: String
},
data() {
return {

View file

@ -29,8 +29,8 @@
:placeholder="label || placeholder || this.i18n.t('general.select_dropdown.placeholder')"
class="w-full border-0 outline-none pl-0 placeholder:text-sn-grey" />
</template>
<div v-else class="flex items-center gap-1 flex-wrap max-w-[calc(100%-24px)]">
<div v-for="tag in tags" class=" truncate px-2 py-1 rounded-sm bg-sn-super-light-grey flex items-center gap-1">
<div v-else class="flex items-center gap-1 flex-wrap">
<div v-for="tag in tags" class="px-2 py-1 rounded-sm bg-sn-super-light-grey grid grid-cols-[auto_1fr] items-center gap-1">
<div class="truncate" v-if="labelRenderer" v-html="tag.label"></div>
<div class="truncate" v-else>{{ tag.label }}</div>
<i @click="removeTag(tag.value)" class="sn-icon mini ml-auto sn-icon-close cursor-pointer"></i>
@ -71,7 +71,7 @@
<div
@click.stop="setValue(option[0])"
ref="options"
class="py-1.5 px-3 rounded cursor-pointer flex items-center gap-2 shrink-0"
class="py-1.5 px-3 rounded cursor-pointer flex items-center gap-2 shrink-0 hover:bg-sn-super-light-grey"
:class="[sizeClass, {
'!bg-sn-super-light-blue': valueSelected(option[0]) && focusedOption !== i,
'!bg-sn-super-light-grey': focusedOption === i ,
@ -179,6 +179,13 @@ export default {
tags() {
if (!this.newValue) return [];
this.selectAllState = 'indeterminate';
if (this.newValue.length === 0) {
this.selectAllState = 'unchecked';
} else if (this.newValue.length === this.rawOptions.length) {
this.selectAllState = 'checked';
}
return this.newValue.map((value) => {
const option = this.rawOptions.find((i) => i[0] === value);
return {

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
class TeamSerializer < ActiveModel::Serializer
attributes :id, :name
end

View file

@ -4,6 +4,8 @@
<global_search
:query="'<%= @display_query %>'"
:search-url="'<%= search_path(format: :json) %>'"
teams-url="<%= visible_teams_teams_path %>"
users-url="<%= visible_users_teams_path %>"
/>
</div>

View file

@ -4,7 +4,10 @@
url="<%= top_menu_navigations_path %>"
notifications-url="<%= user_notifications_path %>"
quick-search-url="<%= quick_search_path %>"
unseen-notifications-url="<%= unseen_counter_user_notifications_path %>" />
unseen-notifications-url="<%= unseen_counter_user_notifications_path %>"
teams-url="<%= visible_teams_teams_path %>"
users-url="<%= visible_users_teams_path %>"
/>
</div>
<% else %>
<div class="sci--navigation--top-menu-container">

View file

@ -438,6 +438,14 @@ en:
whole_phrase: "Match whole phrase"
match_case: "Match case sensitive"
object_id: "ID:"
filters:
by_type: "Filter by type"
by_created_date: "Filter by created date"
by_updated_date: "Filter by updated date"
by_team: "Filter by team"
by_user: "Filter by user"
include_archived: "Include Archived objects"
clear: "Clear filters"
index:
head_title: "Search"
page_title: "Search"

View file

@ -216,6 +216,11 @@ Rails.application.routes.draw do
end
end
collection do
get :visible_users
get :visible_teams
end
member do
post 'parse_sheet', defaults: { format: 'json' }
post 'export_repository', to: 'repositories#export_repository'