mirror of
https://github.com/scinote-eln/scinote-web.git
synced 2025-09-07 21:55:20 +08:00
Implement task cloning in experiments table [SCI-7382] (#4653)
* Implement task cloning in experiments table [SCI-7382] * Fix provisioning status polling [SCI-7382] * Remove unused method [SCI-7382] * Fix linter issues [SCI-7382] * Fix fetching last clone number [SCI-7382] * Fixing experiment duplication [SCI-7382] * Add truncation to cloned name [SCI-7382] * Add readable scope to batch clone action [SCI-7382] * Move 'Clone' to translations, simplify JS [SCI-7382]
This commit is contained in:
parent
a84d228e0f
commit
4664ef1d9b
13 changed files with 183 additions and 29 deletions
|
@ -21,14 +21,18 @@ var ExperimnetTable = {
|
||||||
},
|
},
|
||||||
appendRows: function(result) {
|
appendRows: function(result) {
|
||||||
$.each(result, (id, data) => {
|
$.each(result, (id, data) => {
|
||||||
|
let row;
|
||||||
|
|
||||||
// Checkbox selector
|
// Checkbox selector
|
||||||
let row = `
|
row = `
|
||||||
<div class="table-body-cell">
|
<div class="table-body-cell">
|
||||||
<div class="sci-checkbox-container">
|
<div class="sci-checkbox-container">
|
||||||
<input type="checkbox" class="sci-checkbox my-module-selector" data-my-module="${id}">
|
<div class="loading-overlay"></div>
|
||||||
<span class="sci-checkbox-label"></span>
|
<input type="checkbox" class="sci-checkbox my-module-selector" data-my-module="${id}">
|
||||||
</div>
|
<span class="sci-checkbox-label"></span>
|
||||||
</div>`;
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
// Task columns
|
// Task columns
|
||||||
$.each(data.columns, (_i, cell) => {
|
$.each(data.columns, (_i, cell) => {
|
||||||
let hidden = '';
|
let hidden = '';
|
||||||
|
@ -53,7 +57,9 @@ var ExperimnetTable = {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
$(`<div class="table-row" data-urls='${JSON.stringify(data.urls)}' data-id="${id}">${row}</div>`)
|
|
||||||
|
let tableRowClass = `table-row ${data.provisioning_status === 'in_progress' ? 'table-row-provisioning' : ''}`;
|
||||||
|
$(`<div class="${tableRowClass}" data-urls='${JSON.stringify(data.urls)}' data-id="${id}">${row}</div>`)
|
||||||
.appendTo(`${this.table} .table-body`);
|
.appendTo(`${this.table} .table-body`);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -71,6 +77,15 @@ var ExperimnetTable = {
|
||||||
this.archiveMyModules(e.target.href, e.target.dataset.id);
|
this.archiveMyModules(e.target.href, e.target.dataset.id);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
initDuplicateMyModules: function() {
|
||||||
|
$('#duplicateTasks').on('click', (e) => {
|
||||||
|
$.post(e.target.dataset.url, { my_module_ids: this.selectedMyModules }, () => {
|
||||||
|
this.loadTable();
|
||||||
|
}).error((data) => {
|
||||||
|
HelperModule.flashAlertMsg(data.responseJSON.message, 'danger');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
initArchiveMyModules: function() {
|
initArchiveMyModules: function() {
|
||||||
$('#archiveTask').on('click', (e) => {
|
$('#archiveTask').on('click', (e) => {
|
||||||
this.archiveMyModules(e.target.dataset.url, this.selectedMyModules);
|
this.archiveMyModules(e.target.dataset.url, this.selectedMyModules);
|
||||||
|
@ -391,8 +406,43 @@ var ExperimnetTable = {
|
||||||
return { ...params, ...{ filters: this.activeFilters } };
|
return { ...params, ...{ filters: this.activeFilters } };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.initProvisioningStatusPolling();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
initProvisioningStatusPolling: function() {
|
||||||
|
let provisioningStatusUrls = $('.table-row-provisioning').toArray()
|
||||||
|
.map((u) => $(u).data('urls').provisioning_status);
|
||||||
|
|
||||||
|
this.provisioningMyModulesCount = provisioningStatusUrls.length;
|
||||||
|
|
||||||
|
if (this.provisioningMyModulesCount > 0) this.pollProvisioningStatuses(provisioningStatusUrls);
|
||||||
|
},
|
||||||
|
pollProvisioningStatuses: function(provisioningStatusUrls) {
|
||||||
|
let remainingUrls = [];
|
||||||
|
|
||||||
|
provisioningStatusUrls.forEach((url) => {
|
||||||
|
jQuery.ajax({
|
||||||
|
url: url,
|
||||||
|
success: (data) => {
|
||||||
|
if (data.provisioning_status === 'in_progress') remainingUrls.push(url);
|
||||||
|
},
|
||||||
|
async: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (remainingUrls.length > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.pollProvisioningStatuses(remainingUrls);
|
||||||
|
}, 10000);
|
||||||
|
} else {
|
||||||
|
HelperModule.flashAlertMsg(
|
||||||
|
I18n.t('experiments.duplicate_tasks.success', { count: this.provisioningMyModulesCount }),
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
this.loadTable();
|
||||||
|
}
|
||||||
|
},
|
||||||
init: function() {
|
init: function() {
|
||||||
this.initSelector();
|
this.initSelector();
|
||||||
this.initSelectAllCheckbox();
|
this.initSelectAllCheckbox();
|
||||||
|
@ -400,6 +450,7 @@ var ExperimnetTable = {
|
||||||
this.loadTable();
|
this.loadTable();
|
||||||
this.initRenameModal();
|
this.initRenameModal();
|
||||||
this.initAccessModal();
|
this.initAccessModal();
|
||||||
|
this.initDuplicateMyModules();
|
||||||
this.initMoveModulesModal();
|
this.initMoveModulesModal();
|
||||||
this.initArchiveMyModules();
|
this.initArchiveMyModules();
|
||||||
this.initManageColumnsModal();
|
this.initManageColumnsModal();
|
||||||
|
@ -411,6 +462,10 @@ var ExperimnetTable = {
|
||||||
};
|
};
|
||||||
|
|
||||||
ExperimnetTable.render.task_name = function(data) {
|
ExperimnetTable.render.task_name = function(data) {
|
||||||
|
if (data.provisioning_status === 'in_progress') {
|
||||||
|
return `<span data-full-name="${data.name}">${data.name}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
return `<a href="${data.url}" id="taskName${data.id}" data-full-name="${data.name}">${data.name}</a>`;
|
return `<a href="${data.url}" id="taskName${data.id}" data-full-name="${data.name}">${data.name}</a>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -69,6 +69,31 @@
|
||||||
display: contents;
|
display: contents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-row-provisioning {
|
||||||
|
.loading-overlay {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sci-checkbox-container {
|
||||||
|
height: 1.5em;
|
||||||
|
width: 1.5em;
|
||||||
|
|
||||||
|
.loading-overlay::after {
|
||||||
|
background-size: 1.5em;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sci-checkbox,
|
||||||
|
.sci-checkbox-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.table-body-cell {
|
.table-body-cell {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -387,6 +412,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task_name-column span {
|
||||||
|
color: $color-silver-chalice;
|
||||||
|
}
|
||||||
|
|
||||||
.table-display-modal {
|
.table-display-modal {
|
||||||
.column-container {
|
.column-container {
|
||||||
|
|
|
@ -12,7 +12,7 @@ class ExperimentsController < ApplicationController
|
||||||
before_action :check_read_permissions, except: %i(edit archive clone move new create archive_group restore_group)
|
before_action :check_read_permissions, except: %i(edit archive clone move new create archive_group restore_group)
|
||||||
before_action :check_canvas_read_permissions, only: %i(canvas)
|
before_action :check_canvas_read_permissions, only: %i(canvas)
|
||||||
before_action :check_create_permissions, only: %i(new create)
|
before_action :check_create_permissions, only: %i(new create)
|
||||||
before_action :check_manage_permissions, only: %i(edit)
|
before_action :check_manage_permissions, only: %i(edit batch_clone_my_modules)
|
||||||
before_action :check_update_permissions, only: %i(update)
|
before_action :check_update_permissions, only: %i(update)
|
||||||
before_action :check_archive_permissions, only: :archive
|
before_action :check_archive_permissions, only: :archive
|
||||||
before_action :check_clone_permissions, only: %i(clone_modal clone)
|
before_action :check_clone_permissions, only: %i(clone_modal clone)
|
||||||
|
@ -402,6 +402,32 @@ class ExperimentsController < ApplicationController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def batch_clone_my_modules
|
||||||
|
MyModule.transaction do
|
||||||
|
@my_modules =
|
||||||
|
@experiment.my_modules
|
||||||
|
.readable_by_user(current_user)
|
||||||
|
.where(id: params[:my_module_ids])
|
||||||
|
|
||||||
|
@my_modules.find_each do |my_module|
|
||||||
|
new_my_module = my_module.dup
|
||||||
|
new_my_module.update!(
|
||||||
|
{
|
||||||
|
provisioning_status: :in_progress,
|
||||||
|
name: my_module.next_clone_name
|
||||||
|
}.merge(new_my_module.get_new_position)
|
||||||
|
)
|
||||||
|
MyModules::CopyContentJob.perform_later(current_user, my_module.id, new_my_module.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render(
|
||||||
|
json: {
|
||||||
|
provisioning_status_urls: @my_modules.map { |m| provisioning_status_my_module_url(m) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def load_experiment
|
def load_experiment
|
||||||
|
|
|
@ -407,6 +407,10 @@ class MyModulesController < ApplicationController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def provisioning_status
|
||||||
|
render json: { provisioning_status: @my_module.provisioning_status }
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def load_vars
|
def load_vars
|
||||||
|
|
19
app/models/concerns/cloneable.rb
Normal file
19
app/models/concerns/cloneable.rb
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Cloneable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
def next_clone_name
|
||||||
|
raise NotImplementedError, "Cloneable model must implement the '.parent' method!" unless respond_to?(:parent)
|
||||||
|
|
||||||
|
clone_label = I18n.t('general.clone_label')
|
||||||
|
last_clone_number =
|
||||||
|
parent.public_send(self.class.table_name)
|
||||||
|
.select("substring(#{self.class.table_name}.name, '(?:^#{clone_label} )(\\d+)')::int AS clone_number")
|
||||||
|
.where('name ~ ?', "^#{clone_label} \\d+ - #{name}$")
|
||||||
|
.order(clone_number: :asc)
|
||||||
|
.last&.clone_number
|
||||||
|
|
||||||
|
"#{clone_label} #{(last_clone_number || 0) + 1} - #{name}".truncate(Constants::NAME_MAX_LENGTH)
|
||||||
|
end
|
||||||
|
end
|
|
@ -11,6 +11,7 @@ class Experiment < ApplicationRecord
|
||||||
include SearchableByNameModel
|
include SearchableByNameModel
|
||||||
include PermissionCheckableModel
|
include PermissionCheckableModel
|
||||||
include Assignable
|
include Assignable
|
||||||
|
include Cloneable
|
||||||
|
|
||||||
before_save -> { report_elements.destroy_all }, if: -> { !new_record? && project_id_changed? }
|
before_save -> { report_elements.destroy_all }, if: -> { !new_record? && project_id_changed? }
|
||||||
|
|
||||||
|
@ -224,6 +225,10 @@ class Experiment < ApplicationRecord
|
||||||
.with_granted_permissions(current_user, ProjectPermissions::EXPERIMENTS_CREATE)
|
.with_granted_permissions(current_user, ProjectPermissions::EXPERIMENTS_CREATE)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def parent
|
||||||
|
project
|
||||||
|
end
|
||||||
|
|
||||||
def permission_parent
|
def permission_parent
|
||||||
project
|
project
|
||||||
end
|
end
|
||||||
|
|
|
@ -9,10 +9,12 @@ class MyModule < ApplicationRecord
|
||||||
include TinyMceImages
|
include TinyMceImages
|
||||||
include PermissionCheckableModel
|
include PermissionCheckableModel
|
||||||
include Assignable
|
include Assignable
|
||||||
|
include Cloneable
|
||||||
|
|
||||||
attr_accessor :transition_error_rollback
|
attr_accessor :transition_error_rollback
|
||||||
|
|
||||||
enum state: Extends::TASKS_STATES
|
enum state: Extends::TASKS_STATES
|
||||||
|
enum provisioning_status: { done: 0, in_progress: 1, failed: 2 }
|
||||||
|
|
||||||
before_validation :archiving_and_restoring_extras, on: :update, if: :archived_changed?
|
before_validation :archiving_and_restoring_extras, on: :update, if: :archived_changed?
|
||||||
before_save -> { report_elements.destroy_all }, if: -> { !new_record? && experiment_id_changed? }
|
before_save -> { report_elements.destroy_all }, if: -> { !new_record? && experiment_id_changed? }
|
||||||
|
@ -124,6 +126,10 @@ class MyModule < ApplicationRecord
|
||||||
joins(experiment: :project).where(experiment: { projects: { team: teams } })
|
joins(experiment: :project).where(experiment: { projects: { team: teams } })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def parent
|
||||||
|
experiment
|
||||||
|
end
|
||||||
|
|
||||||
def navigable?
|
def navigable?
|
||||||
!experiment.archived? && experiment.navigable?
|
!experiment.archived? && experiment.navigable?
|
||||||
end
|
end
|
||||||
|
@ -381,25 +387,29 @@ class MyModule < ApplicationRecord
|
||||||
|
|
||||||
clone.assign_user(current_user)
|
clone.assign_user(current_user)
|
||||||
|
|
||||||
|
copy_content(current_user, clone)
|
||||||
|
|
||||||
|
clone
|
||||||
|
end
|
||||||
|
|
||||||
|
def copy_content(current_user, target_my_module)
|
||||||
# Remove the automatically generated protocol,
|
# Remove the automatically generated protocol,
|
||||||
# & clone the protocol instead
|
# & clone the protocol instead
|
||||||
clone.protocol.destroy
|
target_my_module.protocol.destroy
|
||||||
clone.reload
|
target_my_module.reload
|
||||||
|
|
||||||
# Update the cloned protocol if neccesary
|
# Update the cloned protocol if neccesary
|
||||||
clone_tinymce_assets(clone, clone.experiment.project.team)
|
clone_tinymce_assets(clone, target_my_module.experiment.project.team)
|
||||||
clone.protocols << protocol.deep_clone_my_module(self, current_user)
|
target_my_module.protocols << protocol.deep_clone_my_module(self, current_user)
|
||||||
clone.reload
|
target_my_module.reload
|
||||||
|
|
||||||
# fixes linked protocols
|
# fixes linked protocols
|
||||||
clone.protocols.each do |protocol|
|
target_my_module.protocols.each do |protocol|
|
||||||
next unless protocol.linked?
|
next unless protocol.linked?
|
||||||
|
|
||||||
protocol.updated_at = protocol.parent_updated_at
|
protocol.updated_at = protocol.parent_updated_at
|
||||||
protocol.save
|
protocol.save
|
||||||
end
|
end
|
||||||
|
|
||||||
clone
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find an empty position for the restored module. It's
|
# Find an empty position for the restored module. It's
|
||||||
|
|
|
@ -21,7 +21,7 @@ module Experiments
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@c_exp = Experiment.new(
|
@c_exp = Experiment.new(
|
||||||
name: find_uniq_name,
|
name: @exp.next_clone_name,
|
||||||
description: @exp.description,
|
description: @exp.description,
|
||||||
created_by: @user,
|
created_by: @user,
|
||||||
last_modified_by: @user,
|
last_modified_by: @user,
|
||||||
|
@ -54,15 +54,6 @@ module Experiments
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def find_uniq_name
|
|
||||||
experiment_names = @project.experiments.map(&:name)
|
|
||||||
format = 'Clone %d - %s'
|
|
||||||
free_index = 1
|
|
||||||
free_index += 1 while experiment_names
|
|
||||||
.include?(format(format, free_index, @exp.name))
|
|
||||||
format(format, free_index, @exp.name).truncate(Constants::NAME_MAX_LENGTH)
|
|
||||||
end
|
|
||||||
|
|
||||||
def valid?
|
def valid?
|
||||||
unless @exp && @project && @user
|
unless @exp && @project && @user
|
||||||
@errors[:invalid_arguments] =
|
@errors[:invalid_arguments] =
|
||||||
|
|
|
@ -66,10 +66,13 @@ module Experiments
|
||||||
|
|
||||||
result[my_module.id] = {
|
result[my_module.id] = {
|
||||||
columns: prepared_my_module,
|
columns: prepared_my_module,
|
||||||
|
provisioning_status: my_module.provisioning_status,
|
||||||
urls: {
|
urls: {
|
||||||
permissions: permissions_my_module_path(my_module),
|
permissions: permissions_my_module_path(my_module),
|
||||||
actions_dropdown: actions_dropdown_my_module_path(my_module),
|
actions_dropdown: actions_dropdown_my_module_path(my_module),
|
||||||
name_update: my_module_path(my_module),
|
name_update: my_module_path(my_module),
|
||||||
|
provisioning_status:
|
||||||
|
my_module.provisioning_status == 'in_progress' && provisioning_status_my_module_url(my_module),
|
||||||
access: edit_access_permissions_project_experiment_my_module_path(project, experiment, my_module)
|
access: edit_access_permissions_project_experiment_my_module_path(project, experiment, my_module)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -87,6 +90,7 @@ module Experiments
|
||||||
{
|
{
|
||||||
id: my_module.id,
|
id: my_module.id,
|
||||||
name: my_module.name,
|
name: my_module.name,
|
||||||
|
provisioning_status: my_module.provisioning_status,
|
||||||
url: protocols_my_module_path(my_module)
|
url: protocols_my_module_path(my_module)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
@ -179,7 +183,7 @@ module Experiments
|
||||||
end
|
end
|
||||||
|
|
||||||
def statuses_filter(my_modules, value)
|
def statuses_filter(my_modules, value)
|
||||||
my_modules.where('my_module_status_id IN (?)', value)
|
my_modules.where(my_module_status_id: value)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
<%= t("experiments.table.toolbar.edit") %>
|
<%= t("experiments.table.toolbar.edit") %>
|
||||||
</button>
|
</button>
|
||||||
<% if can_manage_experiment?(@experiment) %>
|
<% if can_manage_experiment?(@experiment) %>
|
||||||
<button id="duplicateTask" class="btn btn-light multiple-object-action hidden only-active">
|
<button id="duplicateTasks" class="btn btn-light multiple-object-action hidden only-active" data-url="<%= batch_clone_my_modules_experiment_path(@experiment) %>">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
<%= t("experiments.table.toolbar.duplicate") %>
|
<%= t("experiments.table.toolbar.duplicate") %>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -1282,6 +1282,8 @@ en:
|
||||||
success_flash: 'Successfully duplicated experiment %{experiment} as template.'
|
success_flash: 'Successfully duplicated experiment %{experiment} as template.'
|
||||||
error_flash: 'Could not duplicate the experiment as template.'
|
error_flash: 'Could not duplicate the experiment as template.'
|
||||||
current_project: '(current project)'
|
current_project: '(current project)'
|
||||||
|
duplicate_tasks:
|
||||||
|
success: 'Successfully duplicated %{count} task(s) as template.'
|
||||||
move:
|
move:
|
||||||
modal_title: 'Move experiment %{experiment}'
|
modal_title: 'Move experiment %{experiment}'
|
||||||
notice: 'Moving is possible to projects, where you have permissions to create experiments and tasks.'
|
notice: 'Moving is possible to projects, where you have permissions to create experiments and tasks.'
|
||||||
|
@ -3200,6 +3202,7 @@ en:
|
||||||
create: 'Create'
|
create: 'Create'
|
||||||
change: "Change"
|
change: "Change"
|
||||||
remove: "Remove"
|
remove: "Remove"
|
||||||
|
clone_label: "Clone"
|
||||||
# In order to use the strings 'yes' and 'no' as keys, you need to wrap them with quotes
|
# In order to use the strings 'yes' and 'no' as keys, you need to wrap them with quotes
|
||||||
'yes': "Yes"
|
'yes': "Yes"
|
||||||
'no': "No"
|
'no': "No"
|
||||||
|
|
|
@ -381,6 +381,7 @@ Rails.application.routes.draw do
|
||||||
get 'sidebar'
|
get 'sidebar'
|
||||||
get :assigned_users_to_tasks
|
get :assigned_users_to_tasks
|
||||||
post :archive_my_modules
|
post :archive_my_modules
|
||||||
|
post :batch_clone_my_modules
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -390,6 +391,7 @@ Rails.application.routes.draw do
|
||||||
member do
|
member do
|
||||||
get :permissions
|
get :permissions
|
||||||
get :actions_dropdown
|
get :actions_dropdown
|
||||||
|
get :provisioning_status
|
||||||
end
|
end
|
||||||
resources :my_module_tags, path: '/tags', only: [:index, :create, :destroy] do
|
resources :my_module_tags, path: '/tags', only: [:index, :create, :destroy] do
|
||||||
collection do
|
collection do
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddProvisioningStatusToMyModules < ActiveRecord::Migration[6.1]
|
||||||
|
def change
|
||||||
|
add_column :my_modules, :provisioning_status, :integer
|
||||||
|
end
|
||||||
|
end
|
Loading…
Add table
Reference in a new issue