Add tag filtering to global search [SCI-12288]

This commit is contained in:
Oleksii Kriuchykhin 2025-09-02 13:18:36 +02:00
parent fe17f2256c
commit c11d79bcba
15 changed files with 93 additions and 10 deletions

View file

@ -157,7 +157,8 @@ class SearchController < ApplicationController
def filter_records
filter_datetime!(:created_at) if @filters[:created_at].present?
filter_datetime!(:updated_at) if @filters[:updated_at].present?
filter_users! if @filters[:users].present?
filter_by_users! if @filters[:users].present?
filter_by_tags! if @filters[:tags].present? && @model == MyModule
end
def sort_records
@ -198,7 +199,7 @@ class SearchController < ApplicationController
@records = @records.where("#{model_name}.#{attribute} <= ?", to_date) if to_date.present?
end
def filter_users!
def filter_by_users!
@records = @records.joins("INNER JOIN activities ON #{@model.model_name.collection}.id = activities.subject_id
AND activities.subject_type= '#{@model.name}'")
@ -209,4 +210,9 @@ class SearchController < ApplicationController
@records.where('activities.owner_id': user_ids)
end
end
def filter_by_tags!
tag_ids = @filters[:tags]&.values&.collect(&:to_i)
@records = @records.joins(:tags).where(tags: { id: tag_ids })
end
end

View file

@ -9,7 +9,7 @@ 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: %i(view_type visible_teams visible_users current_team_users)
before_action :check_read_permissions, except: %i(view_type visible_teams visible_users visible_tags current_team_users)
before_action :check_export_projects_permissions, only: %i(export_projects_modal export_projects)
def visible_teams
@ -26,6 +26,16 @@ class TeamsController < ApplicationController
render json: users, each_serializer: UserSerializer, user: current_user
end
def visible_tags
teams = if params[:teams].present?
current_user.teams.where(id: params[:teams])
else
current_team
end
tags = Tag.where(team: teams)
render json: tags, each_serializer: TagSerializer
end
def current_team_users
users = current_team.users.order(:full_name)
render json: users, each_serializer: UserSerializer, user: current_user

View file

@ -109,6 +109,7 @@
v-if="filterModalOpened"
:teamsUrl="teamsUrl"
:usersUrl="usersUrl"
:tagsUrl="tagsUrl"
:filters="filters"
:currentTeam="currentTeam"
@search="applyFilters"
@ -157,6 +158,10 @@ export default {
type: String,
required: true
},
tagsUrl: {
type: String,
required: true
},
currentTeam: {
type: Number || String,
required: true
@ -213,7 +218,7 @@ export default {
return Object.keys(this.filters).filter((key) => {
if (key === 'created_at' || key === 'updated_at') {
return this.filters[key].on || this.filters[key].from || this.filters[key].to;
} if (key === 'teams' || key === 'users') {
} if (key === 'teams' || key === 'users' || key === 'tags') {
return this.filters[key].length > 0;
}
return this.filters[key];
@ -242,6 +247,7 @@ export default {
include_archived: urlParams.get('include_archived') === 'true',
teams: (this.singleTeam ? [] : urlParams.getAll('teams[]').map((team) => parseInt(team, 10))),
users: urlParams.getAll('users[]').map((user) => parseInt(user, 10)),
tags: urlParams.getAll('tags[]').map((tag) => parseInt(tag, 10)),
group: urlParams.get('group')
};
['created_at', 'updated_at'].forEach((key) => {
@ -364,6 +370,7 @@ export default {
include_archived: false,
teams: [],
users: [],
tags: [],
group: null
};
this.activeGroup = null;

View file

@ -13,6 +13,20 @@
</button>
</template>
</div>
<div class="sci-label mb-2">{{ i18n.t('search.filters.by_tag') }}</div>
<div class="grow -mt-2.5">
<SelectDropdown :options="tags"
@change="selectTags"
:optionRenderer="tagRenderer"
:labelRenderer="tagRenderer"
:multiple="true"
class="grow"
:value="selectedTags"
:searchable="true"
:placeholder="i18n.t('search.filters.by_tag_placeholder')"
:tagsView="true">
</SelectDropdown>
</div>
<div class="sci-label mb-2" data-e2e="e2e-TX-globalSearch-filters-filterByCreated">{{ i18n.t('search.filters.by_created_date') }}</div>
<DateFilter
:date="createdAt"
@ -79,6 +93,7 @@
import DateFilter from './filters/date.vue';
import SelectDropdown from '../shared/select_dropdown.vue';
import escapeHtml from '../shared/escape_html.js';
import axios from '../../packs/custom_axios.js';
export default {
@ -92,6 +107,10 @@ export default {
type: String,
required: true
},
tagsUrl: {
type: String,
required: true
},
filters: Object,
currentTeam: Number || String,
searchUrl: String,
@ -109,6 +128,7 @@ export default {
this.selectedTeams = this.filters.teams;
this.$nextTick(() => {
this.selectedUsers = this.filters.users;
this.selectedTags = this.filters.tags;
});
this.includeArchived = this.filters.include_archived;
this.activeGroup = this.filters.group;
@ -118,6 +138,8 @@ export default {
selectedTeams() {
this.selectedUsers = [];
this.fetchUsers();
this.selectedTags = [];
this.fetchTags();
}
},
data() {
@ -135,9 +157,11 @@ export default {
},
selectedTeams: [],
selectedUsers: [],
selectedTags: [],
includeArchived: true,
teams: [],
users: [],
tags:[],
searchGroups: [
{ value: 'FoldersComponent', label: this.i18n.t('search.index.folders') },
{ value: 'ProjectsComponent', label: this.i18n.t('search.index.projects') },
@ -161,9 +185,12 @@ export default {
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 title="${escapeHtml(option[1])}" class="truncate">${escapeHtml(option[1])}</div>
</div>`;
},
tagRenderer(option) {
return `<div class="sci-tag text-white" style="background-color: ${escapeHtml(option[2])};">${escapeHtml(option[1])}</div>`;
},
setActiveGroup(group) {
if (group === this.activeGroup) {
this.activeGroup = null;
@ -183,6 +210,12 @@ export default {
this.users = response.data.data.map((user) => ([parseInt(user.id, 10), user.attributes.name, { avatar_url: user.attributes.avatar_url }]));
});
},
fetchTags() {
axios.get(this.tagsUrl, { params: { teams: this.selectedTeams } })
.then((response) => {
this.tags = response.data.data.map((tag) => ([parseInt(tag.id, 10), tag.attributes.name, tag.attributes.color]));
});
},
selectTeams(teams) {
if (Array.isArray(teams)) {
this.selectedTeams = teams;
@ -193,6 +226,11 @@ export default {
this.selectedUsers = users;
}
},
selectTags(tags) {
if (Array.isArray(tags)) {
this.selectedTags = tags;
}
},
clearFilters() {
this.createdAt = {
on: null,
@ -220,6 +258,7 @@ export default {
updated_at: this.updatedAt,
teams: this.selectedTeams,
users: this.selectedUsers,
tags: this.selectedTags,
include_archived: this.includeArchived,
group: this.activeGroup
});
@ -249,6 +288,10 @@ export default {
searchParams.append('users[]', user);
});
this.selectedTags.forEach((tag) => {
searchParams.append('tags[]', tag.id);
});
window.location.href = `${this.searchUrl}?${searchParams.toString()}`;
}
}

View file

@ -18,6 +18,7 @@
<Filters
:teams-url="teamsUrl"
:users-url="usersUrl"
:tags-url="tagsUrl"
:filters="filters"
:currentTeam="currentTeam"
@search="(newFilters) => { this.$emit('search', newFilters); }"
@ -38,6 +39,7 @@ export default {
props: {
teamsUrl: String,
usersUrl: String,
tagsUrl: String,
filters: Object,
currentTeam: Number || String
},

View file

@ -196,8 +196,8 @@ export default {
},
tagsRenderer(tag) {
return `<div class="flex items-center gap-2">
<span class="w-4 h-4 rounded-full" style="background-color: ${tag[2]}"></span>
<span title="${tag[1]}" class="truncate">${tag[1]}</span>
<span class="w-4 h-4 rounded-full" style="background-color: ${escapeHtml(tag[2])}"></span>
<span title="${escapeHtml(tag[1])}" class="truncate">${escapeHtml(tag[1])}</span>
</div>`;
},
usersRenderer(user) {

View file

@ -29,6 +29,7 @@
v-if="filtersOpened"
:teamsUrl="teamsUrl"
:usersUrl="usersUrl"
:tagsUrl="tagsUrl"
:currentTeam="currentTeam"
:searchUrl="searchUrl"
:searchQuery="searchQuery"
@ -150,6 +151,10 @@ export default {
usersUrl: {
type: String,
required: true
},
tagsUrl: {
type: String,
required: true
}
},
components: {

View file

@ -22,6 +22,7 @@
:currentTeam="currentTeam"
:teamsUrl="teamsUrl"
:usersUrl="usersUrl"
:tagsUrl="tagsUrl"
></QuickSearch>
<MenuDropdown
class="ml-auto"
@ -104,6 +105,7 @@ export default {
quickSearchUrl: String,
teamsUrl: String,
usersUrl: String,
tagsUrl: String,
logoUrl: String
},
data() {

View file

@ -80,7 +80,7 @@ export default {
});
},
tagRenderer(tag) {
return `<div class="sci-tag text-white" style="background-color: ${tag[2]};">${escapeHtml(tag[1])}</div>`;
return `<div class="sci-tag text-white" style="background-color: ${escapeHtml(tag[2])};">${escapeHtml(tag[1])}</div>`;
}
}
};

View file

@ -116,7 +116,7 @@ class MyModule < ApplicationRecord
)
teams = options[:teams] || current_team || user.teams.select(:id)
new_query = distinct.left_joins(:task_comments, my_module_tags: :tag, user_my_modules: :user)
new_query = distinct.left_joins(:task_comments, :tags, user_my_modules: :user)
.readable_by_user(user, teams)
.where_attributes_like_boolean(SEARCHABLE_ATTRIBUTES, query, options)

View file

@ -7,7 +7,7 @@ class TagSerializer < ActiveModel::Serializer
def urls
{
update: project_tag_path(object.project, object, format: :json)
update: users_settings_team_tag_path(object.team, object, format: :json)
}
end
end

View file

@ -6,6 +6,7 @@
:search-url="'<%= search_path(format: :json) %>'"
teams-url="<%= visible_teams_teams_path %>"
users-url="<%= visible_users_teams_path %>"
tags-url="<%= visible_tags_teams_path %>"
:single-team="<%= current_user.teams.count == 1 %>"
:current-team="<%= current_team.id %>"
/>

View file

@ -8,6 +8,7 @@
unseen-notifications-url="<%= unseen_counter_user_notifications_path %>"
teams-url="<%= visible_teams_teams_path %>"
users-url="<%= visible_users_teams_path %>"
tags-url="<%= visible_tags_teams_path %>"
logo-url="<%= application_logo_url %>"
/>
</div>

View file

@ -494,6 +494,8 @@ en:
title: "More search options"
sub_title: "Refine your search by applying the filters below."
by_type: "Filter by type"
by_tag: "Filter by tag"
by_tag_placeholder: "Select tags"
by_created_date: "Filter by created date"
by_updated_date: "Filter by updated date"
by_team: "Filter by workspace"

View file

@ -17,6 +17,10 @@ class MigrateTagsFromProjectsToWorkspaces < ActiveRecord::Migration[7.2]
FROM my_module_tags
SQL
execute <<~SQL.squish
SELECT setval('taggings_id_seq', (SELECT MAX(id) FROM taggings));
SQL
execute <<~SQL.squish
UPDATE tags
SET team_id = projects.team_id