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', =>
@updateSecurityOptions()
)
@listenTo(App.Group, 'refresh', =>
@sidebarWidget.render(@params())
)
@$('[name="group_id"]').bind('change', =>
@sidebarWidget.render(@params())
)
@updateSecurityOptions()
# show cc
@ -174,6 +182,7 @@ class App.TicketCreate extends App.Controller
@navupdate("#ticket/create/id/#{@id}#{@split}", type: 'menu')
@autosaveStart()
@controllerBind('ticket_create_rerender', (template) => @renderQueue(template))
@controllerBind('ticket_create_import_draft_attachments', @importDraftAttachments)
# initially hide sidebar on mobile
if window.matchMedia('(max-width: 767px)').matches
@ -183,6 +192,7 @@ class App.TicketCreate extends App.Controller
hide: =>
@autosaveStop()
@controllerUnbind('ticket_create_rerender', (template) => @renderQueue(template))
@controllerUnbind('ticket_create_import_draft_attachments')
changed: =>
return true if @hasAttachments()
@ -283,6 +293,18 @@ class App.TicketCreate extends App.Controller
return if !@formMeta
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) =>
taskData = App.TaskManager.get(@taskKey)
return if _.isEmpty(taskData)
@ -312,6 +334,7 @@ class App.TicketCreate extends App.Controller
types: @types,
availableTypes: @availableTypes
form_id: @formId
shared_draft_id: template.shared_draft_id || params.shared_draft_id
))
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-bookmark': 'bookmark'
'click .js-reset': 'reset'
'click .js-draft': 'draft'
'click .main': 'muteTask'
constructor: (params) ->
@ -187,6 +188,9 @@ class App.TicketZoom extends App.Controller
# remember mentions
@mentions = data.mentions
if draft = App.TicketSharedDraftZoom.findByAttribute 'ticket_id', @ticket_id
draft.remove(clear: true)
App.Collection.loadAssets(data.assets, targetModel: 'Ticket')
# get ticket
@ -484,7 +488,9 @@ class App.TicketZoom extends App.Controller
ticket: @ticket
el: elLocal.find('.js-attributeBar')
overview_id: @overview_id
callback: @submit
macroCallback: @submit
draftCallback: @saveDraft
draftState: @draftState()
taskKey: @taskKey
)
#if @shown
@ -965,6 +971,55 @@ class App.TicketZoom extends App.Controller
@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) =>
taskAction = @$('.js-secondaryActionButtonLabel').data('type')
@ -1034,6 +1089,49 @@ class App.TicketZoom extends App.Controller
bookmark: (e) ->
$(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) =>
if e
e.preventDefault()

View file

@ -53,13 +53,16 @@ class App.TicketZoomArticleNew extends App.Controller
@setArticleTypePre(data.type.name, data.signaturePosition)
@openTextarea(null, true, true)
@openTextarea(null, true, !data.nofocus)
for key, value of data.article
if key is 'body'
@$("[data-name=\"#{key}\"]").html(value)
else
@$("[name=\"#{key}\"]").val(value).trigger('change')
@$('[name=shared_draft_id]').val(data.shared_draft_id)
@setArticleTypePost(data.type.name, data.signaturePosition)
# set focus into field
@ -76,6 +79,8 @@ class App.TicketZoomArticleNew extends App.Controller
@tokanice(data.type.name)
)
@controllerBind('ui::ticket::import_draft_attachments', @importDraftAttachments)
# add article attachment
@controllerBind('ui::ticket::addArticleAttachent', (data) =>
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)
)
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: ->
actionConfig = App.Config.get('TicketZoomArticleAction')
keys = _.keys(actionConfig).sort()

View file

@ -9,6 +9,9 @@ class App.TicketZoomAttributeBar extends App.Controller
'mouseup .js-dropdownActionMacro': 'performTicketMacro'
'mouseenter .js-dropdownActionMacro': 'onActionMacroMouseEnter'
'mouseleave .js-dropdownActionMacro': 'onActionMacroMouseLeave'
'mouseup .js-dropdownActionSaveDraft': 'saveDraft'
'mouseenter .js-dropdownActionSaveDraft': 'onActionMacroMouseEnter'
'mouseleave .js-dropdownActionSaveDraft': 'onActionMacroMouseLeave'
'click .js-secondaryAction': 'chooseSecondaryAction'
searchCondition: {}
@ -31,19 +34,45 @@ class App.TicketZoomAttributeBar extends App.Controller
@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: ->
return App.Session.get().preferences.secondaryAction || App.Config.get('ticket_secondary_action') || 'stayOnTab'
release: =>
App.Macro.unsubscribe(@subscribeId)
render: =>
render: (options = {}) =>
# remember current reset state
resetButtonShown = false
if @resetButton.get(0) && !@resetButton.hasClass('hide')
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()
@macroLastUpdated = App.Macro.lastUpdatedAt()
@ -61,9 +90,13 @@ class App.TicketZoomAttributeBar extends App.Controller
localeEl = $(App.view('ticket_zoom/attribute_bar')(
macros: @possibleMacros
macroDisabled: macroDisabled
sharedButtonVisible: sharedButtonVisible
sharedDraftsDisabled: !sharedDraftsEnabled
overview_id: @overview_id
resetButtonShown: resetButtonShown
sharedDraftButtonShown: sharedDraftButtonShown
))
@setSecondaryAction(@secondaryAction, localeEl)
if @ticket.currentView() is 'agent'
@ -74,6 +107,26 @@ class App.TicketZoomAttributeBar extends App.Controller
@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: =>
return if !@taskbarWatcher
@taskbarWatcher.start()
@ -106,9 +159,12 @@ class App.TicketZoomAttributeBar extends App.Controller
macroId = $(e.currentTarget).data('id')
macro = App.Macro.find(macroId)
@callback(e, macro)
@macroCallback(e, macro)
@closeMacroMenu()
saveDraft: (e) =>
@draftCallback(e)
onActionMacroMouseEnter: (e) =>
@$(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()
@ticket = App.Ticket.find(@ticket.id)
if data.form_meta
@formMeta = data.form_meta
@render()
@render(data.draft)
)
@render()
render: =>
render: (draft = {}) =>
defaults = @ticket.attributes()
delete defaults.article # ignore article infos
followUpPossible = App.Group.find(defaults.group_id).follow_up_possible
@ -45,7 +46,7 @@ class Edit extends App.Controller
handlersConfig: handlers
filter: @formMeta.filter
formMeta: @formMeta
params: defaults
params: _.extend(defaults, draft)
isDisabled: editable
taskKey: @taskKey
core_workflow: {

View file

@ -4,7 +4,7 @@ class App.TicketZoomTimeAccounting extends App.ControllerModal
buttonSubmit: __('Account Time')
buttonClass: 'btn--success'
leftButtons: [{
className: 'btn--text btn--subtle js-skip',
className: 'js-skip',
text: __('skip')
}]
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) =>
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
if @sidebarState
@sidebarState.active = name
# remove active state
@tabs.removeClass('active')
# add active state
@$('.tabsSidebar-tab[data-tab=' + name + ']').addClass('active')

View file

@ -1,5 +1,5 @@
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
@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: 'updated_at', display: __('Updated'), tag: 'datetime', readonly: 1 },
{ name: 'active', display: __('Active'), tag: 'active', default: true },
{ name: 'shared_drafts', display: __('Shared Drafts'), tag: 'active' },
]
@configure_clone = true
@configure_overview = [

View file

@ -374,3 +374,11 @@ class App.Ticket extends App.Model
return false if !user.permission('ticket.agent')
return true if @isAccessibleByOwner(user)
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)
attachments.push attachment
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">
<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 %>"/>
<div class="ticket-form-top"></div>
<div class="form-group js-securityOptions hide">

View file

@ -21,16 +21,18 @@
<div class="loading icon"></div>
</div>
<div class="modal-footer">
<% if @buttonCancel || @leftButtons: %>
<div class="modal-leftFooter align-left">
<% if @buttonCancel: %>
<div class="modal-leftFooter">
<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>
<a class="btn <%= @buttonCancelClass %> js-cancel" href="#"><%- @T(@buttonCancel) %></a>
<% 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 %>
<% for button in @centerButtons: %>
<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="internal" value="<%= @article.internal %>">
<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="in_reply_to" value="<%= @article.in_reply_to %>">
<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="buttonDropdown dropdown dropdown--actions dropup">
<div class="btn btn--text btn--icon--last" data-toggle="dropdown">
@ -30,16 +53,30 @@
</ul>
</div>
<form class="buttonDropdown">
<% if @macroDisabled: %>
<% if @macroDisabled && @sharedDraftsDisabled: %>
<button class="btn btn--primary js-submit"><span><%- @T('Update') %></span></button>
<% else: %>
<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--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">
<% 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: %>
<li class="js-dropdownActionMacro" role="menuitem" data-id="<%= macro.id %>"><%- macro.displayName() %>
<% end %>
<% end %>
</ul>
</div>
<% 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;
}
&-draft-spacer {
width: 2px;
height: 60px;
margin-right: 15px;
}
&-avatars:empty + &-draft-spacer {
display: none;
}
&--border {
border-top: 1px solid hsl(0, 0%, 94%);
}
@ -8779,6 +8789,10 @@ a.list-group-item.active > .badge,
@include phone {
padding: 15px;
}
.btn--text + .btn--text {
margin-bottom: -10px;
}
}
.modal-leftFooter,
@ -8931,6 +8945,14 @@ a.list-group-item.active > .badge,
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.is-active {
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)
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)
# overwrite params
@ -92,7 +102,7 @@ class TicketsController < ApplicationController
end
# 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:(.+?)$}
email_address = $1
email_address_validation = EmailAddressValidation.new(email_address)
@ -245,6 +255,16 @@ class TicketsController < ApplicationController
ticket.with_lock do
ticket.update!(clean_params)
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])
end
end
@ -694,6 +714,10 @@ class TicketsController < ApplicationController
assets = mention.assets(assets)
end
if (draft = ticket.shared_draft) && authorized?(draft, :show?)
assets = draft.assets(assets)
end
# return result
{
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
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

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 :flags, class_name: 'Ticket::Flag', 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 :priority, class_name: 'Ticket::Priority', 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
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
match api_path + '/tickets/search', to: 'tickets#search', via: %i[get 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.boolean :follow_up_assignment, 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.integer :updated_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
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
def self.down
@ -626,5 +647,7 @@ class CreateTicket < ActiveRecord::Migration[4.2]
drop_table :ticket_states
drop_table :ticket_state_types
drop_table :webhooks
drop_table :ticket_shared_draft_zooms
drop_table :ticket_shared_draft_starts
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,
)
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(
force: true,
object: 'Group',

View file

@ -807,6 +807,14 @@ msgstr ""
msgid "Apply"
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/report.coffee
#: app/assets/javascripts/app/controllers/time_accounting.coffee
@ -829,6 +837,10 @@ msgstr ""
msgid "Archived at"
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
msgid "Are you sure you want to reload? You have unsaved changes that will get lost"
msgstr ""
@ -844,6 +856,7 @@ msgstr ""
#: app/assets/javascripts/app/controllers/maintenance.coffee
#: app/assets/javascripts/app/controllers/ticket_zoom/article_action/delete.coffee
#: app/assets/javascripts/app/controllers/widget/template.coffee
#: app/assets/javascripts/app/views/ticket_shared_draft_modal.coffee
msgid "Are you sure?"
msgstr ""
@ -2115,6 +2128,7 @@ msgstr ""
#: 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/translation/todo.jst.eco
#: app/assets/javascripts/app/views/widget/shared_draft.jst.eco
msgid "Create"
msgstr ""
@ -2150,6 +2164,10 @@ msgstr ""
msgid "Create a Test Ticket"
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
msgid "Create a translation"
msgstr ""
@ -3330,6 +3348,10 @@ msgstr ""
msgid "Draft"
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
msgid "Drag to reorder"
msgstr ""
@ -5389,6 +5411,10 @@ msgstr ""
msgid "Last Contact Customer At"
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
msgid "Last contact"
msgstr ""
@ -6902,6 +6928,10 @@ msgstr ""
msgid "Overviews are …"
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/ticket_perform_action.coffee
#: app/assets/javascripts/app/models/ticket.coffee
@ -7778,6 +7808,10 @@ msgstr ""
msgid "Save"
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
msgid "Save as"
msgstr ""
@ -7908,6 +7942,10 @@ msgstr ""
msgid "Select CSV file"
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
msgid "Select Template"
msgstr ""
@ -8155,11 +8193,25 @@ msgstr ""
msgid "Shared"
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
#: db/seeds/object_manager_attributes.rb
msgid "Shared organization"
msgstr ""
#: app/assets/javascripts/app/views/ticket_zoom/attribute_bar.jst.eco
msgid "Sharing draft..."
msgstr ""
#: app/assets/javascripts/app/controllers/_profile/avatar.coffee
msgid "Shoot"
msgstr ""
@ -8173,6 +8225,10 @@ msgstr ""
msgid "Show"
msgstr ""
#: app/assets/javascripts/app/views/ticket_shared_draft_overwrite_modal.coffee
msgid "Show Draft"
msgstr ""
#: app/assets/javascripts/app/views/navigation.jst.eco
msgid "Show Search Details"
msgstr ""
@ -9001,6 +9057,14 @@ msgstr ""
msgid "There are too many people in the chat queue."
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
msgid "There is no Knowledge Base yet. Please create one."
msgstr ""
@ -9617,6 +9681,7 @@ msgstr ""
#: app/assets/javascripts/app/views/knowledge_base/content.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/widget/shared_draft.jst.eco
msgid "Update"
msgstr ""
@ -10337,6 +10402,7 @@ msgstr ""
#: 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/tag.coffee
#: app/assets/javascripts/app/views/ticket_shared_draft_modal.coffee
msgid "Yes"
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
refs_known = { 'Ticket::Article' => { 'ticket_id'=>1 },
'Ticket::TimeAccounting' => { 'ticket_id'=>1 },
'Ticket::SharedDraftZoom' => { 'ticket_id'=>0 },
'Ticket::Flag' => { 'ticket_id'=>1 } }
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::Flag' => { 'created_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::State' => { 'created_by_id' => 0, 'updated_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
css { |task_key| ".tasks .task[data-key='#{task_key}']" }
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