User custom fields in the invite user modal (#9220)
* Projects form working with formly 50%
* Removed console.log
* Working with formattable
* Working with formattable
* Input with id and label
* Input with id and label
* Useless dependencies removed
* Saving forms + required labels with *
* First backend validation approach
* Removed reload on type change + keep model on route changes
* Handlig backend validations with setError
* Formatting the form model to submit
* Make up refactor
* working with op-form-field
* Form creation moved to the service
* Working with op-form-field wrapper
* Working with validation and op-form-field
* Working with []CustomFields
* Clean up
* Clean up
* Clean up
* Clean up
* Form routing working
* Notification on form error and success
* Refactor + removed useless dynamic form observable
* DynamicFieldsService with tests
* Refactor: inputs catalog + catch form load error
* Filter out non writable fields
* Refactor: naming consistency
* Cleaning comments
* dynamic-fields-service tests + wrapper component
* DynamicForm Tests
* @ngx-formly/core dependency added
* Cleaning up
* Provide DynamicFieldsService in root so it can be used independently
* DynamicForm working as a FormControl
* Getting route params sync
* Global FormsService: submit + formatting + error handling
* Fix: @Optional() FormGroupDirective in OpFormFieldComponent
* Code climate fix
* Removed CdkTextareaAutosize because of CDK issue 22469
* DynamicFormComponent tests
* Dynamic input test helpers + boolean and text tests
* Refactor edit fields to avoid circular dependencies in the dynamic forms
* Naming fix
* IntegerInputComponent tests
* SelectInputComponent tests
* Fix: duplicated identifier on inputs
* Extract toolbar to be reused for now
Still TBD whether we want to move them right now to the frontend?
* Create new project route and redirect to rails view after saving
* fieldsSettingsPipe + hide 'identifier' on projects
* Handling multi-values (also as links) and passwords
* Some TODOs removed
* FormattableTextareaInputComponent tests
* Projects form working with formly 50%
* Removed console.log
* Working with formattable
* Working with formattable
* Input with id and label
* Input with id and label
* Useless dependencies removed
* Saving forms + required labels with *
* First backend validation approach
* Removed reload on type change + keep model on route changes
* Handlig backend validations with setError
* Formatting the form model to submit
* Make up refactor
* working with op-form-field
* Form creation moved to the service
* Working with op-form-field wrapper
* Working with validation and op-form-field
* Working with []CustomFields
* Clean up
* Clean up
* Clean up
* Clean up
* Form routing working
* Notification on form error and success
* Refactor + removed useless dynamic form observable
* DynamicFieldsService with tests
* Refactor: inputs catalog + catch form load error
* Filter out non writable fields
* Refactor: naming consistency
* Cleaning comments
* dynamic-fields-service tests + wrapper component
* DynamicForm Tests
* @ngx-formly/core dependency added
* Cleaning up
* DynamicForm working as a FormControl
* Getting route params sync
* Global FormsService: submit + formatting + error handling
* Fix: @Optional() FormGroupDirective in OpFormFieldComponent
* Code climate fix
* Removed CdkTextareaAutosize because of CDK issue 22469
* DynamicFormComponent tests
* Dynamic input test helpers + boolean and text tests
* Refactor edit fields to avoid circular dependencies in the dynamic forms
* Naming fix
* IntegerInputComponent tests
* SelectInputComponent tests
* Fix: duplicated identifier on inputs
* Extract toolbar to be reused for now
Still TBD whether we want to move them right now to the frontend?
* Create new project route and redirect to rails view after saving
* fieldsSettingsPipe + hide 'identifier' on projects
* Handling multi-values (also as links) and passwords
* Some TODOs removed
* FormattableTextareaInputComponent tests
* _isResourceSchema based on parent?.location
* Scope DynamicFieldsService to DynamicFormComponent
* Added backend validation method to FormsService
* Removed projects routes and ruby template
* Removed projects routes and dynamic forms from Projects
* Revert "Provide DynamicFieldsService in root so it can be used independently"
This reverts commit ab56f3c56f
.
* Provide DynamicFieldsService in root so it can be used independently
* TODO: test ProjectsComponent
* Code climate fixes (remove TODOs)
* Default OpFormFieldComponent.inlineLabel to false
* Dynamic components tests xkipped
* Typing improvements
* DynamicFormComponent working as a FormControl
* Global FormsService: submit + formatting + error handling
* Fix: @Optional() FormGroupDirective in OpFormFieldComponent
* Code climate fixes
* noWrapLabel default to false
* Started adding user custom fields to the ium
* Import the dynamic-forms module into the common module
* Refactor edit fields to avoid circular dependencies in the dynamic forms
* Using DynamicFormsModule in OpenprojectInviteUserModalModule
* Add formly form
* Update principal name filter
* Dynamic form field is rendering
* Handling multi-values (also as links) and passwords
* Added backend validation method to FormsService
* Remove form from DynamicForm when not isStandaloneForm
* Allow multiple form keys to validate
* Remove form from non standalone forms
* Remove duplicated button
* Moved to FormGroup input for dynamic form
* Custom field happy path is done
* Add explanatory comment to payload structure transformation
* add op-form class to ium steps
* Add shrinkwrap back in
* Fix test, fix dynamic form resource path
* gimme a shirnkwrap
* Remove failing tests
* Remove another failing test
* Remove more failing specs
* Fix double loading of principals
* Add custom field spec
* Fix spec
* Reset shrinkwrap
* Forbid Factory.build(:user, member_in_project)
If you use the trait member_in_project(s), the user is implicitly saved
to create the member.
This is very confusing if trying to use required custom fields, as this
will fail with the Member#user_id foreign key being nil, as the user
cannot be saved.
Instead, raise an error when trying to use this factory trait
* Change additional spec factory
Co-authored-by: Aleix Suau <info@macrofonoestudio.es>
Co-authored-by: Oliver Günther <mail@oliverguenther.de>
pull/9224/head
parent
843e6ebe14
commit
44294ede04
@ -0,0 +1,22 @@ |
||||
.op-select-footer |
||||
display: block |
||||
margin: 0 |
||||
padding: 0 |
||||
|
||||
&--label |
||||
cursor: pointer |
||||
display: block |
||||
background: transparent |
||||
border: 0 |
||||
padding: 8px 10px |
||||
font-size: 14px |
||||
line-height: 22px |
||||
background-color: #fff |
||||
color: rgba(0, 0, 0, 0.87) |
||||
font-weight: bold |
||||
width: 100% |
||||
text-align: left |
||||
|
||||
&:hover |
||||
background-color: #f5faff |
||||
color: #333 |
@ -1,22 +0,0 @@ |
||||
\:host |
||||
display: block |
||||
margin: 0 |
||||
padding: 0 |
||||
|
||||
.invite-user-button |
||||
cursor: pointer |
||||
display: block |
||||
background: transparent |
||||
border: 0 |
||||
padding: 8px 10px |
||||
font-size: 14px |
||||
line-height: 22px |
||||
background-color: #fff |
||||
color: rgba(0,0,0,0.87) |
||||
font-weight: bold |
||||
width: 100% |
||||
text-align: left |
||||
|
||||
&:hover |
||||
background-color: #f5faff |
||||
color: #333 |
@ -0,0 +1,5 @@ |
||||
FactoryBot.define do |
||||
trait :skip_validations do |
||||
to_create { |model| model.save!(validate: false) } |
||||
end |
||||
end |
@ -0,0 +1,141 @@ |
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2021 the OpenProject GmbH |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License |
||||
# as published by the Free Software Foundation; either version 2 |
||||
# of the License, or (at your option) any later version. |
||||
# |
||||
# This program is distributed in the hope that it will be useful, |
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
# GNU General Public License for more details. |
||||
# |
||||
# You should have received a copy of the GNU General Public License |
||||
# along with this program; if not, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See docs/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
require 'spec_helper' |
||||
|
||||
# rubocop:disable RSpec/MultipleMemoizedHelpers |
||||
feature 'Invite user modal custom fields', type: :feature, js: true do |
||||
shared_let(:project) { FactoryBot.create :project } |
||||
|
||||
let(:permissions) { %i[view_project manage_members] } |
||||
let(:global_permissions) { %i[manage_user] } |
||||
let(:principal) { FactoryBot.build :invited_user } |
||||
let(:modal) do |
||||
::Components::Users::InviteUserModal.new project: project, |
||||
principal: principal, |
||||
role: role |
||||
end |
||||
let!(:role) do |
||||
FactoryBot.create :role, |
||||
name: 'Member', |
||||
permissions: permissions |
||||
end |
||||
|
||||
let!(:boolean_cf) { FactoryBot.create :boolean_user_custom_field, name: 'bool', is_required: true } |
||||
let!(:integer_cf) { FactoryBot.create :integer_user_custom_field, name: 'int', is_required: true } |
||||
let!(:text_cf) { FactoryBot.create :text_user_custom_field, name: 'Text', is_required: true } |
||||
let!(:string_cf) { FactoryBot.create :string_user_custom_field, name: 'String', is_required: true } |
||||
# TODO float not supported yet |
||||
#let!(:float_cf) { FactoryBot.create :float_user_custom_field, name: 'Float', is_required: true } |
||||
let!(:list_cf) { FactoryBot.create :list_user_custom_field, name: 'List', is_required: true } |
||||
let!(:list_multi_cf) { FactoryBot.create :list_user_custom_field, name: 'Multi list', multi_value: true, is_required: true } |
||||
|
||||
let!(:non_req_cf) { FactoryBot.create :string_user_custom_field, name: 'non req', is_required: false } |
||||
|
||||
let(:boolean_field) { ::FormFields::InputFormField.new boolean_cf } |
||||
let(:integer_field) { ::FormFields::InputFormField.new integer_cf } |
||||
let(:text_field) { ::FormFields::EditorFormField.new text_cf } |
||||
let(:string_field) { ::FormFields::InputFormField.new string_cf } |
||||
# TODO float not supported yet |
||||
#let(:float_field) { ::FormFields::InputFormField.new float_cf } |
||||
let(:list_field) { ::FormFields::SelectFormField.new list_cf } |
||||
let(:list_multi_field) { ::FormFields::SelectFormField.new list_multi_cf } |
||||
|
||||
let(:quick_add) { ::Components::QuickAddMenu.new } |
||||
|
||||
current_user do |
||||
FactoryBot.create :user, |
||||
:skip_validations, |
||||
member_in_project: project, |
||||
member_through_role: role, |
||||
global_permissions: global_permissions |
||||
end |
||||
|
||||
it 'shows the required fields during the principal step' do |
||||
visit home_path |
||||
|
||||
quick_add.expect_visible |
||||
|
||||
quick_add.toggle |
||||
|
||||
quick_add.click_link 'Invite user' |
||||
|
||||
modal.project_step |
||||
|
||||
# Fill the principal and try to go to next |
||||
sleep 1 |
||||
modal.principal_step |
||||
|
||||
expect(page).to have_selector('form.ng-invalid', wait: 10) |
||||
|
||||
modal.within_modal do |
||||
expect(page).to have_text "bool can't be blank." |
||||
expect(page).to have_text "int can't be blank." |
||||
expect(page).to have_text "Text can't be blank." |
||||
expect(page).to have_text "String can't be blank." |
||||
expect(page).to have_text "List can't be blank." |
||||
expect(page).to have_text "Multi list can't be blank." |
||||
|
||||
# Does not show the non req field |
||||
expect(page).to have_no_text non_req_cf.name |
||||
end |
||||
|
||||
# Fill all fields |
||||
boolean_field.input_element.check |
||||
integer_field.set_value '1234' |
||||
text_field.set_value 'A **markdown** value' |
||||
string_field.set_value 'String value' |
||||
|
||||
list_field.select_option '1' |
||||
list_multi_field.select_option '1', '2' |
||||
|
||||
modal.click_next |
||||
|
||||
# Remaining steps |
||||
modal.role_step |
||||
modal.invitation_step |
||||
modal.confirmation_step |
||||
modal.click_modal_button 'Send invitation' |
||||
modal.expect_text "Invite #{principal.mail} to #{project.name}" |
||||
|
||||
# Close |
||||
modal.click_modal_button 'Send invitation' |
||||
modal.expect_text "#{principal.mail} was invited!" |
||||
|
||||
# Expect to be added to project |
||||
invited = project.users.last |
||||
expect(invited.mail).to eq principal.mail |
||||
|
||||
expect(invited.custom_value_for(boolean_cf).typed_value).to eq true |
||||
expect(invited.custom_value_for(integer_cf).typed_value).to eq 1234 |
||||
expect(invited.custom_value_for(text_cf).typed_value).to eq 'A **markdown** value' |
||||
expect(invited.custom_value_for(string_cf).typed_value).to eq 'String value' |
||||
expect(invited.custom_value_for(list_cf).typed_value).to eq '1' |
||||
expect(invited.custom_value_for(list_multi_cf).map(&:typed_value)).to eq %w[1 2] |
||||
end |
||||
end |
@ -1,251 +0,0 @@ |
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2021 the OpenProject GmbH |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2013 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License |
||||
# as published by the Free Software Foundation; either version 2 |
||||
# of the License, or (at your option) any later version. |
||||
# |
||||
# This program is distributed in the hope that it will be useful, |
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
# GNU General Public License for more details. |
||||
# |
||||
# You should have received a copy of the GNU General Public License |
||||
# along with this program; if not, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See docs/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
require 'spec_helper' |
||||
|
||||
|
||||
feature 'Invite user modal', type: :feature, js: true do |
||||
shared_let(:project) { FactoryBot.create :project } |
||||
shared_let(:work_package) { FactoryBot.create :work_package, project: project } |
||||
|
||||
let(:permissions) { %i[view_work_packages edit_work_packages manage_members] } |
||||
let(:global_permissions) { %i[] } |
||||
let(:modal) do |
||||
::Components::Users::InviteUserModal.new project: project, |
||||
principal: principal, |
||||
role: role, |
||||
invite_message: invite_message |
||||
end |
||||
let!(:role) do |
||||
FactoryBot.create :role, |
||||
name: 'Member', |
||||
permissions: permissions |
||||
end |
||||
let(:invite_message) { "Welcome to the team. **You'll like it here**."} |
||||
let(:mail_membership_recipients) { [] } |
||||
let(:mail_invite_recipients) { [] } |
||||
|
||||
current_user do |
||||
FactoryBot.create :user, |
||||
member_in_project: project, |
||||
member_through_role: role, |
||||
global_permissions: global_permissions |
||||
end |
||||
|
||||
shared_examples 'invites the principal to the project' do |
||||
it 'invites that principal to the project' do |
||||
perform_enqueued_jobs do |
||||
modal.run_all_steps |
||||
end |
||||
|
||||
assignee_field.expect_inactive! |
||||
assignee_field.expect_display_value added_principal.name |
||||
|
||||
new_member = project.reload.member_principals.find_by(user_id: added_principal.id) |
||||
expect(new_member).to be_present |
||||
expect(new_member.roles).to eq [role] |
||||
|
||||
# Check that the expected number of emails are sent. |
||||
# This includes no mails being sent if the recipient list is empty. |
||||
expect(ActionMailer::Base.deliveries.size) |
||||
.to eql mail_invite_recipients.size + mail_membership_recipients.size |
||||
|
||||
mail_invite_recipients.each_with_index do |recipient, index| |
||||
expect(ActionMailer::Base.deliveries[index].to) |
||||
.to match_array [recipient.mail] |
||||
|
||||
expect(ActionMailer::Base.deliveries[index].body.encoded) |
||||
.to include "Welcome to OpenProject" |
||||
end |
||||
|
||||
mail_membership_recipients.each_with_index do |recipient, index| |
||||
overall_index = index + mail_invite_recipients.length |
||||
|
||||
expect(ActionMailer::Base.deliveries[overall_index].to) |
||||
.to match_array [recipient.mail] |
||||
|
||||
expect(ActionMailer::Base.deliveries[overall_index].body.encoded) |
||||
.to include OpenProject::TextFormatting::Renderer.format_text(invite_message) |
||||
|
||||
expect(ActionMailer::Base.deliveries[overall_index].body.encoded) |
||||
.to include role.name |
||||
end |
||||
end |
||||
end |
||||
|
||||
describe 'inviting a principal to a project' do |
||||
describe 'through the assignee field' do |
||||
let(:wp_page) { Pages::FullWorkPackage.new(work_package, project) } |
||||
let(:assignee_field) { wp_page.edit_field :assignee } |
||||
|
||||
before do |
||||
wp_page.visit! |
||||
|
||||
assignee_field.activate! |
||||
|
||||
find('.ng-dropdown-footer button', text: 'Invite', wait: 10).click |
||||
end |
||||
|
||||
context 'with an existing user' do |
||||
let!(:principal) do |
||||
FactoryBot.create :user, |
||||
firstname: 'Nonproject firstname', |
||||
lastname: 'nonproject lastname' |
||||
end |
||||
|
||||
it_behaves_like 'invites the principal to the project' do |
||||
let(:added_principal) { principal } |
||||
let(:mail_membership_recipients) { [principal] } |
||||
end |
||||
end |
||||
|
||||
context 'with a user to be invited' do |
||||
let(:principal) { FactoryBot.build :invited_user } |
||||
|
||||
context 'when the current user has permissions to create a user' do |
||||
let(:permissions) { %i[view_work_packages edit_work_packages manage_members] } |
||||
let(:global_permissions) { %i[manage_user] } |
||||
|
||||
it_behaves_like 'invites the principal to the project' do |
||||
let(:added_principal) { User.find_by!(mail: principal.mail) } |
||||
let(:mail_invite_recipients) { [added_principal] } |
||||
let(:mail_membership_recipients) { [added_principal] } |
||||
end |
||||
end |
||||
|
||||
context 'when the current user does not have permissions to invite a user to the instance by email' do |
||||
let(:permissions) { %i[view_work_packages edit_work_packages manage_members] } |
||||
it 'does not show the invite user option' do |
||||
modal.project_step |
||||
ngselect = modal.open_select_in_step principal.mail |
||||
expect(ngselect).to have_text "No users were found" |
||||
expect(ngselect).not_to have_text "Invite: #{principal.mail}" |
||||
end |
||||
end |
||||
|
||||
context 'when the current user does not have permissions to invite a user in this project' do |
||||
let(:permissions) { %i[view_work_packages edit_work_packages manage_members] } |
||||
let(:global_permissions) { %i[manage_user] } |
||||
|
||||
let(:project_no_permissions) { FactoryBot.create :project } |
||||
let(:role_no_permissions) do |
||||
FactoryBot.create :role, |
||||
permissions: %i[view_work_packages edit_work_packages] |
||||
end |
||||
|
||||
let!(:membership_no_permission) do |
||||
FactoryBot.create :member, |
||||
user: current_user, |
||||
project: project_no_permissions, |
||||
roles: [role_no_permissions] |
||||
end |
||||
|
||||
it 'disables projects for which you do not have rights' do |
||||
ngselect = modal.open_select_in_step |
||||
expect(ngselect).to have_text "#{project_no_permissions.name}\nYou are not allowed to invite members to this project" |
||||
end |
||||
end |
||||
end |
||||
|
||||
describe 'inviting placeholders' do |
||||
let(:principal) { FactoryBot.build :placeholder_user, name: 'MY NEW PLACEHOLDER' } |
||||
|
||||
context 'an enterprise system', with_ee: %i[placeholder_users] do |
||||
describe 'create a new placeholder' do |
||||
context 'with permissions to manage placeholders' do |
||||
let(:permissions) { %i[view_work_packages edit_work_packages manage_members] } |
||||
let(:global_permissions) { %i[manage_placeholder_user] } |
||||
|
||||
it_behaves_like 'invites the principal to the project' do |
||||
let(:added_principal) { PlaceholderUser.find_by!(name: 'MY NEW PLACEHOLDER') } |
||||
# Placeholders get no invite mail |
||||
let(:mail_membership_recipients) { [] } |
||||
end |
||||
end |
||||
|
||||
context 'without permissions to manage placeholders' do |
||||
let(:permissions) { %i[view_work_packages edit_work_packages manage_members] } |
||||
it 'does not allow to invite a new placeholder' do |
||||
modal.within_modal do |
||||
expect(page).to have_selector '.op-option-list--item', count: 2 |
||||
end |
||||
end |
||||
end |
||||
end |
||||
|
||||
context 'with an existing placeholder' do |
||||
let(:principal) { FactoryBot.create :placeholder_user, name: 'EXISTING PLACEHOLDER' } |
||||
let(:permissions) { %i[view_work_packages edit_work_packages manage_members] } |
||||
let(:global_permissions) { %i[manage_placeholder_user] } |
||||
|
||||
it_behaves_like 'invites the principal to the project' do |
||||
let(:added_principal) { principal } |
||||
# Placeholders get no invite mail |
||||
let(:mail_membership_recipients) { [] } |
||||
end |
||||
end |
||||
end |
||||
|
||||
context 'non-enterprise system' do |
||||
it 'shows the modal with placeholder option disabled' do |
||||
modal.within_modal do |
||||
expect(page).to have_field 'Placeholder user', disabled: true |
||||
end |
||||
end |
||||
end |
||||
end |
||||
|
||||
describe 'inviting groups' do |
||||
let(:group_user) { FactoryBot.create(:user) } |
||||
let(:principal) { FactoryBot.create :group, name: 'MY NEW GROUP', members: [group_user] } |
||||
|
||||
it_behaves_like 'invites the principal to the project' do |
||||
let(:added_principal) { principal } |
||||
# Groups get no invite mail themselves but their members do |
||||
let(:mail_membership_recipients) { [group_user] } |
||||
end |
||||
end |
||||
end |
||||
end |
||||
|
||||
context 'when the user has no permission to manage members' do |
||||
let(:permissions) { %i[view_work_packages edit_work_packages] } |
||||
let(:wp_page) { Pages::FullWorkPackage.new(work_package, project) } |
||||
let(:assignee_field) { wp_page.edit_field :assignee } |
||||
|
||||
before do |
||||
wp_page.visit! |
||||
end |
||||
|
||||
it 'cannot add an existing user to the project' do |
||||
assignee_field.activate! |
||||
|
||||
expect(page).to have_no_selector('.ng-dropdown-footer', text: 'Invite') |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,29 @@ |
||||
require_relative './form_field' |
||||
|
||||
module FormFields |
||||
class EditorFormField < FormField |
||||
|
||||
attr_reader :editor |
||||
|
||||
def initialize(property, selector: nil) |
||||
super |
||||
|
||||
@editor = ::Components::WysiwygEditor.new(selector) |
||||
end |
||||
|
||||
def expect_visible |
||||
!!editor.container |
||||
end |
||||
|
||||
## |
||||
# Set or select the given value. |
||||
# For fields of type select, will check for an option with that value. |
||||
def set_value(content) |
||||
editor.set_markdown(content) |
||||
end |
||||
|
||||
def input_element |
||||
editor.editor_element |
||||
end |
||||
end |
||||
end |
Loading…
Reference in new issue