diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 389808f5f..d3587aef0 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -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
diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb
index 1b67ceeb1..495c07fb8 100644
--- a/app/controllers/teams_controller.rb
+++ b/app/controllers/teams_controller.rb
@@ -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
diff --git a/app/javascript/vue/global_search/container.vue b/app/javascript/vue/global_search/container.vue
index 8d99c680a..e68c6150b 100644
--- a/app/javascript/vue/global_search/container.vue
+++ b/app/javascript/vue/global_search/container.vue
@@ -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;
diff --git a/app/javascript/vue/global_search/filters.vue b/app/javascript/vue/global_search/filters.vue
index bec1db43c..bc934e415 100644
--- a/app/javascript/vue/global_search/filters.vue
+++ b/app/javascript/vue/global_search/filters.vue
@@ -13,6 +13,20 @@
+
{{ i18n.t('search.filters.by_tag') }}
+
+
+
+
{{ i18n.t('search.filters.by_created_date') }}
{
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 `

-
${option[1]}
+
${escapeHtml(option[1])}
`;
},
+ tagRenderer(option) {
+ return `${escapeHtml(option[1])}
`;
+ },
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()}`;
}
}
diff --git a/app/javascript/vue/global_search/filters_modal.vue b/app/javascript/vue/global_search/filters_modal.vue
index 1391486ff..b71e5f0f7 100644
--- a/app/javascript/vue/global_search/filters_modal.vue
+++ b/app/javascript/vue/global_search/filters_modal.vue
@@ -18,6 +18,7 @@
{ this.$emit('search', newFilters); }"
@@ -38,6 +39,7 @@ export default {
props: {
teamsUrl: String,
usersUrl: String,
+ tagsUrl: String,
filters: Object,
currentTeam: Number || String
},
diff --git a/app/javascript/vue/my_modules/modals/new.vue b/app/javascript/vue/my_modules/modals/new.vue
index f3e5549a6..e1b8d697f 100644
--- a/app/javascript/vue/my_modules/modals/new.vue
+++ b/app/javascript/vue/my_modules/modals/new.vue
@@ -196,8 +196,8 @@ export default {
},
tagsRenderer(tag) {
return `
-
- ${tag[1]}
+
+ ${escapeHtml(tag[1])}
`;
},
usersRenderer(user) {
diff --git a/app/javascript/vue/navigation/quick_search.vue b/app/javascript/vue/navigation/quick_search.vue
index d0b83a74e..e5d98241b 100644
--- a/app/javascript/vue/navigation/quick_search.vue
+++ b/app/javascript/vue/navigation/quick_search.vue
@@ -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: {
diff --git a/app/javascript/vue/navigation/top_menu.vue b/app/javascript/vue/navigation/top_menu.vue
index 74162b052..8caca65af 100644
--- a/app/javascript/vue/navigation/top_menu.vue
+++ b/app/javascript/vue/navigation/top_menu.vue
@@ -22,6 +22,7 @@
:currentTeam="currentTeam"
:teamsUrl="teamsUrl"
:usersUrl="usersUrl"
+ :tagsUrl="tagsUrl"
>
${escapeHtml(tag[1])}`;
+ return `${escapeHtml(tag[1])}
`;
}
}
};
diff --git a/app/models/my_module.rb b/app/models/my_module.rb
index 9e2951f9c..ba5bc38ee 100644
--- a/app/models/my_module.rb
+++ b/app/models/my_module.rb
@@ -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)
diff --git a/app/serializers/tag_serializer.rb b/app/serializers/tag_serializer.rb
index f3049e842..41d193775 100644
--- a/app/serializers/tag_serializer.rb
+++ b/app/serializers/tag_serializer.rb
@@ -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
diff --git a/app/views/search/index.html.erb b/app/views/search/index.html.erb
index 4b1d7d56a..da9371a19 100644
--- a/app/views/search/index.html.erb
+++ b/app/views/search/index.html.erb
@@ -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 %>"
/>
diff --git a/app/views/shared/navigation/_top.html.erb b/app/views/shared/navigation/_top.html.erb
index 2445e4572..88b730466 100644
--- a/app/views/shared/navigation/_top.html.erb
+++ b/app/views/shared/navigation/_top.html.erb
@@ -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 %>"
/>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 710b11de9..8edc7a08f 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -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"
diff --git a/db/migrate/20250818111931_migrate_tags_from_projects_to_workspaces.rb b/db/migrate/20250818111931_migrate_tags_from_projects_to_workspaces.rb
index 544e35b4f..d246fffb7 100644
--- a/db/migrate/20250818111931_migrate_tags_from_projects_to_workspaces.rb
+++ b/db/migrate/20250818111931_migrate_tags_from_projects_to_workspaces.rb
@@ -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