Fixes #2867 - KB links are in the header and footer of the public KB, Fixes #2834 - unclear meaning of "Public Menu" tab in KB admin
This commit is contained in:
parent
a69aebcd0c
commit
cc2bc4f188
24 changed files with 607 additions and 125 deletions
|
@ -91,6 +91,7 @@ RSpec/FilePath:
|
||||||
- 'spec/db/migrate/issue_2460_fix_corrupted_twitter_ids_spec.rb'
|
- 'spec/db/migrate/issue_2460_fix_corrupted_twitter_ids_spec.rb'
|
||||||
- 'spec/db/migrate/issue_2715_fix_broken_twitter_urls_spec.rb'
|
- 'spec/db/migrate/issue_2715_fix_broken_twitter_urls_spec.rb'
|
||||||
- 'spec/jobs/issue_2715_fix_broken_twitter_urls_job_spec.rb'
|
- 'spec/jobs/issue_2715_fix_broken_twitter_urls_job_spec.rb'
|
||||||
|
- 'spec/db/migrate/issue_2867_footer_header_public_link_spec.rb'
|
||||||
- 'spec/lib/import/base_factory_spec.rb'
|
- 'spec/lib/import/base_factory_spec.rb'
|
||||||
|
|
||||||
# Offense count: 60
|
# Offense count: 60
|
||||||
|
|
|
@ -109,7 +109,7 @@ class App.ManageKnowledgeBase extends App.ControllerTabs
|
||||||
},{
|
},{
|
||||||
name: 'Public Menu'
|
name: 'Public Menu'
|
||||||
target: 'public_menu'
|
target: 'public_menu'
|
||||||
controller: App.KnowledgeBasePublicMenuForm
|
controller: App.KnowledgeBasePublicMenuManager
|
||||||
params: _.extend({}, params, { screen: 'public_menu' })
|
params: _.extend({}, params, { screen: 'public_menu' })
|
||||||
},{
|
},{
|
||||||
name: 'Delete'
|
name: 'Delete'
|
||||||
|
|
|
@ -1,17 +1,69 @@
|
||||||
class App.KnowledgeBasePublicMenuForm extends App.Controller
|
class App.KnowledgeBasePublicMenuForm extends App.ControllerModal
|
||||||
events:
|
autoFocusOnFirstInput: false
|
||||||
'show.bs.tab': 'willShow'
|
includeForm: true
|
||||||
|
|
||||||
willShow: ->
|
constructor: (params) ->
|
||||||
@el.empty()
|
@formItems = []
|
||||||
|
@head = params.location.headline
|
||||||
|
super
|
||||||
|
|
||||||
for kb_locale in App.KnowledgeBase.find(@knowledge_base_id).kb_locales()
|
formParams: =>
|
||||||
menu_items = App.KnowledgeBaseMenuItem.using_kb_locale(kb_locale)
|
@formItems.map (elem) -> elem.buildData()
|
||||||
|
|
||||||
form_item = new App.KnowledgeBasePublicMenuFormItem(
|
content: ->
|
||||||
knowledge_base_id: @knowledge_base_id,
|
@formItems = App.KnowledgeBase
|
||||||
kb_locale: kb_locale,
|
.find(@knowledge_base_id)
|
||||||
menu_items: menu_items
|
.kb_locales()
|
||||||
)
|
.map (kb_locale) =>
|
||||||
|
menu_items = App.KnowledgeBaseMenuItem.using_kb_locale_location(kb_locale, @location.identifier)
|
||||||
|
|
||||||
@el.append form_item.el
|
new App.KnowledgeBasePublicMenuFormItem(
|
||||||
|
parent: @,
|
||||||
|
knowledge_base_id: @knowledge_base_id,
|
||||||
|
location: @location.identifier,
|
||||||
|
kb_locale: kb_locale,
|
||||||
|
menu_items: menu_items
|
||||||
|
)
|
||||||
|
|
||||||
|
@formItems.map (elem) -> elem.el
|
||||||
|
|
||||||
|
hasError: ->
|
||||||
|
@formItems
|
||||||
|
.map (elem) -> elem.hasError()
|
||||||
|
.filter((elem) -> elem)
|
||||||
|
.pop()
|
||||||
|
|
||||||
|
onSubmit: (e) ->
|
||||||
|
@preventDefaultAndStopPropagation(e)
|
||||||
|
|
||||||
|
if error = @hasError()
|
||||||
|
@showAlert(error)
|
||||||
|
return
|
||||||
|
|
||||||
|
@clearAlerts()
|
||||||
|
@formItems.forEach (elem) -> elem.toggleUserInteraction(false)
|
||||||
|
|
||||||
|
kb = App.KnowledgeBase.find(@knowledge_base_id)
|
||||||
|
|
||||||
|
@ajax(
|
||||||
|
id: 'update_menu_items'
|
||||||
|
type: 'PATCH'
|
||||||
|
url: kb.manageUrl('update_menu_items')
|
||||||
|
data: JSON.stringify(menu_items_sets: @formParams())
|
||||||
|
processData: true
|
||||||
|
success: @onSuccess
|
||||||
|
error: @onError
|
||||||
|
)
|
||||||
|
|
||||||
|
onSuccess: (data, status, xhr) =>
|
||||||
|
for formItem in @formItems
|
||||||
|
for menuItem in App.KnowledgeBaseMenuItem.using_kb_locale_location(formItem.kb_locale, formItem.location)
|
||||||
|
menuItem.remove(clear: true)
|
||||||
|
|
||||||
|
App.Collection.loadAssets(data.assets)
|
||||||
|
App.KnowledgeBaseMenuItem.trigger('kb_data_change_loaded')
|
||||||
|
@close()
|
||||||
|
|
||||||
|
onError: (xhr) =>
|
||||||
|
@showAlert(xhr.responseJSON?.error_human || 'Couldn\'t save changes')
|
||||||
|
@formItems.forEach (elem) -> elem.toggleUserInteraction(true)
|
||||||
|
|
|
@ -3,7 +3,6 @@ class App.KnowledgeBasePublicMenuFormItem extends App.Controller
|
||||||
'click .js-add': 'add'
|
'click .js-add': 'add'
|
||||||
'click .js-remove': 'remove'
|
'click .js-remove': 'remove'
|
||||||
'input input': 'input'
|
'input input': 'input'
|
||||||
'submit form': 'submit'
|
|
||||||
|
|
||||||
elements:
|
elements:
|
||||||
'.js-alert': 'alert'
|
'.js-alert': 'alert'
|
||||||
|
@ -14,30 +13,29 @@ class App.KnowledgeBasePublicMenuFormItem extends App.Controller
|
||||||
|
|
||||||
render: ->
|
render: ->
|
||||||
@html App.view('knowledge_base/public_menu_form_item')(
|
@html App.view('knowledge_base/public_menu_form_item')(
|
||||||
kb_locale_id: @kb_locale.id
|
rows: @menu_items
|
||||||
rows: @menu_items
|
title: @kb_locale.systemLocale().name
|
||||||
title: @kb_locale.systemLocale().name
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@applySortable()
|
@applySortable()
|
||||||
|
|
||||||
applySortable: ->
|
applySortable: ->
|
||||||
dndOptions =
|
dndOptions =
|
||||||
tolerance: 'pointer'
|
tolerance: 'pointer'
|
||||||
distance: 15
|
distance: 15
|
||||||
opacity: 0.6
|
opacity: 0.6
|
||||||
items: 'tr.sortable'
|
items: 'tr.sortable'
|
||||||
start: (e, ui) ->
|
start: (e, ui) ->
|
||||||
ui.placeholder.height( ui.item.height() )
|
ui.placeholder.height( ui.item.height() )
|
||||||
helper: (e, tr) ->
|
helper: (e, tr) ->
|
||||||
originals = tr.children()
|
originals = tr.children()
|
||||||
helper = tr
|
helper = tr
|
||||||
helper.children().each (index, el) ->
|
helper.children().each (index, el) ->
|
||||||
# Set helper cell sizes to match the original sizes
|
# Set helper cell sizes to match the original sizes
|
||||||
$(@).width( originals.eq(index).width() )
|
$(@).width( originals.eq(index).width() )
|
||||||
return helper
|
return helper
|
||||||
update: @dndCallback
|
update: @dndCallback
|
||||||
stop: (e, ui) ->
|
stop: (e, ui) ->
|
||||||
ui.item.children().each (index, element) ->
|
ui.item.children().each (index, element) ->
|
||||||
element.style.width = ''
|
element.style.width = ''
|
||||||
|
|
||||||
|
@ -66,13 +64,14 @@ class App.KnowledgeBasePublicMenuFormItem extends App.Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
kb_locale_id: @$('form').data('kb-locale-id'),
|
kb_locale_id: @kb_locale.id,
|
||||||
menu_items: items
|
location: @location,
|
||||||
|
menu_items: items
|
||||||
}
|
}
|
||||||
|
|
||||||
input: ->
|
input: ->
|
||||||
if @validateForm(false)
|
if !@hasError()
|
||||||
@hideAlert()
|
@parent.clearAlerts()
|
||||||
|
|
||||||
add: ->
|
add: ->
|
||||||
el = App.view('knowledge_base/public_menu_form_item_row')()
|
el = App.view('knowledge_base/public_menu_form_item_row')()
|
||||||
|
@ -88,57 +87,14 @@ class App.KnowledgeBasePublicMenuFormItem extends App.Controller
|
||||||
else
|
else
|
||||||
row.remove()
|
row.remove()
|
||||||
|
|
||||||
showAlert: (message) ->
|
findEmptyFields: ->
|
||||||
translated = App.i18n.translatePlain(message)
|
|
||||||
|
|
||||||
@alert
|
|
||||||
.text(translated)
|
|
||||||
.removeClass('hidden')
|
|
||||||
|
|
||||||
hideAlert: ->
|
|
||||||
@alert.addClass('hidden')
|
|
||||||
|
|
||||||
emptyFields: ->
|
|
||||||
@$('tr.sortable:not(.js-deleted)')
|
@$('tr.sortable:not(.js-deleted)')
|
||||||
.find('input[data-name]')
|
.find('input[data-name]')
|
||||||
.toArray()
|
.toArray()
|
||||||
.filter (elem) -> $(elem).val().length == 0
|
.filter (elem) -> $(elem).val().length == 0
|
||||||
|
|
||||||
validateForm: (showAlert = true) ->
|
hasError: ->
|
||||||
if @emptyFields().length == 0
|
if @findEmptyFields().length == 0
|
||||||
return true
|
return false
|
||||||
|
|
||||||
if showAlert
|
'Please fill in all fields'
|
||||||
@showAlert('Please fill in all fields')
|
|
||||||
|
|
||||||
false
|
|
||||||
|
|
||||||
submit: (e) ->
|
|
||||||
@preventDefaultAndStopPropagation(e)
|
|
||||||
|
|
||||||
if !@validateForm()
|
|
||||||
return
|
|
||||||
|
|
||||||
@hideAlert()
|
|
||||||
@toggleUserInteraction(false)
|
|
||||||
|
|
||||||
kb = App.KnowledgeBase.find(@knowledge_base_id)
|
|
||||||
|
|
||||||
@ajax(
|
|
||||||
id: 'update_menu_items'
|
|
||||||
type: 'PATCH'
|
|
||||||
url: kb.manageUrl('update_menu_items')
|
|
||||||
data: JSON.stringify(@buildData())
|
|
||||||
processData: true
|
|
||||||
success: (data, status, xhr) =>
|
|
||||||
for menu_item in App.KnowledgeBaseMenuItem.using_kb_locale(@kb_locale)
|
|
||||||
menu_item.remove(clear: true)
|
|
||||||
|
|
||||||
App.Collection.loadAssets(data.assets)
|
|
||||||
|
|
||||||
@menu_items = App.KnowledgeBaseMenuItem.using_kb_locale(@kb_locale)
|
|
||||||
@render()
|
|
||||||
error: (xhr) =>
|
|
||||||
@showAlert(xhr.responseJSON?.error_human || 'Couldn\'t save changes')
|
|
||||||
@toggleUserInteraction(true)
|
|
||||||
)
|
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
class App.KnowledgeBasePublicMenuManager extends App.Controller
|
||||||
|
events:
|
||||||
|
'show.bs.tab': 'willShow'
|
||||||
|
'click .js-edit': 'edit'
|
||||||
|
|
||||||
|
constructor: ->
|
||||||
|
super
|
||||||
|
|
||||||
|
@listenTo App.KnowledgeBaseMenuItem, 'kb_data_change_loaded', =>
|
||||||
|
@render()
|
||||||
|
|
||||||
|
willShow: ->
|
||||||
|
@render()
|
||||||
|
|
||||||
|
render: ->
|
||||||
|
kb = App.KnowledgeBase.find(@knowledge_base_id)
|
||||||
|
|
||||||
|
@html App.view('knowledge_base/public_menu_manager')(
|
||||||
|
locations: @locations(),
|
||||||
|
locales: kb.kb_locales()
|
||||||
|
)
|
||||||
|
|
||||||
|
locations: ->
|
||||||
|
kb = App.KnowledgeBase.find(@knowledge_base_id)
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
headline: 'Header menu',
|
||||||
|
identifier: 'header',
|
||||||
|
color: kb.color_header
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headline: 'Footer menu',
|
||||||
|
identifier: 'footer'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
edit: (e) =>
|
||||||
|
@preventDefaultAndStopPropagation(e)
|
||||||
|
|
||||||
|
identifier = $(e.target).data('target-location')
|
||||||
|
location = _.find @locations(), (elem) -> elem.identifier == identifier
|
||||||
|
|
||||||
|
new App.KnowledgeBasePublicMenuForm(
|
||||||
|
location: location,
|
||||||
|
knowledge_base_id: @knowledge_base_id
|
||||||
|
container: @el.closest('.main')
|
||||||
|
)
|
|
@ -5,3 +5,8 @@ class App.KnowledgeBaseMenuItem extends App.Model
|
||||||
items = @findAllByAttribute('kb_locale_id', kb_locale.id)
|
items = @findAllByAttribute('kb_locale_id', kb_locale.id)
|
||||||
items.sort( (a, b) -> if a.position < b.position then -1 else 1)
|
items.sort( (a, b) -> if a.position < b.position then -1 else 1)
|
||||||
items
|
items
|
||||||
|
|
||||||
|
@using_kb_locale_location: (kb_locale, location) ->
|
||||||
|
items = @all().filter (elem) -> elem.kb_locale_id is kb_locale.id and elem.location is location
|
||||||
|
items.sort( (a, b) -> if a.position < b.position then -1 else 1)
|
||||||
|
items
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<form data-kb-locale-id="<%= @kb_locale_id %>" class="settings-entry">
|
<div data-kb-locale-id="<%= @kb_locale_id %>" class="settings-entry">
|
||||||
<h2><%= @title %></h2>
|
<h2><%= @title %></h2>
|
||||||
|
|
||||||
<div class="js-alert alert alert--danger hidden"></div>
|
<div class="js-alert alert alert--danger hidden"></div>
|
||||||
|
@ -28,8 +28,4 @@
|
||||||
</a>
|
</a>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="horizontal justify-end">
|
|
||||||
<button type="submit" class="btn btn--primary"><%- @T('Submit') %></button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
<h2>
|
||||||
|
<%- @T 'Public Menu' %>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="help-text">
|
||||||
|
<%- @T 'Here you can add further links to your public FAQ page, which will be displayed either in the header or footer.' %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<% for location in @locations: %>
|
||||||
|
<div class="settings-entry kb-menu-settings-entry">
|
||||||
|
<h3><%= @T(location.headline) %></h3>
|
||||||
|
|
||||||
|
<% for kb_locale in @locales: %>
|
||||||
|
<div class="kb-menu-preview">
|
||||||
|
<div class="label"><%= kb_locale.systemLocale().name %></div>
|
||||||
|
|
||||||
|
<div class="kb-menu-preview-container kb-menu-preview-container--<%= location.identifier %>" style="background-color: <%= location.color %>">
|
||||||
|
<% menu_items = App.KnowledgeBaseMenuItem.using_kb_locale_location(kb_locale, location.identifier) %>
|
||||||
|
|
||||||
|
<% if menu_items.length == 0: %>
|
||||||
|
<span class="text-muted"><%= @T 'Empty' %></span>
|
||||||
|
<% else: %>
|
||||||
|
<% for item in menu_items: %>
|
||||||
|
<a href="<%= item.url %>" target="_blank"><%= item.title %></a>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="btn btn--primary js-edit btn-manage-public-menu-edit"
|
||||||
|
href="#"
|
||||||
|
data-target-location="<%= location.identifier %>"
|
||||||
|
data-target-locale="<%= kb_locale.id %>">
|
||||||
|
|
||||||
|
<%= @T 'Edit' %>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
|
@ -2472,6 +2472,36 @@ input.has-error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.kb-menu-preview {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
|
||||||
|
&-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
border: 1px solid hsl(213,14%,91%);
|
||||||
|
|
||||||
|
&--footer {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a, span {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: .5em 1em;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: hsl(206,8%,50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.modified-icon {
|
.modified-icon {
|
||||||
position: relative;
|
position: relative;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
@ -11684,3 +11714,11 @@ span.is-disabled {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-manage-public-menu-edit {
|
||||||
|
margin-top: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-menu-settings-entry {
|
||||||
|
margin-bottom: 12px
|
||||||
|
}
|
||||||
|
|
|
@ -31,13 +31,10 @@ class KnowledgeBase::ManageController < KnowledgeBase::BaseController
|
||||||
|
|
||||||
def update_menu_items
|
def update_menu_items
|
||||||
kb = KnowledgeBase.find params[:id]
|
kb = KnowledgeBase.find params[:id]
|
||||||
kb_locale = kb.kb_locales.find params[:kb_locale_id]
|
|
||||||
|
|
||||||
KnowledgeBase::MenuItemUpdateAction
|
affected_items = KnowledgeBase::MenuItemUpdateAction.update_using_params! kb, params_for_permission[:menu_items_sets]
|
||||||
.new(kb_locale, params[:menu_items])
|
|
||||||
.perform!
|
|
||||||
|
|
||||||
render json: { assets: ApplicationModel::CanAssets.reduce(kb_locale.menu_items.reload, {}) }
|
render json: { assets: ApplicationModel::CanAssets.reduce(affected_items || [], {}) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
|
|
@ -24,9 +24,7 @@ class KnowledgeBase::Public::BaseController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def menu_items
|
def menu_items
|
||||||
@menu_items ||= KnowledgeBase::MenuItem
|
@menu_items ||= KnowledgeBase::MenuItem.using_locale(guess_locale_via_uri || filter_primary_kb_locale)
|
||||||
.sorted
|
|
||||||
.using_locale(guess_locale_via_uri || filter_primary_kb_locale)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def system_locale_via_uri
|
def system_locale_via_uri
|
||||||
|
|
|
@ -1,17 +1,24 @@
|
||||||
class KnowledgeBase::MenuItem < ApplicationModel
|
class KnowledgeBase::MenuItem < ApplicationModel
|
||||||
belongs_to :kb_locale, class_name: 'KnowledgeBase::Locale', inverse_of: :menu_items, touch: true
|
belongs_to :kb_locale, class_name: 'KnowledgeBase::Locale', inverse_of: :menu_items, touch: true
|
||||||
|
|
||||||
validates :title, presence: true, length: { maximum: 100 }
|
validates :title, presence: true, length: { maximum: 100 }
|
||||||
validates :url, presence: true, length: { maximum: 100 }
|
validates :url, presence: true, length: { maximum: 500 }
|
||||||
|
validates :location, presence: true, inclusion: { in: %w[header footer] }
|
||||||
|
|
||||||
acts_as_list scope: :kb_locale, top_of_list: 0
|
acts_as_list scope: %i[kb_locale_id location], top_of_list: 0
|
||||||
|
|
||||||
scope :sorted, -> { order(position: :asc) }
|
scope :sorted, -> { order(position: :asc) }
|
||||||
scope :using_locale, ->(locale) { locale.present? ? joins(:kb_locale).where(knowledge_base_locales: { system_locale_id: locale.id } ) : none }
|
scope :using_locale, ->(locale) { locale.present? ? joins(:kb_locale).where(knowledge_base_locales: { system_locale_id: locale.id } ) : none }
|
||||||
|
scope :location, ->(location) { sorted.where(location: location) }
|
||||||
|
|
||||||
|
scope :location_header, -> { location(:header) }
|
||||||
|
scope :location_footer, -> { location(:footer) }
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def add_protocol_prefix
|
def add_protocol_prefix
|
||||||
|
return if url.blank?
|
||||||
|
|
||||||
url.strip!
|
url.strip!
|
||||||
|
|
||||||
return if url.match? %r{^\S+\:\/\/}
|
return if url.match? %r{^\S+\:\/\/}
|
||||||
|
|
|
@ -25,7 +25,7 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</h1>
|
</h1>
|
||||||
<nav class="menu">
|
<nav class="menu">
|
||||||
<% menu_items.each do |menu_item| %>
|
<% menu_items.location_header.each do |menu_item| %>
|
||||||
<%= link_to menu_item.title, menu_item.url, class: 'menu-item', target: menu_item.new_tab ? '_blank' : nil %>
|
<%= link_to menu_item.title, menu_item.url, class: 'menu-item', target: menu_item.new_tab ? '_blank' : nil %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</nav>
|
</nav>
|
||||||
|
@ -51,6 +51,13 @@
|
||||||
<div class="copyright">
|
<div class="copyright">
|
||||||
<%= @knowledge_base.translation.footer_note %>
|
<%= @knowledge_base.translation.footer_note %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<nav class="menu">
|
||||||
|
<% menu_items.location_footer.each do |menu_item| %>
|
||||||
|
<%= link_to menu_item.title, menu_item.url, class: 'menu-item', target: menu_item.new_tab ? '_blank' : nil %>
|
||||||
|
<% end %>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="language-picker">
|
<div class="language-picker">
|
||||||
<a class="btn btn--action" href="#" data-toggle="dropdown" aria-expanded="false">
|
<a class="btn btn--action" href="#" data-toggle="dropdown" aria-expanded="false">
|
||||||
<%= system_locale_via_uri.name %>
|
<%= system_locale_via_uri.name %>
|
||||||
|
|
|
@ -92,6 +92,7 @@ class InitializeKnowledgeBase < ActiveRecord::Migration[5.0]
|
||||||
|
|
||||||
create_table :knowledge_base_menu_items do |t|
|
create_table :knowledge_base_menu_items do |t|
|
||||||
t.references :kb_locale, null: false, foreign_key: { to_table: :knowledge_base_locales, on_delete: :cascade }
|
t.references :kb_locale, null: false, foreign_key: { to_table: :knowledge_base_locales, on_delete: :cascade }
|
||||||
|
t.string :location, null: false, index: true
|
||||||
t.integer :position, null: false, index: true
|
t.integer :position, null: false, index: true
|
||||||
t.string :title, null: false, limit: 100
|
t.string :title, null: false, limit: 100
|
||||||
t.string :url, null: false, limit: 500
|
t.string :url, null: false, limit: 500
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
class Issue2867FooterHeaderPublicLink < ActiveRecord::Migration[5.2]
|
||||||
|
def up
|
||||||
|
# return if it's a new setup
|
||||||
|
return if !Setting.find_by(name: 'system_init_done')
|
||||||
|
|
||||||
|
add_column :knowledge_base_menu_items, :location, :string, null: false, default: 'header'
|
||||||
|
add_index :knowledge_base_menu_items, :location
|
||||||
|
change_column_default :knowledge_base_menu_items, :location, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
remove_column :knowledge_base_menu_items, :location
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,10 +1,15 @@
|
||||||
class KnowledgeBase
|
class KnowledgeBase
|
||||||
class MenuItemUpdateAction
|
class MenuItemUpdateAction
|
||||||
def initialize(kb_locale, menu_items_data)
|
def initialize(kb_locale, location, menu_items_data)
|
||||||
@kb_locale = kb_locale
|
@kb_locale = kb_locale
|
||||||
|
@location = location
|
||||||
@menu_items_data = menu_items_data
|
@menu_items_data = menu_items_data
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def scope
|
||||||
|
@kb_locale.menu_items.location(@location)
|
||||||
|
end
|
||||||
|
|
||||||
def perform!
|
def perform!
|
||||||
raise_unprocessable unless all_ids_present?
|
raise_unprocessable unless all_ids_present?
|
||||||
|
|
||||||
|
@ -16,15 +21,49 @@ class KnowledgeBase
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Mass-update KB menu items
|
||||||
|
#
|
||||||
|
# @param [KnowledgeBase] knowledge_base
|
||||||
|
# @param [[<Hash>]] params @see .update_location_params!
|
||||||
|
#
|
||||||
|
# @return [<KnowledgeBase::MenuItem>]
|
||||||
|
def self.update_using_params!(knowledge_base, params)
|
||||||
|
return if params.blank?
|
||||||
|
|
||||||
|
params
|
||||||
|
.map { |location_params| update_location_using_params! knowledge_base, location_params }
|
||||||
|
.map(&:reload)
|
||||||
|
.reduce(:+)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mass-update KB menu items in a given location
|
||||||
|
#
|
||||||
|
# @param [KnowledgeBase] knowledge_base
|
||||||
|
# @param [Hash] location_params
|
||||||
|
#
|
||||||
|
# @option location_params [Integer] :kb_locale_id
|
||||||
|
# @option location_params [String] :location header or footer
|
||||||
|
# @option location_params [[<Hash>]] :menu_items @see #update_order
|
||||||
|
def self.update_location_using_params!(knowledge_base, location_params)
|
||||||
|
action = new(
|
||||||
|
knowledge_base.kb_locales.find(location_params[:kb_locale_id]),
|
||||||
|
location_params[:location],
|
||||||
|
location_params[:menu_items]
|
||||||
|
)
|
||||||
|
|
||||||
|
action.perform!
|
||||||
|
action.scope
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def update_order
|
def update_order
|
||||||
old_items = @kb_locale.menu_items.to_a
|
old_items = scope.to_a
|
||||||
|
|
||||||
@menu_items_data
|
@menu_items_data
|
||||||
.reject { |elem| elem[:_destroy] }
|
.reject { |elem| elem[:_destroy] }
|
||||||
.each_with_index do |data_elem, index|
|
.each_with_index do |data_elem, index|
|
||||||
item = old_items.find { |record| record.id == data_elem[:id] } || @kb_locale.menu_items.build
|
item = old_items.find { |record| record.id == data_elem[:id] } || scope.build
|
||||||
|
|
||||||
item.position = index
|
item.position = index
|
||||||
item.title = data_elem[:title]
|
item.title = data_elem[:title]
|
||||||
|
@ -43,7 +82,7 @@ class KnowledgeBase
|
||||||
end
|
end
|
||||||
|
|
||||||
def all_ids_present?
|
def all_ids_present?
|
||||||
old_ids = @kb_locale.menu_items.pluck(:id)
|
old_ids = scope.pluck(:id)
|
||||||
new_ids = @menu_items_data.map { |elem| elem[:id]&.to_i }.compact
|
new_ids = @menu_items_data.map { |elem| elem[:id]&.to_i }.compact
|
||||||
|
|
||||||
old_ids.sort == new_ids.sort
|
old_ids.sort == new_ids.sort
|
||||||
|
|
39
spec/db/migrate/issue_2867_footer_header_public_link_spec.rb
Normal file
39
spec/db/migrate/issue_2867_footer_header_public_link_spec.rb
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Issue2867FooterHeaderPublicLink, type: :db_migration do
|
||||||
|
self.use_transactional_tests = false # see comments on #without_index method
|
||||||
|
|
||||||
|
before { without_column(table, column: column) }
|
||||||
|
|
||||||
|
let(:table) { :knowledge_base_menu_items }
|
||||||
|
let(:column) { :location }
|
||||||
|
|
||||||
|
it 'adds an index' do
|
||||||
|
expect { migrate }.to change { index_exists?(table, column) }.to(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets no default' do
|
||||||
|
expect { migrate }
|
||||||
|
.not_to change {
|
||||||
|
KnowledgeBase::MenuItem.reset_column_information
|
||||||
|
KnowledgeBase::MenuItem.column_defaults['location']
|
||||||
|
}.from(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets location for existing items' do
|
||||||
|
# create menu item without touching location column
|
||||||
|
menu_item = KnowledgeBase::MenuItem.acts_as_list_no_update do
|
||||||
|
attrs = attributes_for(:knowledge_base_menu_item)
|
||||||
|
attrs.delete :location
|
||||||
|
|
||||||
|
item = KnowledgeBase::MenuItem.new(attrs)
|
||||||
|
item.position = 0
|
||||||
|
item.kb_locale = create(:knowledge_base).kb_locales.first
|
||||||
|
item.save(validate: false)
|
||||||
|
|
||||||
|
item
|
||||||
|
end
|
||||||
|
|
||||||
|
expect { migrate }.to change { menu_item.reload.attributes['location'] }.from(nil).to('header')
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,8 +1,10 @@
|
||||||
FactoryBot.define do
|
FactoryBot.define do
|
||||||
factory 'knowledge_base/menu_item', aliases: %i[knowledge_base_menu_item] do
|
factory 'knowledge_base/menu_item', aliases: %i[knowledge_base_menu_item] do
|
||||||
kb_locale { nil }
|
kb_locale { nil }
|
||||||
title { Faker::Kpop.iii_groups }
|
sequence(:title) { |n| "menu_#{n}" }
|
||||||
url { Faker::Internet.url }
|
url { Faker::Internet.url }
|
||||||
|
|
||||||
|
for_header
|
||||||
|
|
||||||
before :create do |menu_item|
|
before :create do |menu_item|
|
||||||
if menu_item.kb_locale.blank?
|
if menu_item.kb_locale.blank?
|
||||||
|
@ -10,5 +12,13 @@ FactoryBot.define do
|
||||||
menu_item.kb_locale = kb.kb_locales.first
|
menu_item.kb_locale = kb.kb_locales.first
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
trait :for_footer do
|
||||||
|
location { 'footer' }
|
||||||
|
end
|
||||||
|
|
||||||
|
trait :for_header do
|
||||||
|
location { 'header' }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,39 +6,64 @@ RSpec.describe KnowledgeBase::MenuItem, type: :model do
|
||||||
|
|
||||||
include_context 'factory'
|
include_context 'factory'
|
||||||
|
|
||||||
context 'when url without prefix is added' do
|
context 'item' do
|
||||||
before { kb_menu_item.update(url: Faker::Internet.domain_name) }
|
it { is_expected.to validate_presence_of :title }
|
||||||
|
it { is_expected.to validate_presence_of :url }
|
||||||
|
it { is_expected.to validate_presence_of :location }
|
||||||
|
it { is_expected.to validate_inclusion_of(:location).in_array(%w[header footer]) }
|
||||||
|
end
|
||||||
|
|
||||||
it 'is saved' do
|
context 'has scopes for' do
|
||||||
expect(kb_menu_item).not_to be_changed
|
let(:kb_locale) { kb_menu_item.kb_locale }
|
||||||
|
let(:scope) { described_class.where(kb_locale: kb_locale) }
|
||||||
|
|
||||||
|
let!(:header) { create(:knowledge_base_menu_item, :for_header, kb_locale: kb_locale) }
|
||||||
|
let!(:footer) { create(:knowledge_base_menu_item, :for_footer, kb_locale: kb_locale) }
|
||||||
|
|
||||||
|
it 'header' do
|
||||||
|
expect(scope.location_header).to match [kb_menu_item, header]
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'prefix is added to hostname' do
|
it 'footer' do
|
||||||
expect(kb_menu_item.url).to start_with 'http://'
|
expect(scope.location_footer).to match [footer]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when url with custom prefix is added' do
|
context 'when url' do
|
||||||
before { kb_menu_item.update(url: "scheme://#{Faker::Internet.domain_name}") }
|
context 'without prefix is added' do
|
||||||
|
before { kb_menu_item.update(url: Faker::Internet.domain_name) }
|
||||||
|
|
||||||
it 'is saved' do
|
it 'is saved' do
|
||||||
expect(kb_menu_item).not_to be_changed
|
expect(kb_menu_item).not_to be_changed
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'prefix is added to hostname' do
|
||||||
|
expect(kb_menu_item.url).to start_with 'http://'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'given scheme is not touched' do
|
context 'with custom prefix is added' do
|
||||||
expect(kb_menu_item.url).to start_with 'scheme://'
|
before { kb_menu_item.update(url: "scheme://#{Faker::Internet.domain_name}") }
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'protocol prefix is not added to relative url' do
|
it 'is saved' do
|
||||||
before { kb_menu_item.update(url: '/loremipsum') }
|
expect(kb_menu_item).not_to be_changed
|
||||||
|
end
|
||||||
|
|
||||||
it 'is saved' do
|
it 'given scheme is not touched' do
|
||||||
expect(kb_menu_item).not_to be_changed
|
expect(kb_menu_item.url).to start_with 'scheme://'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'path is not modified' do
|
context 'is relative and protocol prefix is not added' do
|
||||||
expect(kb_menu_item.url).not_to start_with 'http://'
|
before { kb_menu_item.update(url: '/loremipsum') }
|
||||||
|
|
||||||
|
it 'is saved' do
|
||||||
|
expect(kb_menu_item).not_to be_changed
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'path is not modified' do
|
||||||
|
expect(kb_menu_item.url).not_to start_with 'http://'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
67
spec/requests/admin/knowledge_base/public_menu_spec.rb
Normal file
67
spec/requests/admin/knowledge_base/public_menu_spec.rb
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Admin Knowledge Base Public Menu', type: :request, authenticated_as: :admin_user do
|
||||||
|
let(:url) { "/api/v1/knowledge_bases/manage/#{knowledge_base.id}/update_menu_items" }
|
||||||
|
let(:params) do
|
||||||
|
{
|
||||||
|
menu_items_sets: [{
|
||||||
|
"kb_locale_id": kb_locale.id,
|
||||||
|
"location": location,
|
||||||
|
"menu_items": menu_items
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:menu_item) { create(:knowledge_base_menu_item) }
|
||||||
|
let(:kb_locale) { menu_item.kb_locale }
|
||||||
|
let(:knowledge_base) { kb_locale.knowledge_base }
|
||||||
|
let(:location) { 'header' }
|
||||||
|
|
||||||
|
it 'edit title' do
|
||||||
|
attrs = to_params(menu_item)
|
||||||
|
attrs[:title] = 'new title'
|
||||||
|
|
||||||
|
params = build_params([attrs])
|
||||||
|
|
||||||
|
expect { make_request(params) }.to change { menu_item.reload.title }.to 'new title'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'delete item' do
|
||||||
|
attrs = to_params(menu_item)
|
||||||
|
attrs[:_destroy] = true
|
||||||
|
|
||||||
|
params = build_params([attrs])
|
||||||
|
|
||||||
|
expect { make_request(params) }.to change { KnowledgeBase::MenuItem.count }.by(-1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'add item' do
|
||||||
|
new_item = {
|
||||||
|
title: 'new item',
|
||||||
|
new_tab: false,
|
||||||
|
url: '/new_url'
|
||||||
|
}
|
||||||
|
|
||||||
|
params = build_params([to_params(menu_item), new_item])
|
||||||
|
|
||||||
|
expect { make_request(params) }.to change { KnowledgeBase::MenuItem.count }.by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_params(item)
|
||||||
|
item.slice :id, :title, :url, :new_tab
|
||||||
|
end
|
||||||
|
|
||||||
|
def make_request(params)
|
||||||
|
patch url, params: params, as: :json
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_params(menu_items)
|
||||||
|
{
|
||||||
|
menu_items_sets: [{
|
||||||
|
"kb_locale_id": kb_locale.id,
|
||||||
|
"location": location,
|
||||||
|
"menu_items": menu_items
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
|
@ -53,6 +53,27 @@ module DbMigrationHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Helper method for setting up specs on DB migrations that add columns.
|
||||||
|
# Make sure to define type: :db_migration in your RSpec.describe call
|
||||||
|
# and add `self.use_transactional_tests = false` to your context.
|
||||||
|
#
|
||||||
|
# @param [Symbol] from_table the name of the table with the indexed column
|
||||||
|
# @param [Symbol] name(s) of indexed column(s)
|
||||||
|
#
|
||||||
|
# @example
|
||||||
|
# without_column(:online_notifications, column: :user_id)
|
||||||
|
#
|
||||||
|
# @return [nil]
|
||||||
|
def without_column(from_table, column:)
|
||||||
|
suppress_messages do
|
||||||
|
Array(column).each do |elem|
|
||||||
|
next unless column_exists?(from_table, elem)
|
||||||
|
|
||||||
|
remove_column(from_table, elem)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Helper method for setting up specs on DB migrations that add indices.
|
# Helper method for setting up specs on DB migrations that add indices.
|
||||||
# Make sure to define type: :db_migration in your RSpec.describe call
|
# Make sure to define type: :db_migration in your RSpec.describe call
|
||||||
# and add `self.use_transactional_tests = false` to your context.
|
# and add `self.use_transactional_tests = false` to your context.
|
||||||
|
|
|
@ -35,3 +35,11 @@ RSpec.shared_context 'basic Knowledge Base', current_user_id: 1 do
|
||||||
create(:knowledge_base_answer, category: category, archived_at: 1.week.ago)
|
create(:knowledge_base_answer, category: category, archived_at: 1.week.ago)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
RSpec.shared_context 'Knowledge Base menu items', current_user_id: 1 do
|
||||||
|
let!(:menu_item_1) { create(:knowledge_base_menu_item, :for_header, kb_locale: primary_locale) }
|
||||||
|
let!(:menu_item_2) { create(:knowledge_base_menu_item, :for_header, kb_locale: primary_locale) }
|
||||||
|
let!(:menu_item_3) { create(:knowledge_base_menu_item, :for_footer, kb_locale: primary_locale) }
|
||||||
|
let!(:menu_item_4) { create(:knowledge_base_menu_item, :for_footer, kb_locale: alternative_locale) }
|
||||||
|
let!(:menu_item_5) { create(:knowledge_base_menu_item, :for_footer, kb_locale: alternative_locale) }
|
||||||
|
end
|
||||||
|
|
74
spec/system/admin/knowledge_base/public_menu_spec.rb
Normal file
74
spec/system/admin/knowledge_base/public_menu_spec.rb
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
# https://github.com/zammad/zammad/issues/266
|
||||||
|
RSpec.describe 'Admin Panel > Knowledge Base > Public Menu', type: :system, authenticated: true do
|
||||||
|
include_context 'basic Knowledge Base'
|
||||||
|
include_context 'Knowledge Base menu items'
|
||||||
|
|
||||||
|
before do
|
||||||
|
visit '/#manage/knowledge_base'
|
||||||
|
find('a', text: 'Public Menu').click
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'lists menu items' do
|
||||||
|
it { expect(find_locale('Footer menu', alternative_locale).text).to include menu_item_4.title }
|
||||||
|
it { expect(find_locale('Header menu', primary_locale).text).to include menu_item_1.title }
|
||||||
|
it { expect(find_locale('Header menu', alternative_locale).text).not_to include menu_item_2.title }
|
||||||
|
it { expect(find_locale('Header menu', primary_locale).text).to include menu_item_2.title }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'edit menu items' do
|
||||||
|
before do
|
||||||
|
find_location('Header menu').find('a', text: 'Edit').click
|
||||||
|
|
||||||
|
modal_ready
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'edit menu item' do
|
||||||
|
find('input') { |elem| elem.value == menu_item_1.title }.fill_in with: 'test menu'
|
||||||
|
find('button', text: 'Submit').click
|
||||||
|
|
||||||
|
modal_disappear
|
||||||
|
|
||||||
|
expect(find_locale('Header menu', primary_locale).text).to include 'test menu'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'adds menu item' do
|
||||||
|
container = find(:css, '.modal-body h2', text: alternative_locale.system_locale.name).find(:xpath, '..')
|
||||||
|
container.find('a', text: 'Add').click
|
||||||
|
|
||||||
|
container.find('input') { |elem| elem['data-name'] == 'title' }.fill_in with: 'new item'
|
||||||
|
container.find('input') { |elem| elem['data-name'] == 'url' }.fill_in with: '/new_item'
|
||||||
|
|
||||||
|
find('button', text: 'Submit').click
|
||||||
|
|
||||||
|
modal_disappear
|
||||||
|
|
||||||
|
expect(find_locale('Header menu', alternative_locale).text).to include 'new item'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'deletes menu item' do
|
||||||
|
find(:css, '.modal-body')
|
||||||
|
.find('input') { |elem| elem.value == menu_item_1.title }
|
||||||
|
.ancestor('tr')
|
||||||
|
.find('.js-remove')
|
||||||
|
.click
|
||||||
|
|
||||||
|
find('button', text: 'Submit').click
|
||||||
|
|
||||||
|
modal_disappear
|
||||||
|
|
||||||
|
expect(find_locale('Header menu', alternative_locale).text).not_to include menu_item_1.title
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_locale(location, locale)
|
||||||
|
find_location(location)
|
||||||
|
.find('.label', text: /#{Regexp.escape locale.system_locale.name}/i)
|
||||||
|
.ancestor('.kb-menu-preview')
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_location(location)
|
||||||
|
find('h3', text: location).ancestor('.settings-entry')
|
||||||
|
end
|
||||||
|
end
|
38
spec/system/knowledge_base_public/menu_items_spec.rb
Normal file
38
spec/system/knowledge_base_public/menu_items_spec.rb
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Public Knowledge Base menu items', type: :system, authenticated: false do
|
||||||
|
include_context 'basic Knowledge Base'
|
||||||
|
include_context 'Knowledge Base menu items'
|
||||||
|
|
||||||
|
before do
|
||||||
|
published_answer
|
||||||
|
|
||||||
|
visit help_no_locale_path
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'shows header public link' do
|
||||||
|
expect(page).to have_css('header .menu-item', text: menu_item_1.title)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'shows another header public link' do
|
||||||
|
expect(page).to have_css('header .menu-item', text: menu_item_2.title)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't show footer link in header" do
|
||||||
|
expect(page).not_to have_css('header .menu-item', text: menu_item_3.title)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'shows footer public link' do
|
||||||
|
expect(page).to have_css('footer .menu-item', text: menu_item_3.title)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't show footer link of another locale" do
|
||||||
|
expect(page).not_to have_css('footer .menu-item', text: menu_item_4.title)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'shows public links in given order' do
|
||||||
|
index_1 = page.body.index menu_item_1.title
|
||||||
|
index_2 = page.body.index menu_item_2.title
|
||||||
|
expect(index_1).to be < index_2
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in a new issue