Add new tags managment modal [SCI-12241]

This commit is contained in:
Anton 2025-09-03 14:30:56 +02:00
parent 832a43b69d
commit 8a8b11da6a
9 changed files with 251 additions and 66 deletions

View file

@ -4,9 +4,10 @@ module TaggableActions
extend ActiveSupport::Concern
included do
before_action :load_taggable_item, only: %i(tag_resource untag_resource)
before_action :load_taggable_item, only: %i(tag_resource untag_resource tag_resource_with_new_tag)
before_action :load_tag, only: %i(tag_resource untag_resource)
before_action :check_tag_manage_permissions, only: %i(tag_resource untag_resource)
before_action :check_tag_manage_permissions, only: %i(tag_resource untag_resource tag_resource_with_new_tag)
before_action :check_tag_create_permissions, only: %i(tag_resource_with_new_tag)
end
def tag_resource
@ -18,6 +19,20 @@ module TaggableActions
end
end
def tag_resource_with_new_tag
ActiveRecord::Base.transaction do
params[:tag][:color] = Constants::TAG_COLORS.sample.to_s if params[:tag][:color].blank?
@tag = current_team.tags.create!(tag_params.merge(created_by: current_user, last_modified_by: current_user))
tagging = @taggable_item.taggings.new(tag: @tag, created_by: current_user)
tagging.save!
render json: { tag: [@tag.id, @tag.name, @tag.color] }
rescue ActiveRecord::RecordInvalid => e
render json: { status: :error, error: e.message }, status: :unprocessable_entity
raise ActiveRecord::Rollback
end
end
def untag_resource
tagging = @taggable_item.taggings.find_by(tag_id: @tag.id)
if tagging&.destroy
@ -29,6 +44,10 @@ module TaggableActions
private
def tag_params
params.require(:tag).permit(:name, :color)
end
def load_taggable_item
@taggable_item = controller_name.singularize.camelize.constantize.find(params[:id])
end
@ -41,4 +60,8 @@ module TaggableActions
def check_tag_manage_permissions
raise NotImplementedError
end
def check_tag_create_permissions
true # TODO: implement
end
end

View file

@ -72,7 +72,11 @@ module Users
tags_to_merge = @team.tags.where(id: params[:merge_ids]).where.not(id: @tag.id)
taggings_to_update = Tagging.where(tag_id: tags_to_merge.select(:id))
.where.not(id: Tagging.where(tag_id: @tag.id).select(:id))
.where.not(
Tagging.where(tag_id: @tag.id).map{|i|
Arel.sql("(taggable_type = '#{i.taggable_type}' AND taggable_id = #{i.taggable_id})")
}.join(" OR ")
)
taggings_to_update.update!(tag_id: @tag.id)
tags_to_merge.each(&:destroy!)

View file

@ -695,6 +695,7 @@ export default {
},
applyFilters(filters) {
this.activeFilters = filters;
console.log(this.activeFilters);
this.reloadTable();
},
switchViewRender(view) {

View file

@ -0,0 +1,91 @@
import axios from '../../../packs/custom_axios.js';
import {
tags_path
} from '../../../routes.js';
export default {
props: {
subject: {
type: Object,
required: true
},
},
computed: {
canManage() {
return this.subject.attributes.permissions.manage_tags;
},
canAssign() {
return this.subject.attributes.permissions.assign_tags;
},
tagResourceUrl() {
return this.subject.attributes.urls.tag_resource;
},
untagResourceUrl() {
return this.subject.attributes.urls.untag_resource;
},
createTagUrl() {
return this.subject.attributes.urls.tag_resource_with_new_tag;
}
},
created() {
this.loadAllTags();
this.tags = this.subject.attributes.tags || [];
},
data() {
return {
tags: [],
allTags: [],
linkingTag: false,
searchQuery: ''
};
},
methods: {
loadAllTags() {
axios.get(tags_path()).then((response) => {
this.allTags = response.data.data;
});
},
linkTag(tag) {
if (this.tags.map(t => t[0]).includes(tag[0])) {
this.unlinkTag(tag);
return;
}
if (this.linkingTag) {
return;
}
this.linkingTag = true;
axios.post(this.tagResourceUrl, {
tag_id: tag[0],
}).then((response) => {
this.tags.push(response.data.tag);
this.linkingTag = false;
this.searchQuery = '';
}).catch(() => {
this.linkingTag = false;
HelperModule.flashAlertMsg(I18n.t('errors.general'), 'danger');
});
},
unlinkTag(tag) {
if (this.linkingTag) {
return;
}
this.linkingTag = true;
axios.post(this.untagResourceUrl, {
tag_id: tag[0],
}).then((response) => {
this.tags = this.tags.filter(t => t[0] !== tag[0]);
this.linkingTag = false;
this.searchQuery = '';
}).catch(() => {
this.linkingTag = false;
HelperModule.flashAlertMsg(I18n.t('errors.general'), 'danger');
});
}
}
};

View file

@ -1,26 +1,32 @@
<template>
<div>
<GeneralDropdown @open="opened = true" @close="opened = false">
<GeneralDropdown :canOpen="canAssign" @open="openSearch" @close="closeSearch">
<template v-slot:field>
<div class="w-full flex flex-wrap rounded gap-2 p-1 border border-solid cursor-pointer"
<div class="w-full flex flex-wrap rounded gap-2 p-1 border border-solid"
:class="{
'!border-sn-science-blue': opened,
'hover:!border-sn-light-grey !border-transparent': !opened,
'!border-sn-science-blue cursor-pointer': opened,
'hover:!border-sn-light-grey !border-transparent cursor-pointer': !opened && canAssign,
'!border-transparent': !canAssign
}">
<template v-if="tags.length > 0">
<div v-for="tag in tags" :key="tag[0]" class="sci-tag text-white" :style="{ backgroundColor: tag[2] }" >
{{ tag[1] }}
<i @click.stop="unlinkTag(tag)" class="sn-icon sn-icon-close"></i>
<i v-if="canAssign" @click.stop="unlinkTag(tag)" class="sn-icon sn-icon-close"></i>
</div>
</template>
<div v-else class="sci-tag bg-sn-super-light-grey">
<div v-else-if="!opened" class="sci-tag bg-sn-super-light-grey">
{{ i18n.t('tags.tags_input.add_tag') }}
</div>
<input v-if="opened" @click.stop="handleInputClick" @keydown.enter.stop="handleInputEnter" type="text" ref="tagSearch" v-model="searchQuery" class="flex-grow outline-none border-none bg-transparent p-1" />
</div>
</template>
<template v-slot:flyout>
<div class="flex flex-col">
<div v-for="tag in allTags" :key="tag[0]" @click="linkTag(tag)" class="py-2 cursor-pointer hover:bg-sn-super-light-grey px-3 flex items-center gap-2" >
<div v-if="validTagName && canManage" @click="createTag" class="py-2 cursor-pointer hover:bg-sn-super-light-grey px-3 flex items-center gap-2">
<i class="sn-icon sn-icon-new-task"></i>
{{ i18n.t('tags.tags_input.create_tag') }}
</div>
<div v-for="tag in filteredTags" :key="tag[0]" @click="linkTag(tag)" class="py-2 cursor-pointer hover:bg-sn-super-light-grey px-3 flex items-center gap-2" >
<div class="sci-checkbox-container pointer-events-none" >
<input type="checkbox" :checked="tags.map(t => t[0]).includes(tag[0])" class="sci-checkbox" />
<span class="sci-checkbox-label"></span>
@ -41,90 +47,74 @@
</template>
<script>
import axios from '../../packs/custom_axios.js';
import GeneralDropdown from './general_dropdown.vue';
import {
tags_path
} from '../../routes.js';
import TagsMixin from './mixins/tags_mixin.js';
export default {
name: 'TagsInput',
props: {
subject: {
type: Object,
required: true
},
},
components: {
GeneralDropdown,
},
mixins: [TagsMixin],
computed: {
tagResourceUrl() {
return this.subject.attributes.urls.tag_resource;
filteredTags() {
if (this.searchQuery.trim() === '') {
return this.allTags;
}
const lowerQuery = this.searchQuery.toLowerCase();
return filter(this.allTags, tag => tag[1].toLowerCase().includes(lowerQuery));
},
untagResourceUrl() {
return this.subject.attributes.urls.untag_resource;
},
},
created() {
this.loadAllTags();
this.tags = this.subject.attributes.tags || [];
validTagName() {
return this.searchQuery.trim().length > GLOBAL_CONSTANTS.NAME_MIN_LENGTH &&
!this.allTags.map(t => t[1].toLowerCase()).includes(this.searchQuery.toLowerCase());
}
},
data() {
return {
tags: [],
allTags: [],
linkingTag: false,
opened: false,
};
},
methods: {
loadAllTags() {
axios.get(tags_path()).then((response) => {
this.allTags = response.data.data;
});
},
linkTag(tag) {
if (this.tags.map(t => t[0]).includes(tag[0])) {
this.unlinkTag(tag);
return;
}
if (this.linkingTag) {
createTag() {
if (this.linkingTag || this.searchQuery.trim() === '') {
return;
}
this.linkingTag = true;
axios.post(this.tagResourceUrl, {
tag_id: tag[0],
axios.post(this.createTagUrl, {
tag: {
name: this.searchQuery.trim()
}
}).then((response) => {
this.tags.push(response.data.tag);
this.loadAllTags();
this.linkingTag = false;
this.searchQuery = '';
}).catch(() => {
this.linkingTag = false;
HelperModule.flashAlertMsg(I18n.t('errors.general'), 'danger');
});
},
unlinkTag(tag) {
if (this.linkingTag) {
return;
openSearch() {
this.opened = true;
this.$nextTick(() => {
this.$refs.tagSearch.focus();
});
},
closeSearch() {
this.opened = false;
this.searchQuery = '';
},
handleInputClick() {
},
handleInputEnter() {
if (this.validTagName && this.canManage) {
this.createTag();
} else if (this.filteredTags.length > 0 && this.canAssign) {
this.linkTag(this.filteredTags[0]);
}
this.linkingTag = true;
axios.post(this.untagResourceUrl, {
tag_id: tag[0],
}).then((response) => {
this.tags = this.tags.filter(t => t[0] !== tag[0]);
this.linkingTag = false;
}).catch(() => {
this.linkingTag = false;
HelperModule.flashAlertMsg(I18n.t('errors.general'), 'danger');
});
},
}
}
}
</script>

View file

@ -0,0 +1,71 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 v-if="canManage" class="modal-title truncate !block">
{{ i18n.t("experiments.canvas.modal_manage_tags.head_title") }}
</h4>
<h4 v-else class="modal-title truncate !block">
{{ i18n.t("experiments.canvas.modal_manage_tags.head_title_read") }}
</h4>
</div>
<div class="modal-body">
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.close') }}</button>
</div>
</div>
</div>
</div>
</template>
<script>
import modalMixin from '../../shared/modal_mixin';
import GeneralDropdown from '../../shared/general_dropdown.vue';
import TagsMixin from './mixins/tags_mixin.js';
export default {
name: 'TagsModal',
emits: ['close', 'tagsLoaded', 'tagDeleted'],
props: {
params: {
required: true
},
tagsColors: {
required: true
},
projectTagsUrl: {
required: true
},
projectName: {
required: true
}
},
directives: {
'click-outside': vOnClickOutside
},
components: {
InlineEdit,
GeneralDropdown,
ConfirmationModal
},
mixins: [modalMixin],
data() {
return {
tags: [],
allTags: [],
linkingTag: false,
};
},
created() {
this.loadAllTags();
this.tags = this.subject.attributes.tags || [];
},
methods: {
}
};
</script>

View file

@ -46,7 +46,9 @@ class MyModuleSerializer < ActiveModel::Serializer
manage_description: can_update_my_module_description?(object),
manage_due_date: can_update_my_module_due_date?(object),
manage_start_date: can_update_my_module_start_date?(object),
manage_designated_users: can_manage_my_module_designated_users?(object)
manage_designated_users: can_manage_my_module_designated_users?(object),
assign_tags: can_manage_my_module_tags?(object),
manage_tags: true # TODO: implement
}
end
@ -55,7 +57,8 @@ class MyModuleSerializer < ActiveModel::Serializer
show_access: access_permissions_my_module_path(object),
show_user_group_assignments_access: show_user_group_assignments_access_permissions_my_module_path(object),
tag_resource: tag_resource_my_module_path(object),
untag_resource: untag_resource_my_module_path(object)
untag_resource: untag_resource_my_module_path(object),
tag_resource_with_new_tag: tag_resource_with_new_tag_my_module_path(object)
}
end

View file

@ -4243,6 +4243,7 @@ en:
tags_input:
add_tag: "Add tag"
edit_tags: "Edit tags"
create_tag: "Create tag"
user_groups:
promo:
head_title: 'User groups'

View file

@ -529,6 +529,7 @@ Rails.application.routes.draw do
get :assigned_users
post :tag_resource
post :untag_resource
post :tag_resource_with_new_tag
end
resources :user_my_modules, path: '/users', only: %i(index create destroy) do
collection do