Fixes #629 - Draft Sharing.

This commit is contained in:
Mantas Masalskis 2022-02-24 12:33:52 +01:00 committed by Rolf Schmidt
parent 92b7c39879
commit c1d467aa3d
59 changed files with 2717 additions and 51 deletions

View file

@ -132,6 +132,14 @@ class App.TicketCreate extends App.Controller
@$('[name="cc"], [name="group_id"], [name="customer_id"]').on('change', => @$('[name="cc"], [name="group_id"], [name="customer_id"]').on('change', =>
@updateSecurityOptions() @updateSecurityOptions()
) )
@listenTo(App.Group, 'refresh', =>
@sidebarWidget.render(@params())
)
@$('[name="group_id"]').bind('change', =>
@sidebarWidget.render(@params())
)
@updateSecurityOptions() @updateSecurityOptions()
# show cc # show cc
@ -174,6 +182,7 @@ class App.TicketCreate extends App.Controller
@navupdate("#ticket/create/id/#{@id}#{@split}", type: 'menu') @navupdate("#ticket/create/id/#{@id}#{@split}", type: 'menu')
@autosaveStart() @autosaveStart()
@controllerBind('ticket_create_rerender', (template) => @renderQueue(template)) @controllerBind('ticket_create_rerender', (template) => @renderQueue(template))
@controllerBind('ticket_create_import_draft_attachments', @importDraftAttachments)
# initially hide sidebar on mobile # initially hide sidebar on mobile
if window.matchMedia('(max-width: 767px)').matches if window.matchMedia('(max-width: 767px)').matches
@ -183,6 +192,7 @@ class App.TicketCreate extends App.Controller
hide: => hide: =>
@autosaveStop() @autosaveStop()
@controllerUnbind('ticket_create_rerender', (template) => @renderQueue(template)) @controllerUnbind('ticket_create_rerender', (template) => @renderQueue(template))
@controllerUnbind('ticket_create_import_draft_attachments')
changed: => changed: =>
return true if @hasAttachments() return true if @hasAttachments()
@ -283,6 +293,18 @@ class App.TicketCreate extends App.Controller
return if !@formMeta return if !@formMeta
App.QueueManager.run(@queueKey) App.QueueManager.run(@queueKey)
importDraftAttachments: (options = {}) =>
@ajax
id: 'import_attachments'
type: 'POST'
url: "#{@apiPath}/tickets/shared_drafts/#{options.shared_draft_id}/import_attachments"
data: JSON.stringify({ form_id: @formId })
processData: true
success: (data, status, xhr) ->
App.Event.trigger(options.callbackName, { success: true, attachments: data.attachments })
error: ->
App.Event.trigger(options.callbackName, { success: false })
updateTaskManagerAttachments: (attribute, attachments) => updateTaskManagerAttachments: (attribute, attachments) =>
taskData = App.TaskManager.get(@taskKey) taskData = App.TaskManager.get(@taskKey)
return if _.isEmpty(taskData) return if _.isEmpty(taskData)
@ -312,6 +334,7 @@ class App.TicketCreate extends App.Controller
types: @types, types: @types,
availableTypes: @availableTypes availableTypes: @availableTypes
form_id: @formId form_id: @formId
shared_draft_id: template.shared_draft_id || params.shared_draft_id
)) ))
App.Ticket.configure_attributes.push { App.Ticket.configure_attributes.push {

View file

@ -0,0 +1,29 @@
class SidebarSharedDraft extends App.Controller
sidebarItem: =>
return if !@permissionCheck('ticket.agent')
group = App.Group.find @params.group_id
return if !group?.shared_drafts
@item = {
name: 'shared_draft'
badgeIcon: 'note'
sidebarHead: __('Shared Drafts')
sidebarActions: []
sidebarCallback: @showDrafts
}
@item
showDrafts: (el) =>
@el = el
# show template UI
new App.WidgetSharedDraft(
el: el
taskKey: @taskKey
group_id: @params.group_id
active_draft_id: @params.shared_draft_id
)
App.Config.set('400-SharedDraft', SidebarSharedDraft, 'TicketCreateSidebar')

View file

@ -10,6 +10,7 @@ class App.TicketZoom extends App.Controller
'click .js-submit': 'submit' 'click .js-submit': 'submit'
'click .js-bookmark': 'bookmark' 'click .js-bookmark': 'bookmark'
'click .js-reset': 'reset' 'click .js-reset': 'reset'
'click .js-draft': 'draft'
'click .main': 'muteTask' 'click .main': 'muteTask'
constructor: (params) -> constructor: (params) ->
@ -187,6 +188,9 @@ class App.TicketZoom extends App.Controller
# remember mentions # remember mentions
@mentions = data.mentions @mentions = data.mentions
if draft = App.TicketSharedDraftZoom.findByAttribute 'ticket_id', @ticket_id
draft.remove(clear: true)
App.Collection.loadAssets(data.assets, targetModel: 'Ticket') App.Collection.loadAssets(data.assets, targetModel: 'Ticket')
# get ticket # get ticket
@ -484,7 +488,9 @@ class App.TicketZoom extends App.Controller
ticket: @ticket ticket: @ticket
el: elLocal.find('.js-attributeBar') el: elLocal.find('.js-attributeBar')
overview_id: @overview_id overview_id: @overview_id
callback: @submit macroCallback: @submit
draftCallback: @saveDraft
draftState: @draftState()
taskKey: @taskKey taskKey: @taskKey
) )
#if @shown #if @shown
@ -965,6 +971,55 @@ class App.TicketZoom extends App.Controller
@submitPost(e, ticket, macro) @submitPost(e, ticket, macro)
) )
saveDraft: (e) =>
e.stopPropagation()
e.preventDefault()
params =
new_article: @articleNew.params()
ticket_attributes: @ticketParams()
loaded_draft_id = params.new_article.shared_draft_id
params.form_id = params.new_article['form_id']
delete params.new_article['form_id']
delete params.new_article['shared_draft_id']
sharedDraft = @sharedDraft()
draftExists = sharedDraft?
isLoaded = loaded_draft_id == String(sharedDraft?.id)
matches = draftExists &&
_.isEqual(sharedDraft.new_article, params.new_article) &&
_.isEqual(sharedDraft.ticket_attributes, params.ticket_attributes)
if draftExists && !(isLoaded && matches)
new App.TicketSharedDraftOverwriteModal(
onShowDraft: @draft
onSaveDraft: =>
@draftSaveToServer(params)
)
return
@draftSaveToServer(params)
draftSaveToServer: (params) =>
@draftSaving()
@ajax
id: 'ticket_shared_draft_update'
type: 'PUT'
url: @apiPath + '/tickets/' + @ticket_id + '/shared_draft'
processData: true
data: JSON.stringify(params)
success: (data, status, xhr) =>
App.Collection.loadAssets(data.assets)
@draftFetched()
error: =>
@draftFetched()
submitPost: (e, ticket, macro) => submitPost: (e, ticket, macro) =>
taskAction = @$('.js-secondaryActionButtonLabel').data('type') taskAction = @$('.js-secondaryActionButtonLabel').data('type')
@ -1034,6 +1089,49 @@ class App.TicketZoom extends App.Controller
bookmark: (e) -> bookmark: (e) ->
$(e.currentTarget).find('.bookmark.icon').toggleClass('filled') $(e.currentTarget).find('.bookmark.icon').toggleClass('filled')
draft: (e) =>
e.preventDefault()
new App.TicketSharedDraftModal(
container: @el.closest('.content')
hasChanges: App.TaskManager.worker(@taskKey).changed()
parent: @
shared_draft: @sharedDraft()
)
fetchDraft: ->
@ajax(
id: "ticket_#{@ticket_id}_shared_draft"
type: 'GET'
url: "#{@apiPath}/tickets/#{@ticket_id}/shared_draft"
processData: true
success: (data, status, xhr) =>
App.Collection.loadAssets(data.assets)
@draftFetched()
)
draftSaving: ->
@updateDraftButton(true, 'saving')
updateDraftButton: (visible, state) ->
button = @el.find('.js-draft')
button.toggleClass('hide', !visible)
button.find('.attributeBar-draft--available').toggleClass('hide', state != 'available')
button.find('.attributeBar-draft--saving').toggleClass('hide', state != 'saving')
button.attr('disabled', state == 'saving')
@el.find('.js-dropdownActionSaveDraft').attr('disabled', state == 'saving')
draftFetched: ->
@updateDraftButton(@sharedDraft()?, 'available')
draftState: ->
@sharedDraft()?
sharedDraft: ->
App.TicketSharedDraftZoom.findByAttribute 'ticket_id', @ticket_id
reset: (e) => reset: (e) =>
if e if e
e.preventDefault() e.preventDefault()

View file

@ -53,13 +53,16 @@ class App.TicketZoomArticleNew extends App.Controller
@setArticleTypePre(data.type.name, data.signaturePosition) @setArticleTypePre(data.type.name, data.signaturePosition)
@openTextarea(null, true, true) @openTextarea(null, true, !data.nofocus)
for key, value of data.article for key, value of data.article
if key is 'body' if key is 'body'
@$("[data-name=\"#{key}\"]").html(value) @$("[data-name=\"#{key}\"]").html(value)
else else
@$("[name=\"#{key}\"]").val(value).trigger('change') @$("[name=\"#{key}\"]").val(value).trigger('change')
@$('[name=shared_draft_id]').val(data.shared_draft_id)
@setArticleTypePost(data.type.name, data.signaturePosition) @setArticleTypePost(data.type.name, data.signaturePosition)
# set focus into field # set focus into field
@ -76,6 +79,8 @@ class App.TicketZoomArticleNew extends App.Controller
@tokanice(data.type.name) @tokanice(data.type.name)
) )
@controllerBind('ui::ticket::import_draft_attachments', @importDraftAttachments)
# add article attachment # add article attachment
@controllerBind('ui::ticket::addArticleAttachent', (data) => @controllerBind('ui::ticket::addArticleAttachent', (data) =>
return if data.ticket?.id?.toString() isnt @ticket_id.toString() && data.form_id isnt @form_id return if data.ticket?.id?.toString() isnt @ticket_id.toString() && data.form_id isnt @form_id
@ -634,6 +639,26 @@ class App.TicketZoomArticleNew extends App.Controller
@richTextUploadDeleteCallback?(@attachments) @richTextUploadDeleteCallback?(@attachments)
) )
importDraftAttachments: (options) =>
return if @ticket.id != options.ticket_id
@ajax
id: 'import_attachments'
type: 'POST'
url: "#{@apiPath}/tickets/#{@ticket.id}/shared_draft/import_attachments"
data: JSON.stringify({ form_id: @form_id })
processData: true
success: (data, status, xhr) =>
App.Event.trigger('ui::ticket::addArticleAttachent', {
ticket: @ticket
attachments: data.attachments
form_id: @form_id
})
App.Event.trigger(options.callbackName, { success: true })
error: ->
App.Event.trigger(options.callbackName, { success: false })
actions: -> actions: ->
actionConfig = App.Config.get('TicketZoomArticleAction') actionConfig = App.Config.get('TicketZoomArticleAction')
keys = _.keys(actionConfig).sort() keys = _.keys(actionConfig).sort()

View file

@ -9,6 +9,9 @@ class App.TicketZoomAttributeBar extends App.Controller
'mouseup .js-dropdownActionMacro': 'performTicketMacro' 'mouseup .js-dropdownActionMacro': 'performTicketMacro'
'mouseenter .js-dropdownActionMacro': 'onActionMacroMouseEnter' 'mouseenter .js-dropdownActionMacro': 'onActionMacroMouseEnter'
'mouseleave .js-dropdownActionMacro': 'onActionMacroMouseLeave' 'mouseleave .js-dropdownActionMacro': 'onActionMacroMouseLeave'
'mouseup .js-dropdownActionSaveDraft': 'saveDraft'
'mouseenter .js-dropdownActionSaveDraft': 'onActionMacroMouseEnter'
'mouseleave .js-dropdownActionSaveDraft': 'onActionMacroMouseLeave'
'click .js-secondaryAction': 'chooseSecondaryAction' 'click .js-secondaryAction': 'chooseSecondaryAction'
searchCondition: {} searchCondition: {}
@ -31,19 +34,45 @@ class App.TicketZoomAttributeBar extends App.Controller
@render() @render()
) )
@controllerBind('ui::ticket::updateSharedDraft', (data) =>
return if data.taskKey isnt @taskKey
@render(data)
)
@listenTo(App.Group, 'refresh', (refreshed_group) =>
selected_group_id = @el.closest('.content').find('[name=group_id]').val()
selected_group = App.Group.find selected_group_id
return if !selected_group
return if !refreshed_group
return if refreshed_group.id != selected_group.id
return if @sharedDraftsEnabled == selected_group.shared_drafts
@render({ newGroupId: selected_group.id })
)
getAction: -> getAction: ->
return App.Session.get().preferences.secondaryAction || App.Config.get('ticket_secondary_action') || 'stayOnTab' return App.Session.get().preferences.secondaryAction || App.Config.get('ticket_secondary_action') || 'stayOnTab'
release: => release: =>
App.Macro.unsubscribe(@subscribeId) App.Macro.unsubscribe(@subscribeId)
render: => render: (options = {}) =>
# remember current reset state # remember current reset state
resetButtonShown = false resetButtonShown = false
if @resetButton.get(0) && !@resetButton.hasClass('hide') if @resetButton.get(0) && !@resetButton.hasClass('hide')
resetButtonShown = true resetButtonShown = true
group = App.Group.find options?.newGroupId || @ticket.group_id
draft = App.TicketSharedDraftZoom.findByAttribute 'ticket_id', @ticket.id
accessibleGroups = App.User.current().allGroupIds('change')
sharedDraftButtonShown = group?.shared_drafts && _.contains(accessibleGroups, String(group.id))
sharedDraftsEnabled = group?.shared_drafts && _.contains(accessibleGroups, String(group.id))
sharedButtonVisible = sharedDraftsEnabled && draft?
@sharedDraftsEnabled = sharedDraftsEnabled
macros = App.Macro.getList() macros = App.Macro.getList()
@macroLastUpdated = App.Macro.lastUpdatedAt() @macroLastUpdated = App.Macro.lastUpdatedAt()
@ -61,9 +90,13 @@ class App.TicketZoomAttributeBar extends App.Controller
localeEl = $(App.view('ticket_zoom/attribute_bar')( localeEl = $(App.view('ticket_zoom/attribute_bar')(
macros: @possibleMacros macros: @possibleMacros
macroDisabled: macroDisabled macroDisabled: macroDisabled
sharedButtonVisible: sharedButtonVisible
sharedDraftsDisabled: !sharedDraftsEnabled
overview_id: @overview_id overview_id: @overview_id
resetButtonShown: resetButtonShown resetButtonShown: resetButtonShown
sharedDraftButtonShown: sharedDraftButtonShown
)) ))
@setSecondaryAction(@secondaryAction, localeEl) @setSecondaryAction(@secondaryAction, localeEl)
if @ticket.currentView() is 'agent' if @ticket.currentView() is 'agent'
@ -74,6 +107,26 @@ class App.TicketZoomAttributeBar extends App.Controller
@html localeEl @html localeEl
@el.find('.js-draft').popover(
trigger: 'hover'
container: 'body'
html: true
animation: false
delay: 100
placement: 'auto'
sanitize: false
content: =>
draft = App.TicketSharedDraftZoom.findByAttribute 'ticket_id', @ticket?.id
timestamp = App.ViewHelpers.humanTime(draft?.updated_at)
user = App.User.find draft?.updated_by_id
name = user?.displayName()
content = App.i18n.translatePlain('Last change %s<br>by %s', timestamp, name)
# needs linebreak to align vertically without title
'<br>' + content
)
start: => start: =>
return if !@taskbarWatcher return if !@taskbarWatcher
@taskbarWatcher.start() @taskbarWatcher.start()
@ -106,9 +159,12 @@ class App.TicketZoomAttributeBar extends App.Controller
macroId = $(e.currentTarget).data('id') macroId = $(e.currentTarget).data('id')
macro = App.Macro.find(macroId) macro = App.Macro.find(macroId)
@callback(e, macro) @macroCallback(e, macro)
@closeMacroMenu() @closeMacroMenu()
saveDraft: (e) =>
@draftCallback(e)
onActionMacroMouseEnter: (e) => onActionMacroMouseEnter: (e) =>
@$(e.currentTarget).addClass('is-active') @$(e.currentTarget).addClass('is-active')

View file

@ -0,0 +1,9 @@
class TicketZoomFormHandlerSharedDraft
# central method, is getting called on every ticket form change
# but only trigger event for group_id changes
@run: (params, attribute, attributes, classname, form, ui) ->
return if attribute.name isnt 'group_id'
App.Event.trigger('ui::ticket::updateSharedDraft', { taskKey: ui.taskKey, newGroupId: params.group_id })
App.Config.set('150-ticketFormSharedDraft', TicketZoomFormHandlerSharedDraft, 'TicketZoomFormHandler')

View file

@ -9,12 +9,13 @@ class Edit extends App.Controller
return if data.ticket_id.toString() isnt @ticket.id.toString() return if data.ticket_id.toString() isnt @ticket.id.toString()
@ticket = App.Ticket.find(@ticket.id) @ticket = App.Ticket.find(@ticket.id)
if data.form_meta
@formMeta = data.form_meta @formMeta = data.form_meta
@render() @render(data.draft)
) )
@render() @render()
render: => render: (draft = {}) =>
defaults = @ticket.attributes() defaults = @ticket.attributes()
delete defaults.article # ignore article infos delete defaults.article # ignore article infos
followUpPossible = App.Group.find(defaults.group_id).follow_up_possible followUpPossible = App.Group.find(defaults.group_id).follow_up_possible
@ -45,7 +46,7 @@ class Edit extends App.Controller
handlersConfig: handlers handlersConfig: handlers
filter: @formMeta.filter filter: @formMeta.filter
formMeta: @formMeta formMeta: @formMeta
params: defaults params: _.extend(defaults, draft)
isDisabled: editable isDisabled: editable
taskKey: @taskKey taskKey: @taskKey
core_workflow: { core_workflow: {

View file

@ -4,7 +4,7 @@ class App.TicketZoomTimeAccounting extends App.ControllerModal
buttonSubmit: __('Account Time') buttonSubmit: __('Account Time')
buttonClass: 'btn--success' buttonClass: 'btn--success'
leftButtons: [{ leftButtons: [{
className: 'btn--text btn--subtle js-skip', className: 'js-skip',
text: __('skip') text: __('skip')
}] }]
head: __('Time Accounting') head: __('Time Accounting')

View file

@ -0,0 +1,108 @@
class App.WidgetSharedDraft extends App.Controller
constructor: ->
super
@load()
@subscribeId = App.TicketSharedDraftStart.subscribe(@render)
@render()
events:
'click .shared-draft-item': 'clicked'
'click .js-create': 'create'
'click .js-update': 'update'
'input #shared_draft_name': 'sharedDraftNameChanged'
elements:
'#shared_draft_name': 'sharedDraftNameInput'
render: =>
active_draft = App.TicketSharedDraftStart.find(@active_draft_id)
@html App.view('widget/shared_draft')(
shared_drafts: @visibleDrafts()
active_draft: active_draft
)
load: =>
@ajax
id: 'shared_drafts_index'
type: 'GET'
url: @apiPath + '/tickets/shared_drafts'
processData: true
success: (data, status, xhr) =>
App.TicketSharedDraftStart.deleteAll()
App.Collection.loadAssets(data.assets)
@render()
visibleDrafts: ->
App.TicketSharedDraftStart.findAllByAttribute 'group_id', parseInt(@group_id)
clicked: (e) ->
shared_draft_id = e.currentTarget.getAttribute('shared-draft-id')
draft = App.TicketSharedDraftStart.find shared_draft_id
hasChanges = App.TaskManager.worker(@taskKey).changed()
new App.TicketSharedDraftModal(
container: @el.closest('.content')
shared_draft: draft
hasChanges: hasChanges
parent: @
)
getParams: ->
form = @formParam(@el.closest('.content').find('.ticket-create'))
meta = @formParam(@el)
form_id = form.form_id
delete form.form_id
return false if meta.name.trim() == ''
JSON.stringify({
name: meta.name
group_id: form.group_id
form_id: form_id
content: form
})
success: (data, status, xhr) =>
App.Collection.loadAssets(data.assets)
@render()
highlightError: ->
@sharedDraftNameInput
.addClass('has-error')
.focus()
false
sharedDraftNameChanged: (e) ->
@sharedDraftNameInput.removeClass('has-error')
create: (e) ->
@onAction(e,
id: 'shared_drafts_create'
type: 'POST'
url: @apiPath + '/tickets/shared_drafts'
)
update: (e) ->
@onAction(e,
id: 'shared_drafts_update'
type: 'PATCH'
url: @apiPath + '/tickets/shared_drafts/' + @active_draft_id
)
onAction: (e, options) ->
e.preventDefault()
params = @getParams()
return @highlightError() if !params
@ajax _.extend(options, { data: params, success: @success })
release: =>
if @subscribeId
App.TicketSharedDraftStart.unsubscribe(@subscribeId)
super

View file

@ -102,13 +102,22 @@ class App.Sidebar extends App.Controller
toggleTabAction: (name) => toggleTabAction: (name) =>
return if !name return if !name
# remove active state
@tabs.removeClass('active')
if name == 'shared_draft'
draft_enabled = _.find @items, (elem) -> elem?.item?.name == 'shared_draft' and elem.sidebarItem()?
if !draft_enabled?
name = 'template'
available_sidebar = _.first @items, (elem) -> elem.sidebarItem()?
available_sidebar?.shown = true
# remember sidebarState for outsite # remember sidebarState for outsite
if @sidebarState if @sidebarState
@sidebarState.active = name @sidebarState.active = name
# remove active state
@tabs.removeClass('active')
# add active state # add active state
@$('.tabsSidebar-tab[data-tab=' + name + ']').addClass('active') @$('.tabsSidebar-tab[data-tab=' + name + ']').addClass('active')

View file

@ -1,5 +1,5 @@
class App.Group extends App.Model class App.Group extends App.Model
@configure 'Group', 'name', 'assignment_timeout', 'follow_up_possible', 'follow_up_assignment', 'email_address_id', 'signature_id', 'note', 'active', 'updated_at' @configure 'Group', 'name', 'assignment_timeout', 'follow_up_possible', 'follow_up_assignment', 'email_address_id', 'signature_id', 'note', 'active', 'shared_drafts', 'updated_at'
@extend Spine.Model.Ajax @extend Spine.Model.Ajax
@url: @apiPath + '/groups' @url: @apiPath + '/groups'
@ -13,6 +13,7 @@ class App.Group extends App.Model
{ name: 'note', display: __('Note'), tag: 'textarea', note: __('Notes are visible to agents only, never to customers.'), limit: 250, null: true }, { name: 'note', display: __('Note'), tag: 'textarea', note: __('Notes are visible to agents only, never to customers.'), limit: 250, null: true },
{ name: 'updated_at', display: __('Updated'), tag: 'datetime', readonly: 1 }, { name: 'updated_at', display: __('Updated'), tag: 'datetime', readonly: 1 },
{ name: 'active', display: __('Active'), tag: 'active', default: true }, { name: 'active', display: __('Active'), tag: 'active', default: true },
{ name: 'shared_drafts', display: __('Shared Drafts'), tag: 'active' },
] ]
@configure_clone = true @configure_clone = true
@configure_overview = [ @configure_overview = [

View file

@ -374,3 +374,11 @@ class App.Ticket extends App.Model
return false if !user.permission('ticket.agent') return false if !user.permission('ticket.agent')
return true if @isAccessibleByOwner(user) return true if @isAccessibleByOwner(user)
return @isAccessibleByGroup(user, permission) return @isAccessibleByGroup(user, permission)
attributes: ->
attrs = super
if @shared_draft_id
attrs.shared_draft_id = @shared_draft_id
attrs

View file

@ -59,3 +59,11 @@ class App.TicketArticle extends App.Model
if attachment && (!attachment.preferences || attachment.preferences && attachment.preferences['original-format'] isnt true) if attachment && (!attachment.preferences || attachment.preferences && attachment.preferences['original-format'] isnt true)
attachments.push attachment attachments.push attachment
attachments attachments
attributes: ->
attrs = super
if @shared_draft_id
attrs.shared_draft_id = @shared_draft_id
attrs

View file

@ -0,0 +1,6 @@
class App.TicketSharedDraftStart extends App.Model
@configure 'TicketSharedDraftStart', 'name', 'group_id'
@extend Spine.Model.Ajax
@url: @apiPath + '/tickets/shared_drafts'
@needsLoading: true

View file

@ -0,0 +1,4 @@
class App.TicketSharedDraftZoom extends App.Model
@configure 'TicketSharedDraftZoom', 'ticket_id', 'new_article', 'ticket_attributes'
@needsLoading: false

View file

@ -26,6 +26,7 @@
<form role="form" class="ticket-create"> <form role="form" class="ticket-create">
<input type="hidden" name="formSenderType"/> <input type="hidden" name="formSenderType"/>
<input type="hidden" name="shared_draft_id" value="<%= @shared_draft_id %>"/>
<input type="hidden" name="form_id" value="<%= @form_id %>"/> <input type="hidden" name="form_id" value="<%= @form_id %>"/>
<div class="ticket-form-top"></div> <div class="ticket-form-top"></div>
<div class="form-group js-securityOptions hide"> <div class="form-group js-securityOptions hide">

View file

@ -21,16 +21,18 @@
<div class="loading icon"></div> <div class="loading icon"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<% if @buttonCancel || @leftButtons: %>
<div class="modal-leftFooter align-left">
<% if @buttonCancel: %> <% if @buttonCancel: %>
<div class="modal-leftFooter"> <a class="btn <%= @buttonCancelClass %> js-cancel" href="#"><%- @T(@buttonCancel) %></a>
<a class="btn <%= @buttonCancelClass %> js-cancel align-left" href="#"><%- @T(@buttonCancel) %></a>
</div>
<% else if @leftButtons: %>
<% for button in @leftButtons: %>
<div class="modal-leftFooter">
<div class="btn <%= button.className %> align-left" href="#"><%- @T(button.text) %></div>
</div>
<% end %> <% end %>
<% if @leftButtons: %>
<% for button in @leftButtons: %>
<a class="btn btn--text btn--subtle <%= button.className %>" href="#"><%- @T(button.text) %></a>
<% end %>
<% end %>
</div>
<% end %> <% end %>
<% for button in @centerButtons: %> <% for button in @centerButtons: %>
<div class="modal-centerFooter"> <div class="modal-centerFooter">

View file

@ -0,0 +1,190 @@
class App.TicketSharedDraftModal extends App.ControllerModal
head: __('Apply Shared Draft')
events:
'click .js-delete': 'onDelete'
buttonClose: true
buttonCancel: true
buttonSubmit: 'Apply'
leftButtons: [
{
text: 'Delete',
className: 'js-delete'
}
]
constructor: ->
@contentView = new Content(shared_draft: arguments[0].shared_draft)
super
if @shared_draft.constructor.needsLoading
@load()
@controllerBind(@importCallbackName(), @attachmentsImported)
content: ->
@contentView.el
importCallbackName: ->
"import_attachments_done-#{@controllerId}"
load: ->
@startLoading()
@ajax
id: "shared_draft_#{@shared_draft.id}"
type: 'GET'
url: @apiPath + '/tickets/shared_drafts/' + @shared_draft.id
processData: true
success: (data, status, xhr) =>
App.Collection.loadAssets(data.assets)
@shared_draft_content = data.shared_draft_content
@contentView.loadContent(@shared_draft_content)
@stopLoading()
error: =>
@stopLoading()
onSubmit: (e) ->
if !@hasChanges
@disable(true)
@applyAttachments()
return
new App.TicketSharedDraftOverwriteModal(
head: __('Apply Draft')
message: __('There is existing content. Do you want to overwrite it?')
onSaveDraft: =>
@disable(true)
@applyAttachments()
)
attachmentsImported: (options) =>
if !options.success
@disable(false)
return
@applyMeta(options)
@cancel()
disable: (toggle) ->
@el.find('.js-submit').attr('disabled', toggle)
applyAttachments: ->
switch @shared_draft.constructor
when App.TicketSharedDraftZoom
App.Event.trigger('ui::ticket::import_draft_attachments', {
shared_draft_id: @shared_draft.id,
ticket_id: @parent.ticket_id
callbackName: @importCallbackName()
})
when App.TicketSharedDraftStart
App.Event.trigger('ticket_create_import_draft_attachments', {
shared_draft_id: @shared_draft.id,
callbackName: @importCallbackName()
})
applyMeta: (options) ->
switch @shared_draft.constructor
when App.TicketSharedDraftZoom
container = @parent.$('.article-add')
newArticleAttrs = @shared_draft.new_article
App.Event.trigger('ui::ticket::setArticleType', {
ticket: { id: @parent.ticket_id }
type: { name: newArticleAttrs.type }
article: newArticleAttrs
nofocus: true
shared_draft_id: @shared_draft.id
})
App.Event.trigger('ui::ticket::load', {
ticket_id: @parent.ticket_id
draft: @shared_draft.ticket_attributes
})
when App.TicketSharedDraftStart
content = _.clone @shared_draft_content
content.group_id = @shared_draft.group_id
content.attachments = options.attachments
App.Event.trigger 'ticket_create_rerender', { options: content, shared_draft_id: @shared_draft.id }
onDelete: (e) ->
e.preventDefault()
parent = @
new App.ControllerModal
container: @container
buttonClose: true
buttonCancel: true
buttonSubmit: __('Yes')
buttonClass: 'btn--danger'
head: __('Are you sure?')
small: true
content: ->
App.i18n.translateContent('Are you sure to delete this draft?')
onSubmit: ->
@startLoading()
switch parent.shared_draft.constructor
when App.TicketSharedDraftZoom
@ajax
id: 'ticket_shared_draft_delete'
type: 'DELETE'
url: @apiPath + '/tickets/' + parent.parent.ticket_id + '/shared_draft'
success: (data, status, xhr) =>
@stopLoading()
@cancel()
parent.cancel()
parent.shared_draft.remove(clear: true)
parent.parent.draftFetched()
when App.TicketSharedDraftStart
@ajax
id: 'ticket_shared_draft_delete'
type: 'DELETE'
url: @apiPath + '/tickets/shared_drafts/' + parent.shared_draft.id
success: (data, status, xhr) =>
@stopLoading()
@cancel()
parent.cancel()
parent.shared_draft.remove(clear: true)
parent.parent.render()
class Content extends App.Controller
constructor: ->
super
@render()
body: ->
switch @shared_draft.constructor
when App.TicketSharedDraftZoom
@shared_draft.new_article.body
when App.TicketSharedDraftStart
@shared_draft_content?.body
author: ->
App.User.find @shared_draft.updated_by_id
timestamp: ->
new Date(@shared_draft.updated_at)
loadContent: (content) =>
@shared_draft_content = content
@render()
render: ->
@html App.view('ticket_shared_draft_modal')(
body: @body()
name: @author().displayName()
timestamp: @timestamp()
)
new App.WidgetAvatar(
el: @$('.js-avatar')
object_id: @shared_draft.updated_by_id
size: 40
)

View file

@ -0,0 +1,23 @@
<div style="display:flex">
<div style="margin-right: 5em">
<label>Author</label>
<div style="display: flex; align-items: center;">
<span class="js-avatar" style="margin-right: 0.5em"></span>
<%= @name %>
</div>
</div>
<div>
<label>Last changed</label>
<div>
<%- @humanTime(@timestamp) %>
</div>
</div>
</div>
<hr>
<label>Text</label>
<div class='richtext-content'>
<%- @body %>
</div>

View file

@ -0,0 +1,27 @@
class App.TicketSharedDraftOverwriteModal extends App.ControllerModal
head: __('Save Draft')
message: __('There is an existing draft. Do you want to overwrite it?')
buttonCancel: true
buttonSubmit: __('Overwrite Draft')
buttonClass: 'btn--danger'
onShowDraft: null
onSaveDraft: null
showDraft: (e) =>
e.preventDefault()
@cancel()
@onShowDraft(e)
onSubmit: ->
@onSaveDraft()
@close()
post: =>
return if !@onShowDraft
button = $("<div class='btn'>#{App.Utils.icon('note')} #{__('Show Draft')}</div>")
button.click(@showDraft)
@el.find('.modal-rightFooter').prepend(button)

View file

@ -2,6 +2,7 @@
<input type="hidden" name="type" value="<%= @article.type %>"> <input type="hidden" name="type" value="<%= @article.type %>">
<input type="hidden" name="internal" value="<%= @article.internal %>"> <input type="hidden" name="internal" value="<%= @article.internal %>">
<input type="hidden" name="form_id" value="<%= @form_id %>"> <input type="hidden" name="form_id" value="<%= @form_id %>">
<input type="hidden" name="shared_draft_id" value="<%= @article.shared_draft_id %>">
<input type="hidden" name="subtype" value="<%= @article.subtype %>"> <input type="hidden" name="subtype" value="<%= @article.subtype %>">
<input type="hidden" name="in_reply_to" value="<%= @article.in_reply_to %>"> <input type="hidden" name="in_reply_to" value="<%= @article.in_reply_to %>">
<div class="editControls"> <div class="editControls">

View file

@ -1,4 +1,27 @@
<div class="attributeBar-avatars flex horizontal js-avatars hidden-xs"></div> <div class="attributeBar-avatars horizontal js-avatars hidden-xs"></div>
<div class="attributeBar-draft-spacer content hidden-xs"></div>
<div class="attributeBar-draft <% if !@sharedButtonVisible: %>hide<% end %> buttonDropdown btn js-draft align-left">
<span class="attributeBar-draft--available">
<span class="hidden-xs">
<%- @Icon('note') %>
<%- @T('Draft available') %>
</span>
<span class="visible-xs">
<%- @Icon('note') %>
</span>
</span>
<span class="attributeBar-draft--saving hide">
<%- @Icon('reload') %>
<%- @T('Sharing draft...') %>
</span>
</div>
<div class="flex"></div>
<div class="attributeBar-reset buttonDropdown btn js-reset <% if !@resetButtonShown: %>hide<% end %>"><span><%- @T('Discard your unsaved changes.') %></span></div> <div class="attributeBar-reset buttonDropdown btn js-reset <% if !@resetButtonShown: %>hide<% end %>"><span><%- @T('Discard your unsaved changes.') %></span></div>
<div class="buttonDropdown dropdown dropdown--actions dropup"> <div class="buttonDropdown dropdown dropdown--actions dropup">
<div class="btn btn--text btn--icon--last" data-toggle="dropdown"> <div class="btn btn--text btn--icon--last" data-toggle="dropdown">
@ -30,16 +53,30 @@
</ul> </ul>
</div> </div>
<form class="buttonDropdown"> <form class="buttonDropdown">
<% if @macroDisabled: %> <% if @macroDisabled && @sharedDraftsDisabled: %>
<button class="btn btn--primary js-submit"><span><%- @T('Update') %></span></button> <button class="btn btn--primary js-submit"><span><%- @T('Update') %></span></button>
<% else: %> <% else: %>
<div class="buttonDropdown dropdown dropup js-submitDropdown"> <div class="buttonDropdown dropdown dropup js-submitDropdown">
<button class="btn btn--primary btn--split--first js-submit"><span><%- @T('Update') %></span></button> <button class="btn btn--primary btn--split--first js-submit"><span><%- @T('Update') %></span></button>
<button class="btn btn--primary btn--slim btn--only-icon btn--split--last js-openDropdownMacro"><%- @Icon('arrow-up') %></button> <button class="btn btn--primary btn--slim btn--only-icon btn--split--last js-openDropdownMacro"><%- @Icon('arrow-up') %></button>
<ul class="dropdown-menu dropdown-menu-right" role="menu" aria-labelledby="userAction"> <ul class="dropdown-menu dropdown-menu-right" role="menu" aria-labelledby="userAction">
<% if !@sharedDraftsDisabled: %>
<li class="label" role="menuitem">
<label>
Draft
<li class="js-dropdownActionSaveDraft" role="menuitem">Save Draft
<% end %>
<% if !@macroDisabled: %>
<li class="label" role="menuitem">
<label>
Macros
<% for macro in @macros: %> <% for macro in @macros: %>
<li class="js-dropdownActionMacro" role="menuitem" data-id="<%= macro.id %>"><%- macro.displayName() %> <li class="js-dropdownActionMacro" role="menuitem" data-id="<%= macro.id %>"><%- macro.displayName() %>
<% end %> <% end %>
<% end %>
</ul> </ul>
</div> </div>
<% end %> <% end %>

View file

@ -0,0 +1,38 @@
<div class="shared-drafts-manage">
<form>
<div class="form-group">
<label class="" for="template_name"><%- @T('Create a shared draft') %></label>
<input type="text" name="name" id="shared_draft_name" class="form-control js-name"
placeholder="Name" value="<%= @active_draft?.name %>">
</div>
<div class="horizontal" style="justify-content: flex-end">
<button type="submit" class="btn btn--action js-update btn--primary <%= if !@active_draft? then 'hide' %>"><%- @T('Update') %></button>
<button type="submit" class="btn btn--action js-create btn--create"><%- @T('Create') %></button>
</div>
</form>
<hr>
<div class="form-group">
<label class="" for="template_name"><%- @T('Select Shared Draft') %></label>
</div>
<% if _.isEmpty(@shared_drafts): %>
no drafts
<% end %>
<% for draft in @shared_drafts: %>
<div class="shared-draft-item" shared-draft-id="<%= draft.id %>">
<div class="u-highlight u-clickable"><%= draft.name %></div>
<div class="label-subtle" style="display: flex;flex-wrap:wrap;column-gap:0.2em">
<div>
<%= App.User.find(draft.updated_by_id).displayName() %>
</div>
<div>
<%- @humanTime draft.updated_at %>
</div>
</div>
</div>
<% end %>
</div>

View file

@ -8157,6 +8157,16 @@ a.list-group-item.active > .badge,
margin-right: 10px; margin-right: 10px;
} }
&-draft-spacer {
width: 2px;
height: 60px;
margin-right: 15px;
}
&-avatars:empty + &-draft-spacer {
display: none;
}
&--border { &--border {
border-top: 1px solid hsl(0, 0%, 94%); border-top: 1px solid hsl(0, 0%, 94%);
} }
@ -8779,6 +8789,10 @@ a.list-group-item.active > .badge,
@include phone { @include phone {
padding: 15px; padding: 15px;
} }
.btn--text + .btn--text {
margin-bottom: -10px;
}
} }
.modal-leftFooter, .modal-leftFooter,
@ -8931,6 +8945,14 @@ a.list-group-item.active > .badge,
box-shadow: 0 1px rgba(255, 255, 255, 0.13) inset; box-shadow: 0 1px rgba(255, 255, 255, 0.13) inset;
} }
.dropdown li.label {
box-shadow: none;
+ li {
box-shadow: none;
}
}
.dropdown li:hover, .dropdown li:hover,
.dropdown li.is-active { .dropdown li.is-active {
background: hsl(205, 90%, 60%); background: hsl(205, 90%, 60%);

View file

@ -0,0 +1,59 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class TicketSharedDraftZoomController < ApplicationController
prepend_before_action :authorize!
prepend_before_action :authentication_check
def show
object = ticket.shared_draft
render json: {
shared_draft_id: object&.id,
assets: object&.assets,
}
end
def update
if ticket.shared_draft.present?
object = ticket.shared_draft
object.update! draft_params
else
object = ticket.create_shared_draft! draft_params
end
object.attach_upload_cache params[:form_id]
render json: {
shared_draft_id: object.id,
assets: object.assets,
}
end
def destroy
object = ticket.shared_draft
object.destroy!
render json: {
shared_draft_id: object.id
}
end
def import_attachments
new_attachments = ticket.shared_draft.clone_attachments 'UploadCache', params[:form_id]
render json: {
attachments: new_attachments
}
end
private
def ticket
Ticket.find params[:ticket_id]
end
def draft_params
params.permit ticket_attributes: {}, new_article: {}
end
end

View file

@ -81,6 +81,16 @@ class TicketsController < ApplicationController
params.delete(:customer) params.delete(:customer)
end end
if (shared_draft_id = params[:shared_draft_id])
shared_draft = Ticket::SharedDraftStart.find_by id: shared_draft_id
if shared_draft && (shared_draft.group_id.to_s != params[:group_id]&.to_s || !shared_draft.group.shared_drafts?)
raise Exceptions::UnprocessableEntity, __('Shared draft not eligible for this ticket')
end
shared_draft&.destroy
end
clean_params = Ticket.association_name_to_id_convert(params) clean_params = Ticket.association_name_to_id_convert(params)
# overwrite params # overwrite params
@ -92,7 +102,7 @@ class TicketsController < ApplicationController
end end
# The parameter :customer_id is 'abused' in cases where it is not an integer, but a string like # The parameter :customer_id is 'abused' in cases where it is not an integer, but a string like
# 'guess:customers.email@domain.com' which implies that the customer should be looked up. # 'guess:customers.email@domain.cm' which implies that the customer should be looked up.
if clean_params[:customer_id].is_a?(String) && clean_params[:customer_id] =~ %r{^guess:(.+?)$} if clean_params[:customer_id].is_a?(String) && clean_params[:customer_id] =~ %r{^guess:(.+?)$}
email_address = $1 email_address = $1
email_address_validation = EmailAddressValidation.new(email_address) email_address_validation = EmailAddressValidation.new(email_address)
@ -245,6 +255,16 @@ class TicketsController < ApplicationController
ticket.with_lock do ticket.with_lock do
ticket.update!(clean_params) ticket.update!(clean_params)
if params[:article].present? if params[:article].present?
if (shared_draft_id = params[:article][:shared_draft_id])
shared_draft = Ticket::SharedDraftZoom.find_by id: shared_draft_id
if shared_draft && shared_draft.ticket != ticket
raise Exceptions::UnprocessableEntity, __('Shared draft not eligible for this ticket')
end
shared_draft&.destroy
end
article_create(ticket, params[:article]) article_create(ticket, params[:article])
end end
end end
@ -694,6 +714,10 @@ class TicketsController < ApplicationController
assets = mention.assets(assets) assets = mention.assets(assets)
end end
if (draft = ticket.shared_draft) && authorized?(draft, :show?)
assets = draft.assets(assets)
end
# return result # return result
{ {
ticket_id: ticket.id, ticket_id: ticket.id,

View file

@ -0,0 +1,90 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class TicketsSharedDraftStartsController < ApplicationController
prepend_before_action :authorize!
prepend_before_action :authentication_check
def index
drafts = scope
render json: {
shared_draft_ids: drafts.map(&:id),
assets: ApplicationModel::CanAssets.reduce(drafts),
}
end
def show
object = scope.find params[:id]
render json: {
shared_draft_id: object.id,
shared_draft_content: object.content,
assets: object.assets,
}
end
def create
object = scope.create! safe_params
object.attach_upload_cache params[:form_id]
render json: {
shared_draft_id: object.id,
assets: object.assets,
}
end
def update
object = scope.find params[:id]
object.update! safe_params
object.attach_upload_cache params[:form_id]
render json: {
shared_draft_id: object.id,
assets: object.assets,
}
end
def destroy
object = scope.find params[:id]
object.destroy!
render json: {
shared_draft_id: object.id
}
end
def import_attachments
object = scope.find params[:id]
new_attachments = object.clone_attachments 'UploadCache', params[:form_id]
render json: {
attachments: new_attachments
}
end
private
def scope
Ticket::SharedDraftStartPolicy::Scope
.new(current_user, Ticket::SharedDraftStart)
.resolve
end
def safe_params
safe_params = params.permit :name, :group_id, content: {}
safe_params[:content].delete :group_id
allowed_groups = current_user.groups.access('create').map(&:id).map(&:to_s)
group_id = safe_params[:group_id]&.to_s
if allowed_groups.exclude? group_id
raise Exceptions::UnprocessableEntity, __("User does not have access to one of given group IDs: #{group_id}")
end
safe_params
end
end

View file

@ -76,4 +76,20 @@ returns
new_attachments new_attachments
end end
def attach_upload_cache(form_id, source_object_name: 'UploadCache')
attachments_remove_all
Store
.list(object: source_object_name, o_id: form_id)
.map do |old_attachment|
Store.add(
object: self.class.name,
o_id: id,
data: old_attachment.content,
filename: old_attachment.filename,
preferences: old_attachment.preferences,
)
end
end
end end

View file

@ -81,6 +81,7 @@ class Ticket < ApplicationModel
has_many :ticket_time_accounting, class_name: 'Ticket::TimeAccounting', dependent: :destroy, inverse_of: :ticket has_many :ticket_time_accounting, class_name: 'Ticket::TimeAccounting', dependent: :destroy, inverse_of: :ticket
has_many :flags, class_name: 'Ticket::Flag', dependent: :destroy has_many :flags, class_name: 'Ticket::Flag', dependent: :destroy
has_many :mentions, as: :mentionable, dependent: :destroy has_many :mentions, as: :mentionable, dependent: :destroy
has_one :shared_draft, class_name: 'Ticket::SharedDraftZoom', inverse_of: :ticket, dependent: :destroy
belongs_to :state, class_name: 'Ticket::State', optional: true belongs_to :state, class_name: 'Ticket::State', optional: true
belongs_to :priority, class_name: 'Ticket::Priority', optional: true belongs_to :priority, class_name: 'Ticket::Priority', optional: true
belongs_to :owner, class_name: 'User', optional: true belongs_to :owner, class_name: 'User', optional: true

View file

@ -0,0 +1,24 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class Ticket::SharedDraftStart < ApplicationModel
include CanCloneAttachments
include ChecksClientNotification
belongs_to :group
validates :name, presence: true
store :content
# don't include content into assets which may be huge
# assets are used to load the whole list of available drafts
# content is loaded separately
def filter_attributes(attributes)
super.except! 'content'
end
# required by CanCloneAttachments
def content_type
'text/html'
end
end

View file

@ -0,0 +1,16 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class Ticket::SharedDraftZoom < ApplicationModel
include CanCloneAttachments
include ChecksClientNotification
belongs_to :ticket, touch: true
store :new_article
store :ticket_attributes
# required by CanCloneAttachments
def content_type
'text/html'
end
end

View file

@ -0,0 +1,32 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class Controllers::TicketSharedDraftZoomControllerPolicy < Controllers::ApplicationControllerPolicy
def show?
access?(__method__)
end
def create?
access?(__method__)
end
def update?
access?(__method__)
end
def destroy?
access?(__method__)
end
def import_attachments?
access?(__method__)
end
private
def access?(_method)
ticket_id = record.params[:ticket_id]
ticket = Ticket.find ticket_id
TicketPolicy.new(user, ticket).update?
end
end

View file

@ -0,0 +1,33 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class Controllers::TicketsSharedDraftStartsControllerPolicy < Controllers::ApplicationControllerPolicy
def index?
access?(__method__)
end
def show?
access?(__method__)
end
def create?
access?(__method__)
end
def update?
access?(__method__)
end
def destroy?
access?(__method__)
end
def import_attachments?
access?(__method__)
end
private
def access?(_method)
user.permissions?('ticket.agent') && user.groups.access(:create).any?
end
end

View file

@ -0,0 +1,27 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class Ticket::SharedDraftStartPolicy < ApplicationPolicy
def create?
access?(__method__)
end
def update?
access?(__method__)
end
def show?
access?(__method__)
end
def destroy?
access?(__method__)
end
private
def access?(_method)
return if !user.permissions?('ticket.agent')
user.groups.access(:create).include? record.group
end
end

View file

@ -0,0 +1,12 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class Ticket::SharedDraftStartPolicy < ApplicationPolicy
class Scope < ApplicationPolicy::Scope
def resolve
scope.none if !user.permissions?(['ticket.agent'])
scope.where group_id: user.groups.access('change').map(&:id)
end
end
end

View file

@ -0,0 +1,21 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class Ticket::SharedDraftZoomPolicy < ApplicationPolicy
def update?
access?(__method__)
end
def show?
access?(__method__)
end
def destroy?
access?(__method__)
end
private
def access?(_method)
TicketPolicy.new(user, record.ticket).update?
end
end

View file

@ -0,0 +1 @@
<div class='richtext-content'><% @body()}</div>

View file

@ -3,6 +3,20 @@
Zammad::Application.routes.draw do Zammad::Application.routes.draw do
api_path = Rails.configuration.api_path api_path = Rails.configuration.api_path
# ticket shared drafts
resource api_path + '/tickets/:ticket_id/shared_draft', controller: 'ticket_shared_draft_zoom', except: %w[new edit create] do
collection do
post :import_attachments, as: nil
end
end
resources api_path + '/tickets/shared_drafts', controller: 'tickets_shared_draft_starts', except: %w[new edit] do
member do
post :import_attachments, as: nil
end
end
# tickets # tickets
match api_path + '/tickets/search', to: 'tickets#search', via: %i[get post] match api_path + '/tickets/search', to: 'tickets#search', via: %i[get post]
match api_path + '/tickets/selector', to: 'tickets#selector', via: :post match api_path + '/tickets/selector', to: 'tickets#selector', via: :post

View file

@ -104,6 +104,7 @@ class CreateBase < ActiveRecord::Migration[4.2]
t.string :follow_up_possible, limit: 100, null: false, default: 'yes' t.string :follow_up_possible, limit: 100, null: false, default: 'yes'
t.boolean :follow_up_assignment, null: false, default: true t.boolean :follow_up_assignment, null: false, default: true
t.boolean :active, null: false, default: true t.boolean :active, null: false, default: true
t.boolean :shared_drafts, null: false, default: true
t.string :note, limit: 250, null: true t.string :note, limit: 250, null: true
t.integer :updated_by_id, null: false t.integer :updated_by_id, null: false
t.integer :created_by_id, null: false t.integer :created_by_id, null: false

View file

@ -589,6 +589,27 @@ class CreateTicket < ActiveRecord::Migration[4.2]
t.timestamps limit: 3, null: false t.timestamps limit: 3, null: false
end end
create_table :ticket_shared_draft_zooms do |t|
t.references :ticket, null: false, foreign_key: { to_table: :tickets }
t.text :new_article
t.text :ticket_attributes
t.column :created_by_id, :integer, null: true
t.column :updated_by_id, :integer, null: true
t.timestamps limit: 3
end
create_table :ticket_shared_draft_starts do |t|
t.references :group, null: false, foreign_key: { to_table: :groups }
t.string :name
t.text :content
t.column :created_by_id, :integer, null: true
t.column :updated_by_id, :integer, null: true
t.timestamps limit: 3
end
end end
def self.down def self.down
@ -626,5 +647,7 @@ class CreateTicket < ActiveRecord::Migration[4.2]
drop_table :ticket_states drop_table :ticket_states
drop_table :ticket_state_types drop_table :ticket_state_types
drop_table :webhooks drop_table :webhooks
drop_table :ticket_shared_draft_zooms
drop_table :ticket_shared_draft_starts
end end
end end

View file

@ -0,0 +1,73 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
class CreateTicketSharedDrafts < ActiveRecord::Migration[5.0]
def change # rubocop:disable Metrics/AbcSize
# return if it's a new setup
return if !Setting.exists?(name: 'system_init_done')
create_table :ticket_shared_draft_zooms do |t|
t.references :ticket, null: false, foreign_key: { to_table: :tickets }
t.text :new_article
t.text :ticket_attributes
t.column :created_by_id, :integer, null: true
t.column :updated_by_id, :integer, null: true
t.timestamps limit: 3
end
create_table :ticket_shared_draft_starts do |t|
t.references :group, null: false, foreign_key: { to_table: :groups }
t.string :name
t.text :content
t.column :created_by_id, :integer, null: true
t.column :updated_by_id, :integer, null: true
t.timestamps limit: 3
end
change_table :groups do |t|
t.boolean :shared_drafts, null: false, default: true
end
Group.reset_column_information
UserInfo.current_user_id = 1
ObjectManager::Attribute.add(
force: true,
object: 'Group',
name: 'shared_drafts',
display: 'Shared Drafts',
data_type: 'active',
data_option: {
null: false,
default: true,
permission: ['admin.group'],
},
editable: true,
active: true,
screens: {
create: {
'-all-' => {
null: false,
},
},
edit: {
'-all-': {
null: false,
},
},
view: {
'-all-' => {
shown: false,
},
},
},
to_create: false,
to_migrate: false,
to_delete: false,
position: 1400,
)
end
end

View file

@ -1810,6 +1810,42 @@ ObjectManager::Attribute.add(
position: 600, position: 600,
) )
ObjectManager::Attribute.add(
force: true,
object: 'Group',
name: 'shared_drafts',
display: __('Shared Drafts'),
data_type: 'active',
data_option: {
null: false,
default: true,
permission: ['admin.group'],
},
editable: true,
active: true,
screens: {
create: {
'-all-' => {
null: true,
},
},
edit: {
'-all-': {
null: false,
},
},
view: {
'-all-' => {
shown: false,
},
},
},
to_create: false,
to_migrate: false,
to_delete: false,
position: 1400,
)
ObjectManager::Attribute.add( ObjectManager::Attribute.add(
force: true, force: true,
object: 'Group', object: 'Group',

View file

@ -807,6 +807,14 @@ msgstr ""
msgid "Apply" msgid "Apply"
msgstr "" msgstr ""
#: app/assets/javascripts/app/views/ticket_shared_draft_modal.coffee
msgid "Apply Draft"
msgstr ""
#: app/assets/javascripts/app/views/ticket_shared_draft_modal.coffee
msgid "Apply Shared Draft"
msgstr ""
#: app/assets/javascripts/app/controllers/_ui_element/basedate.coffee #: app/assets/javascripts/app/controllers/_ui_element/basedate.coffee
#: app/assets/javascripts/app/controllers/report.coffee #: app/assets/javascripts/app/controllers/report.coffee
#: app/assets/javascripts/app/controllers/time_accounting.coffee #: app/assets/javascripts/app/controllers/time_accounting.coffee
@ -829,6 +837,10 @@ msgstr ""
msgid "Archived at" msgid "Archived at"
msgstr "" msgstr ""
#: app/assets/javascripts/app/views/ticket_shared_draft_modal.coffee
msgid "Are you sure to delete this draft?"
msgstr ""
#: app/assets/javascripts/app/controllers/knowledge_base/content_controller.coffee #: app/assets/javascripts/app/controllers/knowledge_base/content_controller.coffee
msgid "Are you sure you want to reload? You have unsaved changes that will get lost" msgid "Are you sure you want to reload? You have unsaved changes that will get lost"
msgstr "" msgstr ""
@ -844,6 +856,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/maintenance.coffee #: app/assets/javascripts/app/controllers/maintenance.coffee
#: app/assets/javascripts/app/controllers/ticket_zoom/article_action/delete.coffee #: app/assets/javascripts/app/controllers/ticket_zoom/article_action/delete.coffee
#: app/assets/javascripts/app/controllers/widget/template.coffee #: app/assets/javascripts/app/controllers/widget/template.coffee
#: app/assets/javascripts/app/views/ticket_shared_draft_modal.coffee
msgid "Are you sure?" msgid "Are you sure?"
msgstr "" msgstr ""
@ -2115,6 +2128,7 @@ msgstr ""
#: app/assets/javascripts/app/views/object_manager/index.jst.eco #: app/assets/javascripts/app/views/object_manager/index.jst.eco
#: app/assets/javascripts/app/views/profile/token_access.jst.eco #: app/assets/javascripts/app/views/profile/token_access.jst.eco
#: app/assets/javascripts/app/views/translation/todo.jst.eco #: app/assets/javascripts/app/views/translation/todo.jst.eco
#: app/assets/javascripts/app/views/widget/shared_draft.jst.eco
msgid "Create" msgid "Create"
msgstr "" msgstr ""
@ -2150,6 +2164,10 @@ msgstr ""
msgid "Create a Test Ticket" msgid "Create a Test Ticket"
msgstr "" msgstr ""
#: app/assets/javascripts/app/views/widget/shared_draft.jst.eco
msgid "Create a shared draft"
msgstr ""
#: app/assets/javascripts/app/controllers/knowledge_base/reader_controller.coffee #: app/assets/javascripts/app/controllers/knowledge_base/reader_controller.coffee
msgid "Create a translation" msgid "Create a translation"
msgstr "" msgstr ""
@ -3330,6 +3348,10 @@ msgstr ""
msgid "Draft" msgid "Draft"
msgstr "" msgstr ""
#: app/assets/javascripts/app/views/ticket_zoom/attribute_bar.jst.eco
msgid "Draft available"
msgstr ""
#: app/assets/javascripts/app/controllers/_application_controller/reorder_modal.coffee #: app/assets/javascripts/app/controllers/_application_controller/reorder_modal.coffee
msgid "Drag to reorder" msgid "Drag to reorder"
msgstr "" msgstr ""
@ -5389,6 +5411,10 @@ msgstr ""
msgid "Last Contact Customer At" msgid "Last Contact Customer At"
msgstr "" msgstr ""
#: app/assets/javascripts/app/controllers/ticket_zoom/attribute_bar.coffee
msgid "Last change %s<br>by %s"
msgstr ""
#: app/assets/javascripts/app/models/ticket.coffee #: app/assets/javascripts/app/models/ticket.coffee
msgid "Last contact" msgid "Last contact"
msgstr "" msgstr ""
@ -6902,6 +6928,10 @@ msgstr ""
msgid "Overviews are …" msgid "Overviews are …"
msgstr "" msgstr ""
#: app/assets/javascripts/app/views/ticket_shared_draft_overwrite_modal.coffee
msgid "Overwrite Draft"
msgstr ""
#: app/assets/javascripts/app/controllers/_ui_element/postmaster_set.coffee #: app/assets/javascripts/app/controllers/_ui_element/postmaster_set.coffee
#: app/assets/javascripts/app/controllers/_ui_element/ticket_perform_action.coffee #: app/assets/javascripts/app/controllers/_ui_element/ticket_perform_action.coffee
#: app/assets/javascripts/app/models/ticket.coffee #: app/assets/javascripts/app/models/ticket.coffee
@ -7778,6 +7808,10 @@ msgstr ""
msgid "Save" msgid "Save"
msgstr "" msgstr ""
#: app/assets/javascripts/app/views/ticket_shared_draft_overwrite_modal.coffee
msgid "Save Draft"
msgstr ""
#: app/assets/javascripts/app/views/widget/text_module.jst.eco #: app/assets/javascripts/app/views/widget/text_module.jst.eco
msgid "Save as" msgid "Save as"
msgstr "" msgstr ""
@ -7908,6 +7942,10 @@ msgstr ""
msgid "Select CSV file" msgid "Select CSV file"
msgstr "" msgstr ""
#: app/assets/javascripts/app/views/widget/shared_draft.jst.eco
msgid "Select Shared Draft"
msgstr ""
#: app/assets/javascripts/app/views/widget/template.jst.eco #: app/assets/javascripts/app/views/widget/template.jst.eco
msgid "Select Template" msgid "Select Template"
msgstr "" msgstr ""
@ -8155,11 +8193,25 @@ msgstr ""
msgid "Shared" msgid "Shared"
msgstr "" msgstr ""
#: app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_shared_draft.coffee
#: app/assets/javascripts/app/models/group.coffee
#: db/seeds/object_manager_attributes.rb
msgid "Shared Drafts"
msgstr ""
#: app/controllers/tickets_controller.rb
msgid "Shared draft not eligible for this ticket"
msgstr ""
#: app/assets/javascripts/app/models/organization.coffee #: app/assets/javascripts/app/models/organization.coffee
#: db/seeds/object_manager_attributes.rb #: db/seeds/object_manager_attributes.rb
msgid "Shared organization" msgid "Shared organization"
msgstr "" msgstr ""
#: app/assets/javascripts/app/views/ticket_zoom/attribute_bar.jst.eco
msgid "Sharing draft..."
msgstr ""
#: app/assets/javascripts/app/controllers/_profile/avatar.coffee #: app/assets/javascripts/app/controllers/_profile/avatar.coffee
msgid "Shoot" msgid "Shoot"
msgstr "" msgstr ""
@ -8173,6 +8225,10 @@ msgstr ""
msgid "Show" msgid "Show"
msgstr "" msgstr ""
#: app/assets/javascripts/app/views/ticket_shared_draft_overwrite_modal.coffee
msgid "Show Draft"
msgstr ""
#: app/assets/javascripts/app/views/navigation.jst.eco #: app/assets/javascripts/app/views/navigation.jst.eco
msgid "Show Search Details" msgid "Show Search Details"
msgstr "" msgstr ""
@ -9001,6 +9057,14 @@ msgstr ""
msgid "There are too many people in the chat queue." msgid "There are too many people in the chat queue."
msgstr "" msgstr ""
#: app/assets/javascripts/app/views/ticket_shared_draft_overwrite_modal.coffee
msgid "There is an existing draft. Do you want to overwrite it?"
msgstr ""
#: app/assets/javascripts/app/views/ticket_shared_draft_modal.coffee
msgid "There is existing content. Do you want to overwrite it?"
msgstr ""
#: app/assets/javascripts/app/controllers/_manage/knowledge_base.coffee #: app/assets/javascripts/app/controllers/_manage/knowledge_base.coffee
msgid "There is no Knowledge Base yet. Please create one." msgid "There is no Knowledge Base yet. Please create one."
msgstr "" msgstr ""
@ -9617,6 +9681,7 @@ msgstr ""
#: app/assets/javascripts/app/views/knowledge_base/content.jst.eco #: app/assets/javascripts/app/views/knowledge_base/content.jst.eco
#: app/assets/javascripts/app/views/session.jst.eco #: app/assets/javascripts/app/views/session.jst.eco
#: app/assets/javascripts/app/views/ticket_zoom/attribute_bar.jst.eco #: app/assets/javascripts/app/views/ticket_zoom/attribute_bar.jst.eco
#: app/assets/javascripts/app/views/widget/shared_draft.jst.eco
msgid "Update" msgid "Update"
msgstr "" msgstr ""
@ -10337,6 +10402,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/_application_controller/_modal_generic_confirm.coffee #: app/assets/javascripts/app/controllers/_application_controller/_modal_generic_confirm.coffee
#: app/assets/javascripts/app/controllers/_ui_element/ticket_perform_action.coffee #: app/assets/javascripts/app/controllers/_ui_element/ticket_perform_action.coffee
#: app/assets/javascripts/app/controllers/tag.coffee #: app/assets/javascripts/app/controllers/tag.coffee
#: app/assets/javascripts/app/views/ticket_shared_draft_modal.coffee
msgid "Yes" msgid "Yes"
msgstr "" msgstr ""

View file

@ -0,0 +1,9 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
FactoryBot.define do
factory :ticket_shared_draft_start, class: 'Ticket::SharedDraftStart' do
name { Faker::Name.unique.name }
group { create(:group) }
content { { content: true } }
end
end

View file

@ -0,0 +1,9 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
FactoryBot.define do
factory :ticket_shared_draft_zoom, class: 'Ticket::SharedDraftZoom' do
ticket { create(:ticket) }
new_article { { new_article: true } }
ticket_attributes { { ticket_attributes: true } }
end
end

View file

@ -0,0 +1,11 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Ticket::SharedDraftStart, type: :model do
subject(:shared_draft_start) { create :ticket_shared_draft_start }
it { is_expected.to belong_to :group }
it { is_expected.to validate_presence_of :name }
it { expect(shared_draft_start.content).to be_a(Hash) }
end

View file

@ -0,0 +1,11 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Ticket::SharedDraftZoom, type: :model do
subject(:shared_draft_zoom) { create :ticket_shared_draft_zoom }
it { is_expected.to belong_to :ticket }
it { expect(shared_draft_zoom.new_article).to be_a(Hash) }
it { expect(shared_draft_zoom.ticket_attributes).to be_a(Hash) }
end

View file

@ -1780,6 +1780,7 @@ RSpec.describe Ticket, type: :model do
it 'destroys all related dependencies' do it 'destroys all related dependencies' do
refs_known = { 'Ticket::Article' => { 'ticket_id'=>1 }, refs_known = { 'Ticket::Article' => { 'ticket_id'=>1 },
'Ticket::TimeAccounting' => { 'ticket_id'=>1 }, 'Ticket::TimeAccounting' => { 'ticket_id'=>1 },
'Ticket::SharedDraftZoom' => { 'ticket_id'=>0 },
'Ticket::Flag' => { 'ticket_id'=>1 } } 'Ticket::Flag' => { 'ticket_id'=>1 } }
ticket = create(:ticket) ticket = create(:ticket)

View file

@ -961,6 +961,8 @@ RSpec.describe User, type: :model do
'Ticket::Article::Type' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, 'Ticket::Article::Type' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Ticket::Article::Flag' => { 'created_by_id' => 0 }, 'Ticket::Article::Flag' => { 'created_by_id' => 0 },
'Ticket::Priority' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, 'Ticket::Priority' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Ticket::SharedDraftStart' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Ticket::SharedDraftZoom' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Ticket::TimeAccounting' => { 'created_by_id' => 0 }, 'Ticket::TimeAccounting' => { 'created_by_id' => 0 },
'Ticket::State' => { 'created_by_id' => 0, 'updated_by_id' => 0 }, 'Ticket::State' => { 'created_by_id' => 0, 'updated_by_id' => 0 },
'Ticket::Flag' => { 'created_by_id' => 0 }, 'Ticket::Flag' => { 'created_by_id' => 0 },

View file

@ -0,0 +1,65 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
describe Controllers::TicketSharedDraftZoomControllerPolicy do
subject { described_class.new(user, record) }
let(:record_class) { TicketSharedDraftZoomController }
let(:ticket) { create(:ticket) }
let(:user) { create(:agent) }
let(:record) do
rec = record_class.new
rec.action_name = action_name
rec.params = params
rec
end
shared_examples 'basic checks' do
let(:params) { { ticket_id: ticket.id } }
context 'when has access to ticket' do
before do
user.user_groups.create! group: ticket.group, access: :full
end
it { is_expected.to permit_action(action_name) }
end
context 'when has no access to ticket' do
it { is_expected.not_to permit_action(action_name) }
end
end
describe '#show?' do
let(:action_name) { :show }
include_examples 'basic checks'
end
describe '#create?' do
let(:action_name) { :create }
include_examples 'basic checks'
end
describe '#update?' do
let(:action_name) { :update }
include_examples 'basic checks'
end
describe '#destroy?' do
let(:action_name) { :destroy }
include_examples 'basic checks'
end
describe '#import_attachments?' do
let(:action_name) { :import_attachments }
include_examples 'basic checks'
end
end

View file

@ -0,0 +1,70 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
describe Controllers::TicketsSharedDraftStartsControllerPolicy do
subject { described_class.new(user, record) }
let(:record_class) { TicketsSharedDraftStartsController }
let(:record) do
rec = record_class.new
rec.action_name = action_name
rec
end
shared_examples 'basic checks' do
context 'when has access to tickets' do
let(:user) do
user = create(:agent)
user.user_groups.create! group: create(:group), access: :full
user
end
it { is_expected.to permit_action(action_name) }
end
context 'when has no access to tickets' do
let(:user) { create(:customer) }
it { is_expected.not_to permit_action(action_name) }
end
end
describe '#index?' do
let(:action_name) { :index }
include_examples 'basic checks'
end
describe '#show?' do
let(:action_name) { :show }
include_examples 'basic checks'
end
describe '#create?' do
let(:action_name) { :create }
include_examples 'basic checks'
end
describe '#update?' do
let(:action_name) { :update }
include_examples 'basic checks'
end
describe '#destroy?' do
let(:action_name) { :destroy }
include_examples 'basic checks'
end
describe '#import_attachments?' do
let(:action_name) { :import_attachments }
include_examples 'basic checks'
end
end

View file

@ -0,0 +1,46 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe Ticket::SharedDraftStartPolicy::Scope do
subject(:scope) { described_class.new(user, original_collection) }
let(:original_collection) { Ticket::SharedDraftStart }
let(:group_a) { create(:group) }
let(:draft_a) { create(:ticket_shared_draft_start, group: group_a) }
let(:group_b) { create(:group) }
let(:draft_b) { create(:ticket_shared_draft_start, group: group_b) }
before do
draft_a && draft_b
end
describe '#resolve' do
context 'without user' do
let(:user) { nil }
it 'throws exception' do
expect { scope.resolve }.to raise_error %r{Authentication required}
end
end
context 'with customer' do
let(:user) { create(:customer) }
it 'returns empty' do
expect(scope.resolve).to be_empty
end
end
context 'with agent' do
let(:user) { create(:agent) }
before { user.groups << group_a }
it 'returns group a' do
expect(scope.resolve).to match_array [draft_a]
end
end
end
end

View file

@ -0,0 +1,58 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
describe Ticket::SharedDraftStartPolicy do
subject { described_class.new(user, record) }
let(:group) { record.group }
let(:record) { create(:ticket_shared_draft_start) }
let(:user) { create(:agent) }
shared_examples 'access allowed' do
it { is_expected.to be_create }
it { is_expected.to be_update }
it { is_expected.to be_show }
it { is_expected.to be_destroy }
end
shared_examples 'access denied' do
it { is_expected.not_to be_create }
it { is_expected.not_to be_update }
it { is_expected.not_to be_show }
it { is_expected.not_to be_destroy }
end
context 'when user has no tickets access' do
let(:user) { create(:customer) }
include_examples 'access denied'
end
context 'when user has ticket access' do
context 'when draft has same group as user' do
before do
user.user_groups.create! group: group, access: :full
end
include_examples 'access allowed'
end
context 'when draft has same group as user but read-only' do
before do
user.user_groups.create! group: group, access: :read
end
include_examples 'access denied'
end
context 'when draft has one of the groups of the user' do
before do
user.user_groups.create! group: group, access: :full
user.user_groups.create! group: create(:group), access: :full
end
include_examples 'access allowed'
end
end
end

View file

@ -0,0 +1,55 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
describe Ticket::SharedDraftZoomPolicy do
subject { described_class.new(user, record) }
let(:ticket) { create(:ticket) }
let(:record) { create(:ticket_shared_draft_zoom, ticket: ticket) }
let(:user) { create(:agent) }
shared_examples 'access allowed' do
it { is_expected.to be_update }
it { is_expected.to be_show }
it { is_expected.to be_destroy }
end
shared_examples 'access denied' do
it { is_expected.not_to be_update }
it { is_expected.not_to be_show }
it { is_expected.not_to be_destroy }
end
context 'when user has no tickets access' do
let(:user) { create(:customer) }
include_examples 'access denied'
end
context 'when user has ticket access' do
context 'when user has access to the ticket' do
before do
user.user_groups.create! group: ticket.group, access: :full
end
include_examples 'access allowed'
end
context 'when user has read-only access to the ticket' do
before do
user.user_groups.create! group: ticket.group, access: :read
end
include_examples 'access denied'
end
context 'when user has no access to the ticket' do
before do
user.user_groups.create! group: create(:group), access: :full
end
include_examples 'access denied'
end
end
end

View file

@ -0,0 +1,303 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe 'Ticket Shared Drafts Start API endpoints', authenticated_as: :agent, type: :request do
let(:group_a) { create(:group, shared_drafts: true) }
let(:group_b) { create(:group, shared_drafts: true) }
let(:group_c) { create(:group, shared_drafts: false) }
let(:draft_a) { create(:ticket_shared_draft_start, group: group_a) }
let(:draft_b) { create(:ticket_shared_draft_start, group: group_b) }
let(:draft_c) { create(:ticket_shared_draft_start, group: group_c) }
let(:agent) do
user = create(:agent)
user.user_groups.create! group: group_a, access: :full
user.user_groups.create! group: group_c, access: :full
user
end
let(:other_agent) { create(:agent) }
let(:customer) { create(:customer) }
let(:form_id) { 12_345 }
let(:base_params) do
{
name: 'draft name',
group_id: group_a.id,
form_id: form_id,
content: { attrs: true }
}
end
let(:path) { '/api/v1/tickets/shared_drafts' }
let(:path_draft_a) { "#{path}/#{draft_a.id}" }
let(:path_draft_b) { "#{path}/#{draft_b.id}" }
let(:path_draft_nonexistant) { "#{path}/asd" }
describe 'request handling' do
describe '#index' do
it 'returns drafts that user has access to' do
draft_a && draft_b
get path, as: :json
expect(json_response).to include('shared_draft_ids' => [draft_a.id])
end
it 'returns empty array when no drafts available' do
get path, as: :json
expect(json_response).to include('shared_draft_ids' => [])
end
it 'raises error when user has no permissions', authenticated_as: :customer do
get path, as: :json
expect(response).to have_http_status(:forbidden)
end
end
describe '#show' do
it 'returns draft' do
get path_draft_a, as: :json
expect(json_response).to include('shared_draft_id' => draft_a.id)
end
it 'returns 404 when draft does not exist' do
get path_draft_nonexistant, as: :json
expect(response).to have_http_status(:not_found)
end
it 'returns 404 when user has no permissions to drafts', authenticated_as: :other_agent do
get path_draft_b, as: :json
expect(response).to have_http_status(:forbidden)
end
it 'returns 404 when user has no permissions to this draft' do
get path_draft_b, as: :json
expect(response).to have_http_status(:not_found)
end
it 'returns error when user has no permissions', authenticated_as: :customer do
get path_draft_b, as: :json
expect(response).to have_http_status(:forbidden)
end
end
describe '#create' do
it 'creates draft' do
post path, params: base_params, as: :json
expect(Ticket::SharedDraftStart).to be_exist json_response['shared_draft_id']
end
it 'creates draft with attachment' do
attach(id: form_id)
post path, params: base_params, as: :json
new_draft = Ticket::SharedDraftStart.find json_response['shared_draft_id']
expect(new_draft.attachments).to be_one
end
it 'verifies user has access to given group' do
post path, params: base_params.merge(group_id: group_b.id), as: :json
expect(json_response).to include 'error_human' => %r{does not have access}
end
it 'raises error when user has no create permission on any group', authenticated_as: :other_agent do
post path, params: base_params, as: :json
expect(response).to have_http_status(:forbidden)
end
it 'raises error when user has no permissions', authenticated_as: :customer do
post path, params: base_params, as: :json
expect(response).to have_http_status(:forbidden)
end
end
describe '#update' do
it 'updates draft' do
patch path_draft_a, params: base_params, as: :json
expect(draft_a.reload).to have_attributes(content: { attrs: true })
end
it 'updates draft with attachment' do
attach(id: form_id)
expect { patch path_draft_a, params: base_params, as: :json }
.to change { draft_a.attachments.count }
.by(1)
end
it 'updates draft to have no attachments' do
attach(id: draft_a.id, object_name: draft_a.class.name)
expect { patch path_draft_a, params: base_params, as: :json }
.to change { draft_a.attachments.count }
.by(-1)
end
it 'returns 404 when draft does not exist' do
patch path_draft_nonexistant, params: base_params, as: :json
expect(response).to have_http_status(:not_found)
end
it 'changes draft group' do
agent.user_groups.create! group: group_b, access: :full
patch path_draft_b, params: base_params.merge(group_id: group_b.id), as: :json
expect(draft_b.reload.group).to eq group_b
end
it 'returns updated draft ID' do
patch path_draft_a, params: base_params, as: :json
expect(json_response).to include 'shared_draft_id' => draft_a.id
end
it 'verifies user has access to given groups' do
patch path_draft_a, params: base_params.merge(group_id: group_b.id), as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns error when user has no permissions', authenticated_as: :customer do
patch path_draft_a, params: base_params, as: :json
expect(response).to have_http_status(:forbidden)
end
end
describe '#destroy' do
it 'destroys draft' do
delete path_draft_a, as: :json
expect(Ticket::SharedDraftStart).not_to be_exist draft_a.id
end
it 'returns 404 when draft does not exist' do
delete path_draft_nonexistant, as: :json
expect(response).to have_http_status(:not_found)
end
it 'returns 404 when user has no permissions to this draft' do
delete path_draft_b, as: :json
expect(response).to have_http_status(:not_found)
end
it 'returns error when user has no permissions', authenticated_as: :customer do
delete path_draft_b, as: :json
expect(response).to have_http_status(:forbidden)
end
end
describe '#import_attachments' do
let(:import_path) { "#{path_draft_a}/import_attachments" }
let(:import_params) do
{
form_id: form_id
}
end
it 'imports attachments from draft to given form ID' do
attach(id: draft_a.id, object_name: draft_a.class.name)
expect { post import_path, params: import_params, as: :json }
.to change { Store.list(object: 'UploadCache', o_id: form_id).count }
.by(1)
end
it 'returns success if draft has no attachments' do
post import_path, params: import_params, as: :json
expect(response).to have_http_status(:ok)
end
end
end
describe 'clean up' do
it 'removes draft when creating a ticket' do
post_new_ticket group_a.id, draft_a.id
expect(Ticket::SharedDraftStart).not_to be_exist(draft_a.id)
end
it 'not removes draft when fails creating a ticket' do
post_new_ticket group_a.id, draft_a.id, valid: false
expect(Ticket::SharedDraftStart).to be_exist(draft_a.id)
end
it 'raises error if draft is not applicable in this context' do
post_new_ticket group_b.id, draft_a.id
expect(response).to have_http_status(:unprocessable_entity)
end
it 'keeps draft if not applicable in this context' do
post_new_ticket group_b.id, draft_a.id
expect(Ticket::SharedDraftStart).to be_exist(draft_a.id)
end
it 'raises error if group does not support drafts' do
post_new_ticket group_c.id, draft_c.id
expect(response).to have_http_status(:unprocessable_entity)
end
it 'succeeds when draft does not exist' do
post_new_ticket group_a.id, 1_234
expect(response).to have_http_status(:created)
end
def post_new_ticket(group_id, shared_draft_id, valid: true)
params = {
title: 'a new ticket #1',
group_id: group_id,
customer_id: create(:customer).id,
shared_draft_id: shared_draft_id,
article: {
content_type: 'text/plain',
body: valid ? 'some body' : nil,
sender: 'Customer',
type: 'note',
},
}
post '/api/v1/tickets', params: params, as: :json
end
end
def attach(id:, object_name: 'UploadCache')
Store.add(
object: object_name,
o_id: id,
data: File.binread(Rails.root.join('test/data/image/1x1.png')),
filename: '1x1.png',
preferences: {},
created_by_id: 1,
)
end
end

View file

@ -0,0 +1,236 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe 'Ticket Shared Drafts Zoom API endpoints', authenticated_as: :agent, type: :request do
let(:group) { create(:group, shared_drafts: true) }
let(:ticket) { create(:ticket, group: group) }
let(:agent) do
user = create(:agent)
user.user_groups.create! group: ticket.group, access: :full
user
end
let(:other_agent) { create(:agent) }
let(:path) { "/api/v1/tickets/#{ticket.id}/shared_draft" }
let(:non_existant_path) { "/api/v1/tickets/a#{ticket.id}/shared_draft" }
describe 'request handling' do
describe '#show' do
it 'returns draft' do
draft = create(:ticket_shared_draft_zoom, ticket: ticket)
get path, as: :json
expect(json_response).to include('shared_draft_id' => draft.id)
end
it 'returns empty when draft does not exist' do
get path, as: :json
expect(json_response).to include('shared_draft_id' => nil)
end
it 'raises error when ticket does not exist' do
get non_existant_path, as: :json
expect(response).to have_http_status(:not_found)
end
it 'raises error when user has no permissions', authenticated_as: :other_agent do
get path, as: :json
expect(response).to have_http_status(:forbidden)
end
end
describe '#update' do
let(:form_id) { 12_345 }
let(:params) do
{
form_id: form_id,
ticket_attributes: { attrs: true }
}
end
it 'creates draft if does not exist' do
put path, params: params, as: :json
shared_draft = ticket.reload_shared_draft
expect(shared_draft).to have_attributes(ticket_attributes: { attrs: true })
end
it 'creates draft with attachment if does not exist' do
attach(id: form_id)
put path, params: params, as: :json
shared_draft = ticket.reload_shared_draft
expect(shared_draft.attachments).to be_one
end
it 'updates draft' do
shared_draft = create(:ticket_shared_draft_zoom, ticket: ticket)
put path, params: params, as: :json
expect(shared_draft.reload).to have_attributes(ticket_attributes: { attrs: true })
end
it 'updates draft with attachment' do
shared_draft = create(:ticket_shared_draft_zoom, ticket: ticket)
attach(id: form_id)
put path, params: params, as: :json
expect(shared_draft.attachments).to be_one
end
it 'updates draft to have no attachments' do
shared_draft = create(:ticket_shared_draft_zoom, ticket: ticket)
attach(id: shared_draft.id, object_name: shared_draft.class.name)
attach(id: form_id)
put path, params: params, as: :json
expect(shared_draft.attachments).to be_one
end
it 'raises error when user has no permissions', authenticated_as: :other_agent do
put path, params: params, as: :json
expect(response).to have_http_status(:forbidden)
end
end
describe 'destroy' do
it 'destroys draft' do
draft = create(:ticket_shared_draft_zoom, ticket: ticket)
delete path, as: :json
expect(Ticket::SharedDraftZoom).not_to be_exists(draft.id)
end
it 'raises error if draft does not exist' do
delete non_existant_path, as: :json
expect(response).to have_http_status(:not_found)
end
it 'raises error when user has no permissions', authenticated_as: :other_agent do
delete path, as: :json
expect(response).to have_http_status(:forbidden)
end
end
describe 'import_attachments' do
let(:import_path) { "#{path}/import_attachments" }
let(:form_id) { 456 }
let(:params) do
{
form_id: form_id,
}
end
it 'imports attachments from draft to given form ID' do
draft = create(:ticket_shared_draft_zoom, ticket: ticket)
attach(id: draft.id, object_name: draft.class.name)
expect { post import_path, params: params, as: :json }
.to change { Store.list(object: 'UploadCache', o_id: form_id).count }
.by(1)
end
it 'returns success if draft has no attachments' do
create(:ticket_shared_draft_zoom, ticket: ticket)
post import_path, params: params, as: :json
expect(response).to have_http_status(:ok)
end
end
end
describe 'clean up' do
let(:group) { create(:group, shared_drafts: true) }
let(:draft_a) { create(:ticket_shared_draft_zoom, ticket: create(:ticket, group: group)) }
let(:draft_b) { create(:ticket_shared_draft_zoom, ticket: create(:ticket, group: group)) }
before do
agent.user_groups.create! group: group, access: :full
end
it 'removes draft when updating a ticket' do
put_ticket_update(draft_a.ticket.id, draft_a.id)
expect(Ticket::SharedDraftZoom).not_to be_exist(draft_a.id)
end
it 'not removes draft when fails updating a ticket' do
put_ticket_update(draft_a.ticket.id, draft_a.id, valid: false)
expect(Ticket::SharedDraftZoom).to be_exist(draft_a.id)
end
it 'raises error if draft is not applicable in this context' do
put_ticket_update(draft_a.ticket.id, draft_b.id)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'keeps draft if not applicable in this context' do
put_ticket_update(draft_a.ticket.id, draft_b.id)
expect(Ticket::SharedDraftZoom).to be_exist(draft_b.id)
end
it 'succeeds even if group is not eligible anymore' do
group.update(shared_drafts: false)
put_ticket_update(draft_a.ticket.id, 1234)
expect(response).to have_http_status(:ok)
end
it 'removes given draft even if group is not eligible anymore' do
group.update(shared_drafts: false)
put_ticket_update(draft_a.ticket.id, draft_a.id)
expect(Ticket::SharedDraftZoom).not_to be_exist(draft_a.id)
end
it 'succeeds when draft does not exist' do
put_ticket_update(draft_a.ticket.id, 1234)
expect(response).to have_http_status(:ok)
end
def put_ticket_update(ticket_id, shared_draft_id, valid: true)
params = {
article: {
body: valid ? 'some body' : nil,
shared_draft_id: shared_draft_id
}
}
put "/api/v1/tickets/#{ticket_id}", params: params, as: :json
end
end
def attach(id:, object_name: 'UploadCache')
Store.add(
object: object_name,
o_id: id,
data: File.binread(Rails.root.join('test/data/image/1x1.png')),
filename: '1x1.png',
preferences: {},
created_by_id: 1,
)
end
end

View file

@ -53,3 +53,19 @@ end
Capybara.add_selector(:task_with) do Capybara.add_selector(:task_with) do
css { |task_key| ".tasks .task[data-key='#{task_key}']" } css { |task_key| ".tasks .task[data-key='#{task_key}']" }
end end
Capybara.add_selector(:draft_sidebar_button) do
css { '.tabsSidebar-tab[data-tab=shared_draft]' }
end
Capybara.add_selector(:draft_sidebar) do
css { '.shared-drafts-manage' }
end
Capybara.add_selector(:draft_share_button) do
css { '.attributeBar-draft' }
end
Capybara.add_selector(:draft_save_button) do
css { '.js-dropdownActionSaveDraft' }
end

View file

@ -0,0 +1,239 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe 'Ticket Shared Draft Start', type: :system, authenticated_as: :authenticate do
let(:group) { create(:group, shared_drafts: group_shared_drafts) }
let(:group_access) { :full }
let(:group_shared_drafts) { true }
let(:draft) { create(:ticket_shared_draft_start, group: group, content: draft_content) }
let(:draft_body) { 'draft body' }
let(:draft_options) { { priority_id: '3' } }
let(:draft_content) do
{
body: draft_body
}.merge draft_options
end
let(:user) do
user = create(:agent)
user.user_groups.create! group: group, access: group_access
user
end
def authenticate
draft
user
end
before do
click '.settings.add'
end
context 'sidebar' do
context 'given multiple groups' do
let(:another_group) { create(:group, shared_drafts: false) }
def authenticate
user.user_groups.create! group: another_group, access: :full
user
end
it 'not visible without group selected' do
expect(page).to have_no_selector :draft_sidebar_button
end
it 'not visible when group with disabled draft selected' do
within(:active_content) do
select another_group.name, from: 'group_id'
end
expect(page).to have_no_selector :draft_sidebar_button
end
it 'visible when group with active draft selected' do
within(:active_content) do
select group.name, from: 'group_id'
end
expect(page).to have_selector :draft_sidebar_button
end
end
context 'when single group' do
it 'visible' do
expect(page).to have_selector :draft_sidebar_button
end
context 'when drafts disabled' do
let(:group_shared_drafts) { false }
it 'not visible' do
expect(page).to have_no_selector :draft_sidebar_button
end
end
end
end
context 'create' do
before { click :draft_sidebar_button }
it 'prevents a draft creation without name' do
within :draft_sidebar do
expect { click '.js-create' }
.to change { has_css? '.has-error', wait: false }
.to true
end
end
it 'create a draft with name' do
within :draft_sidebar do
find('.js-name').fill_in with: 'Draft Name'
expect { click '.js-create' }
.to change { Ticket::SharedDraftStart.count }
.by 1
end
end
end
context 'update' do
before do
attach(id: draft.id, object_name: draft.class.name)
click :draft_sidebar_button
within :draft_sidebar do
click '.label-subtle'
end
in_modal do
click '.js-submit'
end
end
it 'changes content' do
within :active_content do
find(:richtext).send_keys('add update')
click '.js-update'
end
expect(draft.reload.content['body']).to match %r{add update}
end
it 'changes name' do
within :active_content do
find('.js-name').fill_in with: 'new name'
click '.js-update'
end
expect(draft.reload.name).to eq 'new name'
end
it 'requires name' do
within :draft_sidebar do
find('.js-name').fill_in with: ''
expect { click '.js-update' }
.to change { has_css? '.has-error', wait: false }
.to true
end
end
it 'saves as copy' do
within :draft_sidebar do
expect { click '.js-create' }
.to change { Ticket::SharedDraftStart.count }
.by 1
end
end
end
context 'delete' do
it 'works' do
click :draft_sidebar_button
within :draft_sidebar do
click '.label-subtle'
end
in_modal do
click '.js-delete'
end
click_on 'Yes'
expect(Ticket::SharedDraftStart).not_to be_exist(draft.id)
within :draft_sidebar do
expect(page).to have_no_text(draft.name)
end
end
end
context 'preview' do
before do
click :draft_sidebar_button
within :draft_sidebar do
click '.label-subtle'
end
end
it 'shows body' do
in_modal disappears: false do
expect(page).to have_text(draft_body)
end
end
it 'shows author' do
in_modal disappears: false do
expect(page).to have_text(User.find(draft.created_by_id).fullname)
end
end
end
context 'apply' do
before do
attach(id: draft.id, object_name: draft.class.name)
click :draft_sidebar_button
within :draft_sidebar do
click '.label-subtle'
end
in_modal do
click '.js-submit'
end
end
it 'applies body' do
within :active_content do
expect(page).to have_text draft_body
end
end
it 'applies meta' do
within :active_content do
expect(find('[name=priority_id]').value).to eq draft_options[:priority_id]
end
end
it 'applies attachment' do
within :active_content do
expect(page).to have_text('1x1.png')
end
end
end
def attach(id:, object_name: 'UploadCache')
Store.add(
object: object_name,
o_id: id,
data: File.binread(Rails.root.join('test/data/image/1x1.png')),
filename: '1x1.png',
preferences: {},
created_by_id: 1,
)
end
end

View file

@ -0,0 +1,240 @@
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
require 'rails_helper'
RSpec.describe 'Ticket Shared Draft Zoom', type: :system, authenticated_as: :authenticate do
let(:group) { create(:group, shared_drafts: group_shared_drafts) }
let(:group_access) { :full }
let(:group_shared_drafts) { true }
let(:ticket) { create(:ticket, group: group) }
let(:ticket_with_draft) { create(:ticket, group: group) }
let(:draft_body) { 'draft here' }
let(:draft) do
create(:ticket_shared_draft_zoom,
ticket: ticket_with_draft,
new_article: { body: draft_body, type: 'note', internal: true },
ticket_attributes: { priority_id: '3' })
end
let(:user) do
user = create(:agent)
user.user_groups.create! group: group, access: group_access
user
end
def authenticate
draft
user
end
before do
visit "ticket/zoom/#{ticket.id}"
end
context 'buttons' do
context 'when drafts disabled for the group' do
let(:group_shared_drafts) { false }
it 'share button not visible' do
expect(page).to have_no_selector :draft_share_button
end
it 'save button not visible' do
click '.js-openDropdownMacro'
expect(page).to have_no_selector :draft_save_button
end
end
context 'when drafts enabled for the group' do
it 'share button not visible initially' do
expect(page).to have_no_selector :draft_share_button
end
it 'save button visible' do
expect(page).to have_selector(:draft_save_button, visible: :all)
end
it 'share button visible when draft exists' do
visit "ticket/zoom/#{ticket_with_draft.id}"
within :active_content do
expect(page).to have_selector :draft_share_button
end
end
it 'share button appears when other user creates draft' do
create(:ticket_shared_draft_zoom, ticket: ticket)
expect(page).to have_selector :draft_share_button
end
end
context 'when insufficient permissions' do
let(:group_access) { :read }
it 'share button not visible when draft exists' do
visit "ticket/zoom/#{ticket_with_draft.id}"
within :active_content do
expect(page).to have_no_selector :draft_share_button
end
end
it 'save button not visible' do
click '.js-openDropdownMacro'
expect(page).to have_no_selector :draft_save_button
end
end
end
context 'preview' do
before do
visit "ticket/zoom/#{ticket_with_draft.id}"
within :active_content do
click :draft_share_button
end
end
it 'shows content' do
in_modal disappears: false do
expect(page).to have_text draft_body
end
end
it 'shows author' do
in_modal disappears: false do
expect(page).to have_text(User.find(draft.created_by_id).fullname)
end
end
end
context 'delete' do
it 'works' do
visit "ticket/zoom/#{ticket_with_draft.id}"
within :active_content do
click :draft_share_button
end
in_modal do
click '.js-delete'
end
click_on 'Yes'
within :active_content do
expect(page).to have_no_selector :draft_share_button
end
end
it 'hides button when another user deletes' do
visit "ticket/zoom/#{ticket_with_draft.id}"
draft.destroy
within :active_content do
expect(page).to have_no_selector :draft_share_button
end
end
end
context 'save' do
it 'creates new draft' do
find('.articleNewEdit-body').send_keys('Some reply')
click '.js-openDropdownMacro'
expect { click :draft_save_button }
.to change { ticket.reload.shared_draft.present? }
.to true
end
it 'shows overwrite warning when draft exists' do
visit "ticket/zoom/#{ticket_with_draft.id}"
within :active_content do
find('.articleNewEdit-body').send_keys('another reply')
click '.js-openDropdownMacro'
click :draft_save_button
end
in_modal do
click '.js-submit'
end
expect(draft.reload.new_article[:body]).to match %r{another reply}
end
context 'draft loaded' do
before do
visit "ticket/zoom/#{ticket_with_draft.id}"
click :draft_share_button
in_modal do
click '.js-submit'
end
end
it 'updates existing draft' do
click '.js-openDropdownMacro'
click :draft_save_button
expect(draft.reload.new_article[:body]).to match %r{draft here}
end
it 'shows overwrite warning when draft edited after loading' do
find('.articleNewEdit-body').send_keys('another reply')
click '.js-openDropdownMacro'
click :draft_save_button
in_modal do
click '.js-submit'
end
expect(draft.reload.new_article[:body]).to match %r{another reply}
end
end
end
context 'apply' do
before do
attach(id: draft.id, object_name: draft.class.name)
visit "ticket/zoom/#{ticket_with_draft.id}"
click :draft_share_button
in_modal do
click '.js-submit'
end
end
it 'applies new article body' do
expect(page).to have_text draft_body
end
it 'applies sidebar changes' do
expect(find('[name=priority_id]').value).to eq draft.ticket_attributes[:priority_id]
end
it 'applies attachment' do
expect(page).to have_text('1x1.png')
end
end
def attach(id:, object_name: 'UploadCache')
Store.add(
object: object_name,
o_id: id,
data: File.binread(Rails.root.join('test/data/image/1x1.png')),
filename: '1x1.png',
preferences: {},
created_by_id: 1,
)
end
end