Added tag autocompletion and tag management in admin interface.

This commit is contained in:
Martin Edenhofer 2016-06-02 08:58:10 +02:00
parent 5977b9e348
commit 7c0893ab36
18 changed files with 1044 additions and 159 deletions

View file

@ -1,9 +1,27 @@
# coffeelint: disable=camel_case_classes # coffeelint: disable=camel_case_classes
class App.UiElement.tag class App.UiElement.tag
@render: (attribute) -> @render: (attribute) ->
item = $( App.view('generic/input')( attribute: attribute ) ) item = $( App.view('generic/input')(attribute: attribute) )
source = "#{App.Config.get('api_path')}/tag_search"
possibleTags = {}
a = -> a = ->
$('#' + attribute.id ).tokenfield(createTokensOnBlur: true) $('#' + attribute.id ).tokenfield(
createTokensOnBlur: true
autocomplete: {
source: source
minLength: 2
response: (e, ui) ->
return if !ui
return if !ui.content
for item in ui.content
possibleTags[item.value] = true
},
).on('tokenfield:createtoken', (e) ->
if App.Config.get('tag_new') is false && !possibleTags[e.attrs.value]
e.preventDefault()
return false
true
)
$('#' + attribute.id ).parent().css('height', 'auto') $('#' + attribute.id ).parent().css('height', 'auto')
App.Delay.set(a, 120, undefined, 'tags') App.Delay.set(a, 120, undefined, 'tags')
item item

View file

@ -0,0 +1,149 @@
class Index extends App.ControllerContent
events:
'change .js-newTagSetting input': 'setTagNew'
'submit .js-create': 'create'
elements:
'.js-newTagSetting input': 'tagNewSetting'
constructor: ->
super
# check authentication
return if !@authenticate(false, 'Admin')
@title 'Tags', true
@subscribeId = App.Setting.subscribe(@render, initFetch: true, clear: false)
release: =>
App.Setting.unsubscribe(@subscribeId)
render: =>
currentNewTagSetting = @Config.get('tag_new') || true
return if currentNewTagSetting is @lastNewTagSetting
@lastNewTagSetting = currentNewTagSetting
@html App.view('tag/index')()
new Table(
el: @$('.js-Table')
)
setTagNew: (e) =>
value = @tagNewSetting.prop('checked')
console.log('aa', value)
App.Setting.set('tag_new', value)
create: (e) =>
e.preventDefault()
field = $(e.currentTarget).find('input[name]')
name = field.val().trim()
return if !name
@ajax(
type: 'POST'
url: "#{@apiPath}/tag_list"
data: JSON.stringify(name: name)
success: (data, status, xhr) =>
field.val('')
new Table(
el: @$('.js-Table')
)
)
class Table extends App.Controller
events:
'click .js-delete': 'destroy'
'click .js-edit': 'edit'
constructor: ->
super
@load()
load: =>
@ajax(
id: 'tag_admin_list'
type: 'GET'
url: "#{@apiPath}/tag_list"
processData: true
success: (data, status, xhr) =>
@render(data)
)
render: (list) =>
@html App.view('tag/table')(
list: list
)
edit: (e) =>
e.preventDefault()
row = $(e.currentTarget).closest('tr')
name = row.find('.js-name').text()
id = row.data('id')
new Edit(
id: id
name: name
callback: @load
)
destroy: (e) ->
e.preventDefault()
row = $(e.currentTarget).closest('tr')
id = row.data('id')
new DestroyConfirm(
id: id
row: row
)
class Edit extends App.ControllerModal
buttonClose: true
buttonCancel: true
buttonSubmit: 'Submit'
head: 'Edit'
small: true
content: ->
App.view('tag/edit')(
id: @id
name: @name
)
onSubmit: (e) =>
e.preventDefault()
params = @formParam(e.target)
@ajax(
id: 'tag_admin_list'
type: 'PUT'
url: "#{@apiPath}/tag_list/#{@id}"
data: JSON.stringify(
id: @id
name: params.name
)
success: (data, status, xhr) =>
@callback()
@close()
)
class DestroyConfirm extends App.ControllerModal
buttonClose: true
buttonCancel: true
buttonSubmit: 'yes'
buttonClass: 'btn--danger'
head: 'Confirm'
small: true
content: ->
App.i18n.translateContent('Sure to delete this object?')
onSubmit: =>
@ajax(
id: 'tag_admin_list'
type: 'DELETE'
url: "#{@apiPath}/tag_list/#{@id}"
processData: true
success: (data, status, xhr) =>
@row.remove()
@close()
)
App.Config.set('Tags', { prio: 2320, name: 'Tags', parent: '#manage', target: '#manage/tags', controller: Index, role: ['Admin'] }, 'NavBarAdmin')

View file

@ -1,4 +1,6 @@
class App.WidgetTag extends App.Controller class App.WidgetTag extends App.Controller
possibleTags: {}
shiftHeld: false
elements: elements:
'.js-newTagLabel': 'newTagLabel' '.js-newTagLabel': 'newTagLabel'
'.js-newTagInput': 'newTagInput' '.js-newTagInput': 'newTagInput'
@ -9,6 +11,8 @@ class App.WidgetTag extends App.Controller
'click .js-newTagInput': 'onAddTag' 'click .js-newTagInput': 'onAddTag'
'submit form': 'onAddTag' 'submit form': 'onAddTag'
'click .js-delete': 'onRemoveTag' 'click .js-delete': 'onRemoveTag'
'mousedown .js-tag': 'shiftHeldToogle'
'click .js-tag': 'searchTag'
constructor: -> constructor: ->
super super
@ -40,6 +44,17 @@ class App.WidgetTag extends App.Controller
tags: @tags || [], tags: @tags || [],
) )
source = "#{App.Config.get('api_path')}/tag_search"
@el.find('.js-newTagInput').autocomplete(
source: source
minLength: 2
response: (e, ui) =>
return if !ui
return if !ui.content
for item in ui.content
@possibleTags[item.value] = true
)
showInput: (e) -> showInput: (e) ->
e.preventDefault() e.preventDefault()
@newTagLabel.addClass('hide') @newTagLabel.addClass('hide')
@ -66,16 +81,16 @@ class App.WidgetTag extends App.Controller
if _.contains(@tags, item) if _.contains(@tags, item)
@render() @render()
return return
return if App.Config.get('tag_new') is false && !@possibleTags[item]
@tags.push item @tags.push item
@render() @render()
@ajax( @ajax(
type: 'GET', type: 'GET'
url: @apiPath + '/tags/add', url: @apiPath + '/tags/add'
data: data:
object: @object_type, object: @object_type
o_id: @object.id, o_id: @object.id
item: item item: item
processData: true, processData: true,
success: (data, status, xhr) => success: (data, status, xhr) =>
@ -104,3 +119,24 @@ class App.WidgetTag extends App.Controller
success: (data, status, xhr) => success: (data, status, xhr) =>
@fetch() @fetch()
) )
searchTag: (e) =>
e.preventDefault()
item = $(e.target).text()
item = item.replace('"', '')
if item.match(/\W/)
item = "\"#{item}\""
searchAttribute = "tag:#{item}"
currentValue = $('#global-search').val()
if @shiftHeld && currentValue
currentValue += ' AND '
currentValue += searchAttribute
else
currentValue = searchAttribute
$('#global-search').val(currentValue)
delay = ->
$('#global-search').focus()
@delay(delay, 20)
shiftHeldToogle: (e) =>
@shiftHeld = e.shiftKey

View file

@ -0,0 +1,5 @@
<form>
<div class="form-item">
<input type="text" name="name" class="" value="<%= @name %>" autocomplete="off">
</div>
</form>

View file

@ -0,0 +1,29 @@
<div class="page-header">
<div class="page-header-title">
<h1><%- @T('Tags') %><small></small></h1>
</div>
</div>
<div class="page-content">
<div class="settings-entry">
<div class="page-header-title">
<div class="zammad-switch zammad-switch--small js-newTagSetting">
<input name="chat" type="checkbox" id="tag-new" <% if @C('tag_new'): %>checked<% end %>>
<label for="tag-new"></label>
</div>
<h2><%- @T('New Tags') %></h2>
</div>
<p>⚠ <%- @T('Allow users to add new tags.') %></p>
</div>
<div class="settings-entry">
<h2><%- @T('Manage Tags') %></h2>
<form class="horizontal form-group formGroup--halfSize js-create">
<div class="form-item">
<input type="text" name="name" class="" autocomplete="off">
</div>
<button type="submit" class="btn btn--primary js-submit"><%- @T('Add') %></button>
</form>
<div class="js-Table"></div>
</div>
</div>

View file

@ -0,0 +1,18 @@
<table class="table table-striped table-hover">
<thead>
<tr>
<th><%- @T('Name') %></th>
<th><%- @T('Count') %></th>
<th><%- @T('Action') %></th>
</tr>
</thead>
<tbody>
<% for item in @list: %>
<tr data-id="<%= item.id %>" class="js-edit u-clickable">
<td class="js-name"><%= item.name %></td>
<td><%= item.count %></td>
<td><a href="#" class="js-delete" title="<%- @Ti('Delete') %>"><%- @Icon('trash') %></a></td>
</tr>
<% end %>
</tbody>
</table>

View file

@ -1,8 +1,8 @@
<label><%- @T( 'Tags' ) %></label> <label><%- @T('Tags') %></label>
<ul class="list list--sidebar"> <ul class="list list--sidebar">
<% for tag in @tags: %> <% for tag in @tags: %>
<li class="list-item"> <li class="list-item">
<div class="list-item-name js-tag"><%= tag %></div> <a href="#" class="list-item-name js-tag"><%= tag %></a>
<div class="list-item-delete js-delete"> <div class="list-item-delete js-delete">
<%- @Icon('diagonal-cross') %> <%- @Icon('diagonal-cross') %>
</div> </div>

View file

@ -4,6 +4,7 @@
* the top of the compiled file, but it's generally better to create a new file per style scope. * the top of the compiled file, but it's generally better to create a new file per style scope.
*= require_self *= require_self
*= require ./bootstrap.css *= require ./bootstrap.css
*= require ./jquery-ui.css
*= require ./cropper.css *= require ./cropper.css
*= require ./fineuploader.css *= require ./fineuploader.css
*= require ./font.css *= require ./font.css

332
app/assets/stylesheets/jquery-ui.css vendored Normal file
View file

@ -0,0 +1,332 @@
/*! jQuery UI - v1.11.4 - 2016-06-01
* http://jqueryui.com
* Includes: core.css, autocomplete.css, menu.css, theme.css
* To view and modify this theme, visit http://jqueryui.com/themeroller/?ffDefault=Arial%2CHelvetica%2Csans-serif&fsDefault=1em&fwDefault=normal&cornerRadius=3px&bgColorHeader=e9e9e9&bgTextureHeader=flat&borderColorHeader=dddddd&fcHeader=333333&iconColorHeader=444444&bgColorContent=ffffff&bgTextureContent=flat&borderColorContent=dddddd&fcContent=333333&iconColorContent=444444&bgColorDefault=f6f6f6&bgTextureDefault=flat&borderColorDefault=c5c5c5&fcDefault=454545&iconColorDefault=777777&bgColorHover=ededed&bgTextureHover=flat&borderColorHover=cccccc&fcHover=2b2b2b&iconColorHover=555555&bgColorActive=007fff&bgTextureActive=flat&borderColorActive=003eff&fcActive=ffffff&iconColorActive=ffffff&bgColorHighlight=fffa90&bgTextureHighlight=flat&borderColorHighlight=dad55e&fcHighlight=777620&iconColorHighlight=777620&bgColorError=fddfdf&bgTextureError=flat&borderColorError=f1a899&fcError=5f3f3f&iconColorError=cc0000&bgColorOverlay=aaaaaa&bgTextureOverlay=flat&bgImgOpacityOverlay=0&opacityOverlay=30&bgColorShadow=666666&bgTextureShadow=flat&bgImgOpacityShadow=0&opacityShadow=30&thicknessShadow=5px&offsetTopShadow=0px&offsetLeftShadow=0px&cornerRadiusShadow=8px
* Copyright jQuery Foundation and other contributors; Licensed MIT */
/* Layout helpers
----------------------------------*/
.ui-helper-hidden {
display: none;
}
.ui-helper-hidden-accessible {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
.ui-helper-reset {
margin: 0;
padding: 0;
border: 0;
outline: 0;
line-height: 1.3;
text-decoration: none;
font-size: 100%;
list-style: none;
}
.ui-helper-clearfix:before,
.ui-helper-clearfix:after {
content: "";
display: table;
border-collapse: collapse;
}
.ui-helper-clearfix:after {
clear: both;
}
.ui-helper-clearfix {
min-height: 0; /* support: IE7 */
}
.ui-helper-zfix {
width: 100%;
height: 100%;
top: 0;
left: 0;
position: absolute;
opacity: 0;
filter:Alpha(Opacity=0); /* support: IE8 */
}
.ui-front {
z-index: 100;
}
/* Interaction Cues
----------------------------------*/
.ui-state-disabled {
cursor: default !important;
}
/* Icons
----------------------------------*/
/* states and images */
.ui-icon {
display: block;
text-indent: -99999px;
overflow: hidden;
background-repeat: no-repeat;
}
/* Misc visuals
----------------------------------*/
/* Overlays */
.ui-widget-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.ui-autocomplete {
position: absolute;
top: 0;
left: 0;
cursor: default;
}
.ui-menu {
list-style: none;
padding: 0;
margin: 0;
display: block;
outline: none;
}
.ui-menu .ui-menu {
position: absolute;
}
.ui-menu .ui-menu-item {
position: relative;
margin: 0;
padding: 3px 1em 3px .4em;
cursor: pointer;
min-height: 0; /* support: IE7 */
/* support: IE10, see #8844 */
list-style-image: url("data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7");
}
.ui-menu .ui-menu-divider {
margin: 5px 0;
height: 0;
font-size: 0;
line-height: 0;
border-width: 1px 0 0 0;
}
.ui-menu .ui-state-focus,
.ui-menu .ui-state-active {
margin: -1px;
}
/* icon support */
.ui-menu-icons {
position: relative;
}
.ui-menu-icons .ui-menu-item {
padding-left: 2em;
}
/* left-aligned */
.ui-menu .ui-icon {
position: absolute;
top: 0;
bottom: 0;
left: .2em;
margin: auto 0;
}
/* right-aligned */
.ui-menu .ui-menu-icon {
left: auto;
right: 0;
}
/* Component containers
----------------------------------*/
.ui-widget {
font-family: Arial,Helvetica,sans-serif;
font-size: 1em;
}
.ui-widget .ui-widget {
font-size: 1em;
}
.ui-widget input,
.ui-widget select,
.ui-widget textarea,
.ui-widget button {
font-family: Arial,Helvetica,sans-serif;
font-size: 1em;
}
.ui-widget-content {
border: 1px solid #dddddd;
background: #ffffff;
color: #333333;
}
.ui-widget-content a {
color: #333333;
}
.ui-widget-header {
border: 1px solid #dddddd;
background: #e9e9e9;
color: #333333;
font-weight: bold;
}
.ui-widget-header a {
color: #333333;
}
/* Interaction states
----------------------------------*/
.ui-state-default,
.ui-widget-content .ui-state-default,
.ui-widget-header .ui-state-default {
border: 1px solid #c5c5c5;
background: #f6f6f6;
font-weight: normal;
color: #454545;
}
.ui-state-default a,
.ui-state-default a:link,
.ui-state-default a:visited {
color: #454545;
text-decoration: none;
}
.ui-state-hover,
.ui-widget-content .ui-state-hover,
.ui-widget-header .ui-state-hover,
.ui-state-focus,
.ui-widget-content .ui-state-focus,
.ui-widget-header .ui-state-focus {
border: 1px solid #cccccc;
background: #ededed;
font-weight: normal;
color: #2b2b2b;
}
.ui-state-hover a,
.ui-state-hover a:hover,
.ui-state-hover a:link,
.ui-state-hover a:visited,
.ui-state-focus a,
.ui-state-focus a:hover,
.ui-state-focus a:link,
.ui-state-focus a:visited {
color: #2b2b2b;
text-decoration: none;
}
.ui-state-active,
.ui-widget-content .ui-state-active,
.ui-widget-header .ui-state-active {
border: 1px solid #003eff;
background: #007fff;
font-weight: normal;
color: #ffffff;
}
.ui-state-active a,
.ui-state-active a:link,
.ui-state-active a:visited {
color: #ffffff;
text-decoration: none;
}
/* Interaction Cues
----------------------------------*/
.ui-state-highlight,
.ui-widget-content .ui-state-highlight,
.ui-widget-header .ui-state-highlight {
border: 1px solid #dad55e;
background: #fffa90;
color: #777620;
}
.ui-state-highlight a,
.ui-widget-content .ui-state-highlight a,
.ui-widget-header .ui-state-highlight a {
color: #777620;
}
.ui-state-error,
.ui-widget-content .ui-state-error,
.ui-widget-header .ui-state-error {
border: 1px solid #f1a899;
background: #fddfdf;
color: #5f3f3f;
}
.ui-state-error a,
.ui-widget-content .ui-state-error a,
.ui-widget-header .ui-state-error a {
color: #5f3f3f;
}
.ui-state-error-text,
.ui-widget-content .ui-state-error-text,
.ui-widget-header .ui-state-error-text {
color: #5f3f3f;
}
.ui-priority-primary,
.ui-widget-content .ui-priority-primary,
.ui-widget-header .ui-priority-primary {
font-weight: bold;
}
.ui-priority-secondary,
.ui-widget-content .ui-priority-secondary,
.ui-widget-header .ui-priority-secondary {
opacity: .7;
filter:Alpha(Opacity=70); /* support: IE8 */
font-weight: normal;
}
.ui-state-disabled,
.ui-widget-content .ui-state-disabled,
.ui-widget-header .ui-state-disabled {
opacity: .35;
filter:Alpha(Opacity=35); /* support: IE8 */
background-image: none;
}
.ui-state-disabled .ui-icon {
filter:Alpha(Opacity=35); /* support: IE8 - See #6059 */
}
/* Misc visuals
----------------------------------*/
/* Corner radius */
.ui-corner-all,
.ui-corner-top,
.ui-corner-left,
.ui-corner-tl {
border-top-left-radius: 3px;
}
.ui-corner-all,
.ui-corner-top,
.ui-corner-right,
.ui-corner-tr {
border-top-right-radius: 3px;
}
.ui-corner-all,
.ui-corner-bottom,
.ui-corner-left,
.ui-corner-bl {
border-bottom-left-radius: 3px;
}
.ui-corner-all,
.ui-corner-bottom,
.ui-corner-right,
.ui-corner-br {
border-bottom-right-radius: 3px;
}
/* Overlays */
.ui-widget-overlay {
background: #aaaaaa;
opacity: .3;
filter: Alpha(Opacity=30); /* support: IE8 */
}
.ui-widget-shadow {
margin: 0px 0 0 0px;
padding: 5px;
background: #666666;
opacity: .3;
filter: Alpha(Opacity=30); /* support: IE8 */
border-radius: 8px;
}

View file

@ -3,14 +3,18 @@
class TagsController < ApplicationController class TagsController < ApplicationController
before_action :authentication_check before_action :authentication_check
# GET /api/v1/tags # GET /api/v1/tag_search?term=abc
def index def search
list = Tag.list() list = Tag::Item.where('name_downcase LIKE ?', "#{params[:term].strip.downcase}%").order('name ASC').limit(params[:limit] || 10)
results = []
# return result list.each {|item|
render json: { result = {
tags: list, id: item.id,
value: item.name,
}
results.push result
} }
render json: results
end end
# GET /api/v1/tags # GET /api/v1/tags
@ -31,7 +35,7 @@ class TagsController < ApplicationController
success = Tag.tag_add( success = Tag.tag_add(
object: params[:object], object: params[:object],
o_id: params[:o_id], o_id: params[:o_id],
item: params[:item], item: params[:item].strip,
) )
if success if success
render json: success, status: :created render json: success, status: :created
@ -45,7 +49,7 @@ class TagsController < ApplicationController
success = Tag.tag_remove( success = Tag.tag_remove(
object: params[:object], object: params[:object],
o_id: params[:o_id], o_id: params[:o_id],
item: params[:item], item: params[:item].strip,
) )
if success if success
render json: success, status: :created render json: success, status: :created
@ -54,4 +58,60 @@ class TagsController < ApplicationController
end end
end end
# GET /api/v1/tag_list
def admin_list
list = Tag::Item.order('name ASC').limit(params[:limit] || 1000)
results = []
list.each {|item|
result = {
id: item.id,
name: item.name,
count: Tag.where(tag_item_id: item.id).count
}
results.push result
}
render json: results
end
# POST /api/v1/tag_list
def admin_create
return if deny_if_not_role('Admin')
name = params[:name].strip
if !Tag.tag_item_lookup(name)
Tag::Item.create(name: name)
end
render json: {}
end
# PUT /api/v1/tag_list/:id
def admin_rename
return if deny_if_not_role('Admin')
name = params[:name].strip
tag_item = Tag::Item.find(params[:id])
existing_tag_id = Tag.tag_item_lookup(name)
if existing_tag_id
# assign to already existing tag
Tag.where(tag_item_id: tag_item.id).update_all(tag_item_id: existing_tag_id)
# delete not longer used tag
tag_item.destroy
# update new tag name
else
tag_item.name = name
tag_item.save
end
render json: {}
end
# DELETE /api/v1/tag_list/:id
def admin_delete
return if deny_if_not_role('Admin')
tag_item = Tag::Item.find(params[:id])
Tag.where(tag_item_id: tag_item.id).destroy_all
tag_item.destroy
render json: {}
end
end end

View file

@ -21,7 +21,7 @@ class Tag < ApplicationModel
# return in duplicate # return in duplicate
current_tags = tag_list(data) current_tags = tag_list(data)
return true if current_tags.include?(data[:item].downcase.strip) return true if current_tags.include?(data[:item].strip)
# create history # create history
Tag.create( Tag.create(
@ -79,17 +79,16 @@ class Tag < ApplicationModel
def self.tag_item_lookup(name) def self.tag_item_lookup(name)
name = name.downcase
# use cache # use cache
return @@cache_item[name] if @@cache_item[name] return @@cache_item[name] if @@cache_item[name]
# lookup # lookup
tag_item = Tag::Item.find_by(name: name) tag_items = Tag::Item.where(name: name)
if tag_item tag_items.each {|tag_item|
next if tag_item.name != name
@@cache_item[name] = tag_item.id @@cache_item[name] = tag_item.id
return tag_item.id return tag_item.id
end }
# create # create
tag_item = Tag::Item.create(name: name) tag_item = Tag::Item.create(name: name)
@ -130,6 +129,12 @@ class Tag < ApplicationModel
end end
class Item < ActiveRecord::Base class Item < ActiveRecord::Base
before_save :fill_namedowncase
def fill_namedowncase
self.name_downcase = name.downcase
end
end end
end end

View file

@ -1,9 +1,14 @@
Zammad::Application.routes.draw do Zammad::Application.routes.draw do
api_path = Rails.configuration.api_path api_path = Rails.configuration.api_path
# links match api_path + '/tags', to: 'tags#list', via: :get
match api_path + '/tags', to: 'tags#list', via: :get match api_path + '/tags/add', to: 'tags#add', via: :get
match api_path + '/tags/add', to: 'tags#add', via: :get match api_path + '/tags/remove', to: 'tags#remove', via: :get
match api_path + '/tags/remove', to: 'tags#remove', via: :get match api_path + '/tag_search', to: 'tags#search', via: :get
match api_path + '/tag_list', to: 'tags#admin_list', via: :get
match api_path + '/tag_list', to: 'tags#admin_create', via: :post
match api_path + '/tag_list/:id', to: 'tags#admin_rename', via: :put
match api_path + '/tag_list/:id', to: 'tags#admin_delete', via: :delete
end end

View file

@ -244,9 +244,10 @@ class CreateBase < ActiveRecord::Migration
create_table :tag_items do |t| create_table :tag_items do |t|
t.string :name, limit: 250, null: false t.string :name, limit: 250, null: false
t.string :name_downcase, limit: 250, null: false
t.timestamps null: false t.timestamps null: false
end end
add_index :tag_items, [:name], unique: true add_index :tag_items, [:name_downcase]
create_table :recent_views do |t| create_table :recent_views do |t|
t.references :recent_view_object, null: false t.references :recent_view_object, null: false

View file

@ -0,0 +1,35 @@
class UpdateTag < ActiveRecord::Migration
def up
# return if it's a new setup
return if !Setting.find_by(name: 'system_init_done')
remove_index :tag_items, column: [:name]
add_column :tag_items, :name_downcase, :string, limit: 250
add_index :tag_items, [:name_downcase]
Tag.reset_column_information
Tag::Item.all.each(&:save)
Setting.create_if_not_exists(
title: 'New Tags',
name: 'tag_new',
area: 'Web::Base',
description: 'Allow users to crate new tags.',
options: {
form: [
{
display: '',
null: true,
name: 'tag_new',
tag: 'boolean',
options: {
true => 'yes',
false => 'no',
},
},
],
},
state: true,
frontend: true,
)
end
end

View file

@ -1513,6 +1513,29 @@ Setting.create_if_not_exists(
frontend: false frontend: false
) )
Setting.create_if_not_exists(
title: 'New Tags',
name: 'tag_new',
area: 'Web::Base',
description: 'Allow users to crate new tags.',
options: {
form: [
{
display: '',
null: true,
name: 'tag_new',
tag: 'boolean',
options: {
true => 'yes',
false => 'no',
},
},
],
},
state: true,
frontend: true
)
Setting.create_if_not_exists( Setting.create_if_not_exists(
title: 'Default calendar Tickets subscriptions', title: 'Default calendar Tickets subscriptions',
name: 'defaults_calendar_subscriptions_tickets', name: 'defaults_calendar_subscriptions_tickets',

View file

@ -27,9 +27,7 @@ class AgentTicketActionLevel8Test < TestCase
css: '.active .ticket-form-bottom .token-input', css: '.active .ticket-form-bottom .token-input',
value: 'tag1, tag2', value: 'tag1, tag2',
) )
sendkey( sendkey(value: :tab)
value: :tab,
)
# reload browser # reload browser
sleep 6 sleep 6
@ -45,25 +43,14 @@ class AgentTicketActionLevel8Test < TestCase
end end
# verify tags # verify tags
tags = @browser.find_elements({ css: '.content.active .js-tag' }) tags_verify(
assert(tags) tags: {
assert(tags[0]) 'tag1' => true,
tag1_found = false 'tag2' => true,
tag2_found = false 'tag3' => false,
tags.each {|element| 'tag4' => false,
text = element.text }
if text == 'tag1' )
tag1_found = true
assert(true, 'tag1 exists')
elsif text == 'tag2'
tag2_found = true
assert(true, 'tag2 exists')
else
assert(false, "invalid tag '#{text}'")
end
}
assert(tag1_found, 'tag1 exists')
assert(tag2_found, 'tag2 exists')
# set tag (by blur) # set tag (by blur)
ticket2 = ticket_create( ticket2 = ticket_create(
@ -80,38 +67,22 @@ class AgentTicketActionLevel8Test < TestCase
css: '.active .ticket-form-bottom .token-input', css: '.active .ticket-form-bottom .token-input',
value: 'tag3, tag4', value: 'tag3, tag4',
) )
click( click(css: '#global-search')
css: '#global-search', click(css: '.active .newTicket button.js-submit')
)
click(
css: '.active .newTicket button.js-submit',
)
sleep 5 sleep 5
if @browser.current_url !~ /#{Regexp.quote('#ticket/zoom/')}/ if @browser.current_url !~ /#{Regexp.quote('#ticket/zoom/')}/
raise 'Unable to create ticket!' raise 'Unable to create ticket!'
end end
# verify tags # verify tags
tags = @browser.find_elements({ css: '.content.active .js-tag' }) tags_verify(
assert(tags) tags: {
assert(tags[0]) 'tag1' => false,
tag3_found = false 'tag2' => false,
tag4_found = false 'tag3' => true,
tags.each {|element| 'tag4' => true,
text = element.text }
if text == 'tag3' )
tag3_found = true
assert(true, 'tag 3 exists')
elsif text == 'tag4'
tag4_found = true
assert(true, 'tag 4 exists')
else
assert(false, "invalid tag '#{text}'")
end
}
assert(tag3_found, 'tag3 exists')
assert(tag4_found, 'tag4 exists')
ticket3 = ticket_create( ticket3 = ticket_create(
data: { data: {
@ -175,82 +146,202 @@ class AgentTicketActionLevel8Test < TestCase
sleep 0.5 sleep 0.5
# verify tags # verify tags
tags = @browser.find_elements({ css: '.content.active .js-tag' }) tags_verify(
assert(tags) tags: {
assert(tags[0]) 'tag1' => true,
tag1_found = false 'tag 2' => true,
tag2_found = false 'tag2' => false,
tag3_found = false 'tag3' => true,
tag4_found = false 'tag4' => true,
tag5_found = false 'tag5' => true,
tags.each {|element| }
text = element.text )
if text == 'tag1'
tag1_found = true
assert(true, 'tag1 exists')
elsif text == 'tag 2'
tag2_found = true
assert(true, 'tag 2 exists')
elsif text == 'tag3'
tag3_found = true
assert(true, 'tag3 exists')
elsif text == 'tag4'
tag4_found = true
assert(true, 'tag4 exists')
elsif text == 'tag5'
tag5_found = true
assert(true, 'tag5 exists')
else
assert(false, "invalid tag '#{text}'")
end
}
assert(tag1_found, 'tag1 exists')
assert(tag2_found, 'tag2 exists')
assert(tag3_found, 'tag3 exists')
assert(tag4_found, 'tag4 exists')
assert(tag5_found, 'tag5 exists')
# reload browser # reload browser
reload() reload()
sleep 2
# verify tags # verify tags
tags = @browser.find_elements({ css: '.content.active .js-tag' }) tags_verify(
assert(tags) tags: {
assert(tags[0]) 'tag1' => true,
tag1_found = false 'tag 2' => true,
tag2_found = false 'tag2' => false,
tag3_found = false 'tag3' => true,
tag4_found = false 'tag4' => true,
tag5_found = false 'tag5' => true,
tags.each {|element| }
text = element.text )
if text == 'tag1'
tag1_found = true
assert(true, 'tag1 exists')
elsif text == 'tag 2'
tag2_found = true
assert(true, 'tag 2 exists')
elsif text == 'tag3'
tag3_found = true
assert(true, 'tag3 exists')
elsif text == 'tag4'
tag4_found = true
assert(true, 'tag4 exists')
elsif text == 'tag5'
tag5_found = true
assert(true, 'tag5 exists')
else
assert(false, "invalid tag '#{text}'")
end
}
assert(tag1_found, 'tag1 exists')
assert(tag2_found, 'tag2 exists')
assert(tag3_found, 'tag3 exists')
assert(tag4_found, 'tag4 exists')
assert(tag5_found, 'tag5 exists')
end end
def test_b_link def test_b_tags
tag_prefix = "tag#{rand(999_999_999)}"
@browser = browser_instance
login(
username: 'master@example.com',
password: 'test',
url: browser_url,
)
tasks_close_all()
click(css: 'a[href="#manage"]')
click(css: 'a[href="#manage/tags"]')
switch(
css: '#content .js-newTagSetting',
type: 'off',
)
set(
css: '#content .js-create input[name="name"]',
value: tag_prefix + ' A',
)
click(css: '#content .js-create .js-submit')
set(
css: '#content .js-create input[name="name"]',
value: tag_prefix + ' a',
)
click(css: '#content .js-create .js-submit')
set(
css: '#content .js-create input[name="name"]',
value: tag_prefix + ' B',
)
click(css: '#content .js-create .js-submit')
set(
css: '#content .js-create input[name="name"]',
value: tag_prefix + ' C',
)
click(css: '#content .js-create .js-submit')
# set tag (by tab)
ticket1 = ticket_create(
data: {
customer: 'nico',
group: 'Users',
title: 'some subject 123äöü - tags no new 1',
body: 'some body 123äöü - tags no new 1',
},
do_not_submit: true,
)
sleep 1
set(
css: '.active .ticket-form-bottom .token-input',
value: "#{tag_prefix} A",
)
sleep 2
sendkey(value: :tab)
sleep 1
set(
css: '.active .ticket-form-bottom .token-input',
value: "#{tag_prefix} a",
)
sleep 2
sendkey(value: :tab)
sleep 1
set(
css: '.active .ticket-form-bottom .token-input',
value: "#{tag_prefix} B",
)
sleep 2
sendkey(value: :tab)
sleep 1
set(
css: '.active .ticket-form-bottom .token-input',
value: 'NOT EXISTING',
)
sleep 2
sendkey(value: :tab)
sleep 1
click(
css: '.active .newTicket button.js-submit',
)
sleep 5
if @browser.current_url !~ /#{Regexp.quote('#ticket/zoom/')}/
raise 'Unable to create ticket!'
end
# verify tags
tags_verify(
tags: {
"#{tag_prefix} A" => true,
"#{tag_prefix} a" => true,
"#{tag_prefix} B" => true,
'NOT EXISTING' => false,
}
)
# new ticket with tags in zoom
ticket1 = ticket_create(
data: {
customer: 'nico',
group: 'Users',
title: 'some subject 123äöü - tags no new 2',
body: 'some body 223äöü - tags no new 1',
},
)
click(css: '.active .sidebar .js-newTagLabel')
set(
css: '.active .sidebar .js-newTagInput',
value: "#{tag_prefix} A",
)
sleep 2
sendkey(value: :tab)
click(css: '.active .sidebar .js-newTagLabel')
set(
css: '.active .sidebar .js-newTagInput',
value: "#{tag_prefix} a",
)
sleep 2
sendkey(value: :tab)
click(css: '.active .sidebar .js-newTagLabel')
set(
css: '.active .sidebar .js-newTagInput',
value: "#{tag_prefix} B",
)
sleep 2
sendkey(value: :tab)
click(css: '.active .sidebar .js-newTagLabel')
set(
css: '.active .sidebar .js-newTagInput',
value: 'NOT EXISTING',
)
sleep 2
sendkey(value: :tab)
# verify tags
tags_verify(
tags: {
"#{tag_prefix} A" => true,
"#{tag_prefix} a" => true,
"#{tag_prefix} B" => true,
'NOT EXISTING' => false,
}
)
reload()
sleep 2
# verify tags
tags_verify(
tags: {
"#{tag_prefix} A" => true,
"#{tag_prefix} a" => true,
"#{tag_prefix} B" => true,
'NOT EXISTING' => false,
}
)
click(css: 'a[href="#manage"]')
click(css: 'a[href="#manage/tags"]')
switch(
css: '#content .js-newTagSetting',
type: 'on',
)
end
def test_c_link
@browser = browser_instance @browser = browser_instance
login( login(

View file

@ -3019,6 +3019,47 @@ wait untill text in selector disabppears
end end
=begin
tags_verify(
browser: browser2,
tags: {
'tag 1' => true,
'tag 2' => true,
'tag 3' => false,
},
)
=end
def tags_verify(params = {})
switch_window_focus(params)
log('tags_verify', params)
instance = params[:browser] || @browser
tags = instance.find_elements({ css: '.content.active .js-tag' })
assert(tags)
assert(tags[0])
tags_found = {}
params[:tags].each {|key, _value|
tags_found[key] = false
}
tags.each {|element|
text = element.text
if tags_found.key?(text)
tags_found[text] = true
else
assert(false, "tag exists but is not in check to verify '#{text}'")
end
}
params[:tags].each {|key, value|
assert_equal(value, tags_found[key], "tag '#{key}'")
}
end
def quote(string) def quote(string)
string_quoted = string string_quoted = string
string_quoted.gsub!(/&/, '&amp;') string_quoted.gsub!(/&/, '&amp;')

View file

@ -39,6 +39,23 @@ class TagTest < ActiveSupport::TestCase
}, },
}, },
{
tag_add: {
item: 'TAG2',
object: 'Object1',
o_id: 123,
created_by_id: 1
},
verify: {
object: 'Object1',
items: {
'tag1' => true,
'tag2' => true,
'TAG2' => true,
},
},
},
# test 2 # test 2
{ {
tag_add: { tag_add: {
@ -68,7 +85,7 @@ class TagTest < ActiveSupport::TestCase
object: 'Object2', object: 'Object2',
items: { items: {
'tagöäüß1' => true, 'tagöäüß1' => true,
'tagöäüß2' => true, 'Tagöäüß2' => true,
'tagöäüß3' => false, 'tagöäüß3' => false,
}, },
}, },
@ -87,6 +104,25 @@ class TagTest < ActiveSupport::TestCase
items: { items: {
'tag1' => false, 'tag1' => false,
'tag2' => true, 'tag2' => true,
'TAG2' => true,
},
},
},
# test 5
{
tag_remove: {
item: 'TAG2',
object: 'Object1',
o_id: 123,
created_by_id: 1
},
verify: {
object: 'Object1',
items: {
'tag1' => false,
'tag2' => true,
'TAG2' => false,
}, },
}, },
}, },
@ -96,19 +132,19 @@ class TagTest < ActiveSupport::TestCase
tags = nil tags = nil
if test[:tag_add] if test[:tag_add]
tags = test[:tag_add] tags = test[:tag_add]
success = Tag.tag_add( tags ) success = Tag.tag_add(tags)
assert( success, 'Tag.tag_add successful') assert(success, 'Tag.tag_add successful')
else else
tags = test[:tag_remove] tags = test[:tag_remove]
success = Tag.tag_remove( tags ) success = Tag.tag_remove(tags)
assert( success, 'Tag.tag_remove successful') assert(success, 'Tag.tag_remove successful')
end end
list = Tag.tag_list( tags ) list = Tag.tag_list(tags)
test[:verify][:items].each {|key, value| test[:verify][:items].each {|key, value|
if value == true if value == true
assert( list.include?( key ), "Tag verify - should exists but exists #{key}") assert(list.include?(key), "Tag verify - should exists but exists #{key}")
else else
assert( !list.include?( key ), "Tag verify - exists but should not #{key}") assert(!list.include?(key), "Tag verify - exists but should not #{key}")
end end
} }
} }
@ -121,10 +157,10 @@ class TagTest < ActiveSupport::TestCase
else else
test[:tag_remove] test[:tag_remove]
end end
success = Tag.tag_remove( tags ) success = Tag.tag_remove(tags)
assert( success, 'Tag.tag_remove successful') assert(success, 'Tag.tag_remove successful')
list = Tag.tag_list( tags ) list = Tag.tag_list(tags)
assert( !list.include?( tags[:item] ), 'Tag entry destroyed') assert(!list.include?(tags[:item]), 'Tag entry destroyed')
} }
end end
end end