Add tags input to task page [SCI-12286]

This commit is contained in:
Anton 2025-09-02 15:31:46 +02:00
parent fe17f2256c
commit 1baec4f47d
10 changed files with 216 additions and 113 deletions

View file

@ -98,87 +98,7 @@
}
}
function initTagsSelector() {
var myModuleTagsSelector = '#module-tags-selector';
dropdownSelector.init($(myModuleTagsSelector), {
closeOnSelect: true,
tagClass: 'my-module-white-tags sci-tag',
labelHTML: true,
tagStyle: (data) => {
return `background: ${data.params.color}`;
},
customDropdownIcon: () => {
return '';
},
optionLabel: (data) => {
if (data.value > 0) {
return `<span class="sci-tag max-w-80 truncate text-sn-white "
style="background:${data.params.color}">${data.label}</span>`;
}
return `<span class="my-module-tags-color new"><i class="sn-icon sn-icon-new-task"></i></span>
${data.label + ' '}
<span class="my-module-tags-create-new"> ${I18n.t('my_modules.details.create_new_tag')}</span>`;
},
onOpen: function() {
$('.select-container .edit-button-container').removeClass('hidden');
},
onClose: function() {
$('.select-container .edit-button-container').addClass('hidden');
},
onSelect: function() {
var selectElement = $(myModuleTagsSelector);
var lastTag = selectElement.next().find('.ds-tags').last();
var lastTagId = lastTag.find('.tag-label').data('ds-tag-id');
var newTag;
if (lastTagId > 0) {
newTag = { my_module_tag: { tag_id: lastTagId } };
$.post(selectElement.data('update-module-tags-url'), newTag)
.fail(function(response) {
dropdownSelector.removeValue(myModuleTagsSelector, lastTagId, '', true);
if (response.status === 403) {
HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger');
}
});
} else if (lastTag.length > 0) {
newTag = {
tag: {
name: lastTag.find('.tag-label').text(),
project_id: selectElement.data('project-id'),
color: null
},
my_module_id: selectElement.data('module-id'),
simple_creation: true
};
$.post(selectElement.data('tags-create-url'), newTag, function(result) {
dropdownSelector.removeValue(myModuleTagsSelector, 0, '', true);
dropdownSelector.addValue(myModuleTagsSelector, {
value: result.tag.id,
label: result.tag.name,
params: {
color: result.tag.color
}
}, true);
}).fail(function() {
dropdownSelector.removeValue(myModuleTagsSelector, lastTagId, '', true);
});
}
},
onUnSelect: (id) => {
$.post(`${$(myModuleTagsSelector).data('update-module-tags-url')}/${id}/destroy_by_tag_id`)
.done(() => {
dropdownSelector.closeDropdown(myModuleTagsSelector);
})
.fail(function(r) {
if (r.status === 403) {
HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger');
}
});
}
}).getContainer(myModuleTagsSelector).addClass('my-module-tags-container');
}
function initAssignedUsersSelector() {
@ -258,7 +178,6 @@
}
initTaskCollapseState();
initTagsSelector();
initStartDatePicker();
initDueDatePicker();
initAssignedUsersSelector();

View file

@ -1,9 +1,13 @@
@layer components {
.sci-tag {
@apply text-xs !rounded-full !px-2 !py-1 inline-flex items-center gap-1;
@apply text-xs !rounded-full !pl-2 !pr-1.5 h-6 !py-0.5 inline-flex items-center gap-1;
}
.sci-tag .sn-icon {
@apply -ml-2;
@apply leading-4 !text-base cursor-pointer shrink-0;
}
.sci-tag .sn-icon.sn-icon-close {
@apply cursor-pointer;
}
}

View file

@ -0,0 +1,39 @@
# frozen_string_literal: true
module TaggableActions
extend ActiveSupport::Concern
included do
before_action :load_taggable_item, only: %i(link_tag unlink_tag)
before_action :load_tag, only: %i(link_tag unlink_tag)
end
def link_tag
tagging = @taggable_item.taggings.new(tag: @tag, created_by: current_user)
if tagging.save
render json: { tag: [@tag.id, @tag.name, @tag.color] }
else
render json: { status: :error }, status: :unprocessable_entity
end
end
def unlink_tag
tagging = @taggable_item.taggings.find_by(tag_id: @tag.id)
if tagging&.destroy
render json: { status: :ok }
else
render json: { status: :error }, status: :unprocessable_entity
end
end
private
def load_taggable_item
@taggable_item = controller_name.singularize.camelize.constantize.find(params[:id])
end
def load_tag
@tag = @taggable_item.team.tags.find_by(id: params[:tag_id])
render_404 unless @tag
end
end

View file

@ -8,6 +8,7 @@ class MyModulesController < ApplicationController
include MyModulesHelper
include Breadcrumbs
include FavoritesActions
include TaggableActions
before_action :load_vars, except: %i(index restore_group create new save_table_state
inventory_assigning_my_module_filter actions_toolbar)
@ -17,6 +18,7 @@ class MyModulesController < ApplicationController
before_action :check_manage_permissions, only: %i(
description due_date update_description update_protocol_description update_protocol
)
before_action :check_tag_manage_permissions, only: %i(link_tag unlink_tag)
before_action :check_read_permissions, except: %i(create new update update_description
inventory_assigning_my_module_filter
update_protocol_description restore_group
@ -514,6 +516,10 @@ class MyModulesController < ApplicationController
render_404 unless @my_module.my_module_status
end
def check_tag_manage_permissions
render_403 && return unless can_manage_my_module_tags?(@my_module)
end
def set_inline_name_editing
if action_name == 'index'
return unless can_manage_experiment?(@experiment)

View file

@ -128,7 +128,7 @@
{{ i18n.t('my_modules.details.completed_date') }}
</span>
</div>
<div class="flex gap-2 mb-6 mt-2.5">
<div class="flex gap-2 mt-2.5">
<span class="sn-icon sn-icon-users"></span>
<span class="tw-hidden lg:block shrink-0">
{{ i18n.t('my_modules.details.assigned_users') }}
@ -148,6 +148,15 @@
</SelectDropdown>
</div>
</div>
<div class="flex gap-2 mb-6 mt-2.5">
<span class="sn-icon sn-icon-tag"></span>
<span class="tw-hidden lg:block shrink-0">
{{ i18n.t('my_modules.details.tags') }}
</span>
<div class="grow -mt-1.5">
<TagsInput :subject="myModule" v-if="myModule" />
</div>
</div>
</div>
</div>
</template>
@ -156,6 +165,7 @@
import GeneralDropdown from '../shared/general_dropdown.vue';
import DateTimePicker from '../shared/date_time_picker.vue';
import SelectDropdown from '../shared/select_dropdown.vue';
import TagsInput from '../shared/tags_input.vue';
import axios from '../../packs/custom_axios.js';
import escapeHtml from '../shared/escape_html.js';
import {
@ -175,7 +185,8 @@ export default {
components: {
GeneralDropdown,
DateTimePicker,
SelectDropdown
SelectDropdown,
TagsInput
},
data() {
return {

View file

@ -0,0 +1,133 @@
<template>
<div>
<GeneralDropdown @open="opened = true" @close="opened = false">
<template v-slot:field>
<div class="w-full flex flex-wrap rounded gap-2 p-1 border border-solid cursor-pointer"
:class="{
'!border-sn-science-blue': opened,
'hover:!border-sn-light-grey !border-transparent': !opened,
}">
<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>
</div>
</template>
<div v-else class="sci-tag bg-sn-super-light-grey">
{{ i18n.t('tags.tags_input.add_tag') }}
</div>
</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 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>
</div>
<div class="sci-tag text-white" :style="{ backgroundColor: tag[2] }" >
{{ tag[1] }}
</div>
</div>
<hr class="my-0 border-t w-full mb-1">
<button class="btn btn-light btn-black w-32">
<span class="sn-icon sn-icon-edit"></span>
{{ i18n.t('tags.tags_input.edit_tags') }}
</button>
</div>
</template>
</GeneralDropdown>
</div>
</template>
<script>
import axios from '../../packs/custom_axios.js';
import GeneralDropdown from './general_dropdown.vue';
import {
list_users_settings_team_tags_path,
} from '../../routes.js';
export default {
name: 'TagsInput',
props: {
subject: {
type: Object,
required: true
},
},
components: {
GeneralDropdown,
},
computed: {
tagsUrl() {
return list_users_settings_team_tags_path({team_id: this.subject.attributes.team_id});
},
linkTagUrl() {
return this.subject.attributes.urls.link_tag;
},
unlinkTagUrl() {
return this.subject.attributes.urls.unlink_tag;
},
},
created() {
this.loadAllTags();
this.tags = this.subject.attributes.tags || [];
},
data() {
return {
tags: [],
allTags: [],
linkingTag: false,
opened: false,
};
},
methods: {
loadAllTags() {
axios.get(this.tagsUrl).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.linkTagUrl, {
tag_id: tag[0],
}).then((response) => {
this.tags.push(response.data.tag);
this.linkingTag = false;
}).catch(() => {
this.linkingTag = false;
HelperModule.flashAlertMsg(I18n.t('errors.general'), 'danger');
});
},
unlinkTag(tag) {
if (this.linkingTag) {
return;
}
this.linkingTag = true;
axios.post(this.unlinkTagUrl, {
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

@ -6,7 +6,7 @@ class MyModuleSerializer < ActiveModel::Serializer
include ApplicationHelper
include ActionView::Helpers::TextHelper
attributes :name, :description, :permissions, :description_view, :urls, :last_modified_by_name, :created_at, :updated_at,
attributes :name, :description, :permissions, :description_view, :urls, :last_modified_by_name, :created_at, :updated_at, :tags, :team_id,
:project_name, :experiment_name, :created_by_name, :is_creator_current_user, :code, :designated_user_ids, :due_date_cell, :start_date_cell, :completed_on
def project_name
@ -17,6 +17,10 @@ class MyModuleSerializer < ActiveModel::Serializer
object.experiment.name
end
def team_id
object.team.id
end
def created_by_name
object.created_by&.full_name
end
@ -53,7 +57,9 @@ class MyModuleSerializer < ActiveModel::Serializer
def urls
{
show_access: access_permissions_my_module_path(object),
show_user_group_assignments_access: show_user_group_assignments_access_permissions_my_module_path(object)
show_user_group_assignments_access: show_user_group_assignments_access_permissions_my_module_path(object),
link_tag: link_tag_my_module_path(object),
unlink_tag: unlink_tag_my_module_path(object)
}
end
@ -100,4 +106,10 @@ class MyModuleSerializer < ActiveModel::Serializer
def description
sanitize_input(object.tinymce_render('description'))
end
def tags
object.tags.map do |tag|
[tag.id, tag.name, tag.color]
end
end
end

View file

@ -97,30 +97,6 @@
<%= render partial: 'my_modules/repositories/consume_stock_modal'%>
<!-- Tags modal -->
<div id="tagsModalContainer" class="vue-tags-modal">
<div ref="tagsModal" id="tagsModalComponent"></div>
<teleport to="body">
<tags-modal v-if="tagsModalOpen"
:params="<%=
{
id: @my_module.id,
permissions: {
manage_tags: can_manage_my_module_tags?(@my_module)
},
urls: {
assigned_tags: assigned_tags_my_module_my_module_tags_path(@my_module),
assign_tags: my_module_my_module_tags_path(@my_module)
}
}.to_json
%>"
:tags-colors="<%= Constants::TAG_COLORS.to_json %>"
project-name="<%= @experiment.project.name %>"
project-tags-url="<%= project_tags_path(@experiment.project) %>"
@close="close"
@tags-loaded="syncTags"
/>
</teleport>
</div>
<div id="accessModalContainer" class="vue-access-modal"
data-url="<%= my_module_path(@my_module, format: :json) %>"
>
@ -137,8 +113,6 @@
<%= stylesheet_link_tag 'datatables' %>
<%= javascript_include_tag "handsontable.full" %>
<%= render partial: "shared/formulas_libraries" %>
<%= javascript_include_tag("my_modules/protocols") %>
<%= javascript_include_tag("my_modules/tags") %>
<%= javascript_include_tag 'emoji_button' %>
<%= javascript_include_tag("my_modules/repositories") %>
<%= javascript_include_tag("my_modules/pwa_mobile_app") %>

View file

@ -4236,6 +4236,9 @@ en:
merge: "Merge"
merge_success: "Tags merged successfully."
merge_error: "There was an error merging tags."
tags_input:
add_tag: "Add tag"
edit_tags: "Edit tags"
user_groups:
promo:
head_title: 'User groups'

View file

@ -525,6 +525,8 @@ Rails.application.routes.draw do
post :favorite
post :unfavorite
get :assigned_users
post :link_tag
post :unlink_tag
end
resources :user_my_modules, path: '/users', only: %i(index create destroy) do
collection do