mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
Merge pull request #20593 from opf/feature/primerize-invitation-dialog
[#64879] Primerise user invitation flow
This commit is contained in:
@@ -93,7 +93,7 @@
|
||||
classes: class_names("op-enterprise-banner--close-icon", classes),
|
||||
scheme: :invisible,
|
||||
tag: :a,
|
||||
href: dismiss_enterprise_banner_path(feature_key: @dismiss_key),
|
||||
href: dismiss_enterprise_banner_path(feature_key: @feature_key, dismiss_key: @dismiss_key),
|
||||
data: { turbo_stream: true, turbo_method: :post },
|
||||
icon: :x,
|
||||
aria: { label: t("ee.upsell.hide_banner") }
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<%=
|
||||
render(
|
||||
Primer::Alpha::Dialog.new(
|
||||
id: DIALOG_ID,
|
||||
title: I18n.t(:label_invite_user),
|
||||
size: :medium_portrait,
|
||||
data: { "keep-open-on-submit": true }
|
||||
)
|
||||
) do |dialog|
|
||||
dialog.with_header(variant: :large)
|
||||
dialog.with_body(classes: "Overlay-body_autocomplete_height") do
|
||||
component_collection do |body|
|
||||
if @project && !User.current.allowed_in_project?(:manage_members, @project)
|
||||
body.with_component(
|
||||
render(Primer::Alpha::Banner.new(mb: 2, scheme: :warning)) { I18n.t("users.invite_user_modal.project.no_invite_rights") }
|
||||
)
|
||||
end
|
||||
body.with_component(
|
||||
render(Users::Invitation::ProjectStep::FormComponent.new(model))
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
dialog.with_footer do
|
||||
render Users::Invitation::ProjectStep::FooterComponent.new
|
||||
end
|
||||
end
|
||||
%>
|
||||
@@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
module Users::Invitation
|
||||
class DialogComponent < ApplicationComponent
|
||||
DIALOG_ID = "user-invitation-dialog"
|
||||
FORM_ID = "user-invitation-dialog-form"
|
||||
FOOTER_ID = "user-invitation-dialog-footer"
|
||||
|
||||
include ApplicationHelper
|
||||
include OpenProject::FormTagHelper
|
||||
include OpTurbo::Streamable
|
||||
include OpPrimer::ComponentHelpers
|
||||
|
||||
def initialize(model, project: nil)
|
||||
super
|
||||
@project = project
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
<%=
|
||||
component_wrapper(style: "flex: 1") do
|
||||
flex_layout(align_items: :stretch) do |modal_footer|
|
||||
modal_footer.with_column(flex: 1) do
|
||||
retain_params = model.to_h.slice(:project_id, :principal_type)
|
||||
render(
|
||||
Primer::Beta::Button.new(
|
||||
tag: :a,
|
||||
href: invite_step_users_path(step: :initial, user_invitation: retain_params),
|
||||
data: {
|
||||
turbo_method: :post
|
||||
}
|
||||
)) do
|
||||
I18n.t(:button_back)
|
||||
end
|
||||
end
|
||||
|
||||
modal_footer.with_column do
|
||||
component_collection do |buttons|
|
||||
buttons.with_component(
|
||||
Primer::Beta::Button.new(
|
||||
data: { "close-dialog-id": Users::Invitation::DialogComponent::DIALOG_ID },
|
||||
)
|
||||
) do
|
||||
I18n.t(:button_cancel)
|
||||
end
|
||||
|
||||
buttons.with_component(
|
||||
Primer::Beta::Button.new(
|
||||
scheme: :primary,
|
||||
form: Users::Invitation::DialogComponent::FORM_ID,
|
||||
type: :submit
|
||||
)
|
||||
) do
|
||||
I18n.t("users.invite_user_modal.invite")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
%>
|
||||
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
module Users::Invitation::PrincipalStep
|
||||
class FooterComponent < ApplicationComponent
|
||||
include ApplicationHelper
|
||||
include OpenProject::FormTagHelper
|
||||
include OpTurbo::Streamable
|
||||
include OpPrimer::ComponentHelpers
|
||||
|
||||
def wrapper_key
|
||||
Users::Invitation::DialogComponent::FOOTER_ID
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
<%=
|
||||
component_wrapper do
|
||||
primer_form_with(
|
||||
scope: :user_invitation,
|
||||
model:,
|
||||
method: :post,
|
||||
url: { controller: "/users/invite", action: :step, step: :principal },
|
||||
html: {
|
||||
data: { turbo_stream: true },
|
||||
id: Users::Invitation::DialogComponent::FORM_ID,
|
||||
},
|
||||
) do |f|
|
||||
flex_layout(mb: 3) do |modal_body|
|
||||
if model.errors[:base].present?
|
||||
modal_body.with_row do
|
||||
render(Primer::Alpha::Banner.new(mb: 3, icon: :stop, scheme: :danger)) { model.errors[:base].join("\n") }
|
||||
end
|
||||
end
|
||||
|
||||
modal_body.with_row do
|
||||
render(Users::Invitation::PrincipalStep::Form.new(f))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
%>
|
||||
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
module Users::Invitation::PrincipalStep
|
||||
class FormComponent < ApplicationComponent
|
||||
include ApplicationHelper
|
||||
include OpenProject::FormTagHelper
|
||||
include OpTurbo::Streamable
|
||||
include OpPrimer::ComponentHelpers
|
||||
|
||||
def wrapper_key
|
||||
"user_invitation_form_component"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,66 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
module Users::Invitation::ProjectStep
|
||||
class FooterComponent < ApplicationComponent
|
||||
include ApplicationHelper
|
||||
include OpenProject::FormTagHelper
|
||||
include OpTurbo::Streamable
|
||||
include OpPrimer::ComponentHelpers
|
||||
|
||||
def wrapper_key
|
||||
Users::Invitation::DialogComponent::FOOTER_ID
|
||||
end
|
||||
|
||||
def call
|
||||
component_wrapper do
|
||||
component_collection do |modal_footer|
|
||||
modal_footer.with_component(
|
||||
Primer::Beta::Button.new(
|
||||
data: { "close-dialog-id": Users::Invitation::DialogComponent::DIALOG_ID }
|
||||
)
|
||||
) do
|
||||
I18n.t(:button_cancel)
|
||||
end
|
||||
|
||||
modal_footer.with_component(
|
||||
Primer::Beta::Button.new(
|
||||
scheme: :primary,
|
||||
form: Users::Invitation::DialogComponent::FORM_ID,
|
||||
type: :submit
|
||||
)
|
||||
) do
|
||||
I18n.t(:button_continue)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
<%=
|
||||
component_wrapper do
|
||||
primer_form_with(
|
||||
scope: :user_invitation,
|
||||
model:,
|
||||
method: :post,
|
||||
url: { controller: "/users/invite", action: :step, step: :project },
|
||||
html: {
|
||||
data: { turbo_stream: true },
|
||||
id: Users::Invitation::DialogComponent::FORM_ID,
|
||||
},
|
||||
) do |f|
|
||||
flex_layout(mb: 3) do |modal_body|
|
||||
if model.errors[:base].present?
|
||||
modal_body.with_row do
|
||||
render(Primer::Alpha::Banner.new(mb: 3, icon: :stop, scheme: :danger)) { model.errors[:base].join("\n") }
|
||||
end
|
||||
end
|
||||
|
||||
modal_body.with_row do
|
||||
render(Users::Invitation::ProjectStep::Form.new(f))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
%>
|
||||
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
module Users::Invitation::ProjectStep
|
||||
class FormComponent < ApplicationComponent
|
||||
include ApplicationHelper
|
||||
include OpenProject::FormTagHelper
|
||||
include OpTurbo::Streamable
|
||||
include OpPrimer::ComponentHelpers
|
||||
|
||||
def wrapper_key
|
||||
"user_invitation_form_component"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -53,21 +53,24 @@ module MemberHelper
|
||||
invite_new_users possibly_separated_ids_for_entity(member_params, :user), send_notification: @send_notification
|
||||
end
|
||||
|
||||
def invite_new_user(id, send_notification: true) # rubocop:disable Metrics/PerceivedComplexity
|
||||
if id.present? && (id.to_i == 0 || EmailValidator.valid?(id)) # we've got an email - invite that user
|
||||
# Only users with the create_user permission can add users.
|
||||
if current_user.allowed_globally?(:create_user) && enterprise_allow_new_users?
|
||||
# The invitation can pretty much only fail due to the user already
|
||||
# having been invited. So look them up if it does.
|
||||
user = UserInvitation.invite_new_user(email: id, send_notification:) || User.find_by_mail(id) # rubocop:disable Rails/DynamicFindBy
|
||||
|
||||
user&.id
|
||||
end
|
||||
else
|
||||
id
|
||||
end
|
||||
end
|
||||
|
||||
def invite_new_users(user_ids, send_notification: true)
|
||||
user_ids.filter_map do |id|
|
||||
if id.present? && (id.to_i == 0 || EmailValidator.valid?(id)) # we've got an email - invite that user
|
||||
# Only users with the create_user permission can add users.
|
||||
if current_user.allowed_globally?(:create_user) && enterprise_allow_new_users?
|
||||
# The invitation can pretty much only fail due to the user already
|
||||
# having been invited. So look them up if it does.
|
||||
user = UserInvitation.invite_new_user(email: id, send_notification:) ||
|
||||
User.find_by_mail(id)
|
||||
|
||||
user&.id
|
||||
end
|
||||
else
|
||||
id
|
||||
end
|
||||
invite_new_user(id, send_notification:)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -131,6 +131,14 @@ module OpTurbo
|
||||
.render_in(view_context)
|
||||
end
|
||||
|
||||
def update_dialog_title_via_turbo_stream(dialog_id, new_title:)
|
||||
turbo_streams << OpTurbo::StreamComponent
|
||||
.new(action: :update,
|
||||
target: "#{dialog_id}-title",
|
||||
template: new_title)
|
||||
.render_in(view_context)
|
||||
end
|
||||
|
||||
def reload_page_via_turbo_stream
|
||||
turbo_streams << OpTurbo::StreamComponent.new(action: :reloadPage, target: nil).render_in(view_context)
|
||||
end
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
|
||||
class MembersController < ApplicationController
|
||||
include MemberHelper
|
||||
|
||||
model_object Member
|
||||
before_action :find_model_object_and_project, except: %i[autocomplete_for_member destroy_by_principal]
|
||||
before_action :find_project_by_project_id, only: %i[autocomplete_for_member destroy_by_principal]
|
||||
@@ -101,11 +102,12 @@ class MembersController < ApplicationController
|
||||
end
|
||||
|
||||
def autocomplete_for_member
|
||||
@principals = possible_members(params[:q], 100)
|
||||
type = params[:type]
|
||||
@principals = possible_members(params[:q], 100, type:)
|
||||
|
||||
@email = suggest_invite_via_email? current_user,
|
||||
params[:q],
|
||||
(@principals | @project.principals)
|
||||
if type.nil? || type == "User"
|
||||
@email = suggest_invite_via_email?(current_user, params[:q], @principals | @project.principals)
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.json do
|
||||
@@ -200,9 +202,9 @@ class MembersController < ApplicationController
|
||||
@principals_available = possible_members("", 1)
|
||||
end
|
||||
|
||||
def possible_members(criteria, limit)
|
||||
def possible_members(criteria, limit, type: nil)
|
||||
Principal
|
||||
.possible_member(@project)
|
||||
.possible_member(@project, type:)
|
||||
.like(criteria, email: user_allowed_to_view_emails?)
|
||||
.limit(limit)
|
||||
end
|
||||
|
||||
@@ -63,10 +63,8 @@ class My::EnterpriseBannersController < ApplicationController
|
||||
private
|
||||
|
||||
def get_feature_key
|
||||
raw_key = params[:feature_key]
|
||||
|
||||
@dismiss_key = raw_key
|
||||
@feature_key = raw_key.gsub(/_trial$/, "").to_sym
|
||||
@feature_key = params[:feature_key].to_sym
|
||||
@dismiss_key = params[:dismiss_key].presence&.to_sym || @feature_key
|
||||
|
||||
render_400 unless OpenProject::Token.lowest_plan_for(@feature_key)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
class Users::InviteController < ApplicationController
|
||||
include OpTurbo::ComponentStream
|
||||
include MemberHelper
|
||||
|
||||
authorize_with_permission :manage_members, global: true
|
||||
before_action :set_project, only: :start_dialog
|
||||
|
||||
def start_dialog
|
||||
respond_with_dialog(
|
||||
Users::Invitation::DialogComponent.new(form_model, project: @project)
|
||||
)
|
||||
end
|
||||
|
||||
def step
|
||||
if form_model.valid?(validation_context) || params[:step] == "initial"
|
||||
respond_with_next_step
|
||||
else
|
||||
handle_errors_in_step
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_errors_in_step
|
||||
case params[:step]
|
||||
when "project"
|
||||
replace_via_turbo_stream(component: Users::Invitation::ProjectStep::FormComponent.new(form_model))
|
||||
respond_with_turbo_streams
|
||||
when "principal"
|
||||
replace_via_turbo_stream(component: Users::Invitation::PrincipalStep::FormComponent.new(form_model))
|
||||
respond_with_turbo_streams
|
||||
else
|
||||
render_400 message: "Invalid step"
|
||||
end
|
||||
end
|
||||
|
||||
def respond_with_next_step # rubocop:disable Metrics/AbcSize
|
||||
case params[:step]
|
||||
when "initial"
|
||||
update_dialog_title_via_turbo_stream(Users::Invitation::DialogComponent::DIALOG_ID,
|
||||
new_title: I18n.t("users.invite_user_modal.title.invite"))
|
||||
replace_via_turbo_stream(component: Users::Invitation::ProjectStep::FormComponent.new(form_model))
|
||||
replace_via_turbo_stream(component: Users::Invitation::ProjectStep::FooterComponent.new(form_model))
|
||||
respond_with_turbo_streams
|
||||
when "project"
|
||||
update_dialog_title_via_turbo_stream(Users::Invitation::DialogComponent::DIALOG_ID, new_title: dialog_title)
|
||||
replace_via_turbo_stream(component: Users::Invitation::PrincipalStep::FormComponent.new(form_model))
|
||||
replace_via_turbo_stream(component: Users::Invitation::PrincipalStep::FooterComponent.new(form_model))
|
||||
respond_with_turbo_streams
|
||||
when "principal"
|
||||
create_invitation
|
||||
else
|
||||
render_400 message: "Invalid step"
|
||||
end
|
||||
end
|
||||
|
||||
def create_invitation # rubocop:disable Metrics/AbcSize
|
||||
call = create_member_call
|
||||
|
||||
if call.success?
|
||||
render_success_flash_message_via_turbo_stream(
|
||||
message: I18n.t("users.invite_user_modal.success_message.#{form_model.principal_type.underscore}",
|
||||
project: form_model.project.name)
|
||||
)
|
||||
close_dialog_via_turbo_stream("##{Users::Invitation::DialogComponent::DIALOG_ID}",
|
||||
additional: { user_id: call.result.user_id })
|
||||
else
|
||||
replace_via_turbo_stream(component: Users::Invitation::PrincipalStep::FormComponent.new(form_model))
|
||||
end
|
||||
|
||||
respond_with_turbo_streams
|
||||
end
|
||||
|
||||
def create_member_call
|
||||
# The form validation worked, now is the time to invite the user
|
||||
invite_user!
|
||||
|
||||
Members::CreateService
|
||||
.new(user: current_user)
|
||||
.call(
|
||||
project_id: form_model.project_id,
|
||||
user_id: form_model.id_or_email,
|
||||
role_ids: [form_model.role_id],
|
||||
notification_message: form_model.message
|
||||
)
|
||||
end
|
||||
|
||||
def invite_user!
|
||||
# Invite new user by email if needed, or use existing user ID
|
||||
form_model.id_or_email = invite_new_user(form_model.id_or_email, send_notification: true)
|
||||
end
|
||||
|
||||
def validation_context
|
||||
if params[:step] == "project"
|
||||
:project_step
|
||||
else
|
||||
%i[project_step principal_step]
|
||||
end
|
||||
end
|
||||
|
||||
def form_model
|
||||
@form_model ||= Users::Invitation::FormModel.new(form_model_params).tap do |model|
|
||||
model.project = @project if @project && current_user.allowed_in_project?(:manage_members, @project)
|
||||
end
|
||||
end
|
||||
|
||||
def set_project
|
||||
@project = Project.find(params[:project_id]) if params[:project_id].present?
|
||||
end
|
||||
|
||||
def dialog_title
|
||||
I18n.t("users.invite_user_modal.type.#{form_model.principal_type.underscore}.title",
|
||||
project_name: form_model.project_name)
|
||||
end
|
||||
|
||||
def form_model_params
|
||||
return {} unless params[:user_invitation]
|
||||
|
||||
permitted_params.user_invitation
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,110 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
module Users::Invitation::PrincipalStep
|
||||
class Form < ApplicationForm
|
||||
include OpenProject::StaticRouting::UrlHelpers
|
||||
include Redmine::I18n
|
||||
|
||||
form do |f|
|
||||
f.hidden name: :project_id
|
||||
f.hidden name: :principal_type
|
||||
|
||||
f.autocompleter(
|
||||
name: :id_or_email,
|
||||
label: name_label,
|
||||
required: true,
|
||||
autocomplete_options: {
|
||||
defaultData: true,
|
||||
component: "opce-members-autocompleter",
|
||||
principalType: model.principal_type.underscore,
|
||||
model: selected_principal,
|
||||
url: autocomplete_for_member_project_members_path(model.project_id, format: :json, type: model.principal_type),
|
||||
focusDirectly: false,
|
||||
multiple: false,
|
||||
clearable: false,
|
||||
appendTo: "##{Users::Invitation::DialogComponent::DIALOG_ID}"
|
||||
}
|
||||
)
|
||||
|
||||
f.autocompleter(
|
||||
name: :role_id,
|
||||
required: true,
|
||||
include_blank: false,
|
||||
label: Role.model_name.human,
|
||||
caption: link_translate("users.invite_user_modal.role.description",
|
||||
links: { docs_url: %i[sysadmin_docs roles_and_permissions] }),
|
||||
autocomplete_options: {
|
||||
multiple: false,
|
||||
decorated: true,
|
||||
clearable: false,
|
||||
focusDirectly: false,
|
||||
appendTo: "##{Users::Invitation::DialogComponent::DIALOG_ID}"
|
||||
}
|
||||
) do |role_list|
|
||||
ProjectRole
|
||||
.givable
|
||||
.ordered_by_builtin_and_position
|
||||
.find_each do |role|
|
||||
role_list.option(label: role.name, value: role.id)
|
||||
end
|
||||
end
|
||||
|
||||
if model.principal_type != "PlaceholderUser"
|
||||
f.text_area(
|
||||
name: :message,
|
||||
label: I18n.t("users.invite_user_modal.message.label"),
|
||||
caption: I18n.t("users.invite_user_modal.message.description"),
|
||||
rows: 5,
|
||||
style: "resize: none"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def selected_principal # rubocop:disable Metrics/AbcSize
|
||||
return if model.id_or_email.blank?
|
||||
|
||||
if EmailValidator.valid?(model.id_or_email)
|
||||
{ name: I18n.t("members.invite_by_mail", mail: model.id_or_email), id: model.id_or_email }
|
||||
else
|
||||
principal = Principal.visible.find_by(id: model.id_or_email)
|
||||
{ name: principal&.name || "User #{id_or_email}", id: principal.id }
|
||||
end
|
||||
end
|
||||
|
||||
def name_label
|
||||
if model.principal_type == "User"
|
||||
I18n.t("activerecord.attributes.users/invitation/form_model.id_or_email")
|
||||
else
|
||||
User.human_attribute_name(:name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,91 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
module Users::Invitation::ProjectStep
|
||||
class Form < ApplicationForm
|
||||
form do |f|
|
||||
f.project_autocompleter(
|
||||
name: "project_id",
|
||||
id: "project_id",
|
||||
label: Project.model_name.human,
|
||||
required: true,
|
||||
autocomplete_options: {
|
||||
with_search_icon: true,
|
||||
openDirectly: false,
|
||||
focusDirectly: false,
|
||||
dropdownPosition: "bottom",
|
||||
placeholder: I18n.t("users.invite_user_modal.project.required"),
|
||||
appendTo: "##{Users::Invitation::DialogComponent::DIALOG_ID}",
|
||||
filters: [
|
||||
{ name: "active", operator: "=", values: ["t"] },
|
||||
{ name: "user_action", operator: "=", values: ["memberships/create"] }
|
||||
],
|
||||
data: {
|
||||
"test-selector": "project_id"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
f.radio_button_group(
|
||||
name: :principal_type,
|
||||
visually_hide_label: true
|
||||
) do |radio_group|
|
||||
radio_group.radio_button(
|
||||
value: "User",
|
||||
checked: model.principal_type.nil? || model.principal_type == "User",
|
||||
label: User.model_name.human,
|
||||
caption: I18n.t("users.invite_user_modal.type.user.description")
|
||||
)
|
||||
radio_group.radio_button(
|
||||
value: "Group",
|
||||
checked: model.principal_type == "Group",
|
||||
label: Group.model_name.human,
|
||||
caption: I18n.t("users.invite_user_modal.type.group.description")
|
||||
)
|
||||
|
||||
radio_group.radio_button(
|
||||
value: "PlaceholderUser",
|
||||
disabled: !EnterpriseToken.allows_to?(:placeholder_users),
|
||||
checked: model.principal_type == "PlaceholderUser",
|
||||
label: PlaceholderUser.model_name.human,
|
||||
caption: I18n.t("users.invite_user_modal.type.placeholder_user.description")
|
||||
)
|
||||
end
|
||||
|
||||
unless EnterpriseToken.allows_to?(:placeholder_users)
|
||||
f.html_content do
|
||||
render(EnterpriseEdition::BannerComponent.new(:placeholder_users,
|
||||
dismissable: true,
|
||||
dismiss_key: "invitation_placeholder_users"))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -225,6 +225,12 @@ class PermittedParams
|
||||
user additional_params
|
||||
end
|
||||
|
||||
def user_invitation
|
||||
params
|
||||
.require(:user_invitation)
|
||||
.permit(:project_id, :principal_type, :id_or_email, :role_id, :message)
|
||||
end
|
||||
|
||||
def type(args = {})
|
||||
permitted = permitted_attributes(:type, args)
|
||||
|
||||
|
||||
@@ -39,14 +39,18 @@ module Principals::Scopes
|
||||
# * Group
|
||||
# User instances need to be non locked (status)
|
||||
# Principals which already are project members are are returned.
|
||||
# @project [Project] The project for which eligible candidates are to be searched
|
||||
# @param [Project] project The project for which eligible candidates are to be searched
|
||||
# @param [String|nil] type The type of principals to be returned. One of 'User', 'Group', 'PlaceholderUser'.
|
||||
# @return [ActiveRecord::Relation] A scope of eligible candidates
|
||||
def possible_member(project)
|
||||
Queries::Principals::PrincipalQuery
|
||||
def possible_member(project, type: nil)
|
||||
query = Queries::Principals::PrincipalQuery
|
||||
.new(user: ::User.current)
|
||||
.where(:member, "!", [project.id])
|
||||
.where(:status, "!", [statuses[:locked]])
|
||||
.results
|
||||
|
||||
query.where(:type, "=", [type]) if type.present?
|
||||
|
||||
query.results
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+13
-2
@@ -208,6 +208,17 @@ class User < Principal
|
||||
end
|
||||
end
|
||||
|
||||
# Override acts_as_customizable to skip custom field validation for invited users
|
||||
# since custom field values cannot be provided during the invitation process.
|
||||
# We only skip the validation if no custom field changes are present.
|
||||
def custom_field_values_to_validate
|
||||
if invited? && custom_field_changes.empty?
|
||||
[]
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def self.search_in_project(query, options)
|
||||
options.fetch(:project).users.like(query)
|
||||
end
|
||||
@@ -559,8 +570,8 @@ class User < Principal
|
||||
end
|
||||
|
||||
def scim_emails=(emails)
|
||||
email = (emails.find { |email| email.primary == true }) ||
|
||||
(emails.find { |email| email.type == "work" }) ||
|
||||
email = emails.find { |email| email.primary == true } ||
|
||||
emails.find { |email| email.type == "work" } ||
|
||||
emails.min
|
||||
|
||||
self.mail = email&.value
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
#-- copyright
|
||||
# OpenProject is an open source project management software.
|
||||
# Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
|
||||
#++
|
||||
|
||||
module Users::Invitation
|
||||
class FormModel < ApplicationRecord
|
||||
include Tableless
|
||||
|
||||
belongs_to :project
|
||||
attribute :project_id, :integer, default: nil
|
||||
attribute :role_id, :integer, default: nil
|
||||
attribute :principal_type, :text, default: nil
|
||||
attribute :id_or_email, :text, default: nil
|
||||
attribute :message, :text, default: nil
|
||||
|
||||
validates :project_id, presence: true, on: :project_step
|
||||
validates :principal_type,
|
||||
inclusion: { in: ->(*) { available_principal_types } },
|
||||
on: :project_step
|
||||
|
||||
validates :id_or_email, presence: true, on: :principal_step
|
||||
validates :role_id, presence: true, on: :principal_step
|
||||
|
||||
def self.available_principal_types
|
||||
if EnterpriseToken.allows_to?(:placeholder_users)
|
||||
%w[User PlaceholderUser Group]
|
||||
else
|
||||
%w[User Group]
|
||||
end
|
||||
end
|
||||
|
||||
def project_name
|
||||
project&.name || project_id
|
||||
end
|
||||
|
||||
def to_h
|
||||
{
|
||||
project_id:,
|
||||
role_id:,
|
||||
principal_type:,
|
||||
id_or_email:,
|
||||
message:
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -112,12 +112,18 @@ Redmine::MenuManager.map :quick_add_menu do |menu|
|
||||
}
|
||||
|
||||
menu.push :invite_user,
|
||||
nil,
|
||||
->(project) {
|
||||
{ controller: "/users/invite", action: :start_dialog, project_id: project&.id }
|
||||
},
|
||||
caption: :label_invite_user,
|
||||
icon: "person-add",
|
||||
html: {
|
||||
"invite-user-modal-augment": "invite-user-modal-augment"
|
||||
target: nil,
|
||||
data: {
|
||||
turbo_stream: true
|
||||
}
|
||||
},
|
||||
skip_permissions_check: true, # Prevent project specific permission checks
|
||||
if: ->(_) { User.current.allowed_in_any_project?(:manage_members) }
|
||||
end
|
||||
|
||||
|
||||
+61
-1
@@ -380,7 +380,7 @@ en:
|
||||
|
||||
danger_dialog:
|
||||
confirmation_live_message_checked: "The button to proceed is now active."
|
||||
confirmation_live_message_unchecked: "The button to proceed is now inactive. You need to tick the checkbox to continue."
|
||||
confirmation_live_message_unchecked: "The button to proceed is now inactive. You need to tick the checkbox to continue."
|
||||
|
||||
op_dry_validation:
|
||||
or: "or"
|
||||
@@ -710,6 +710,63 @@ en:
|
||||
memberships:
|
||||
no_results_title_text: This user is currently not a member of a project.
|
||||
open_profile: "Open profile"
|
||||
invite_user_modal:
|
||||
invite: "Invite"
|
||||
title:
|
||||
invite: "Invite user"
|
||||
invite_to_project: "Invite %{type} to %{project}"
|
||||
invite_principal_to_project: "Invite %{principal} to %{project}"
|
||||
project:
|
||||
label: "Project"
|
||||
required: "Please select a project"
|
||||
next_button: "Next"
|
||||
no_results: "No projects were found"
|
||||
no_invite_rights: "You are not allowed to invite members to this project"
|
||||
type:
|
||||
required: "Please select the type to be invited"
|
||||
user:
|
||||
title: "Invite user to %{project_name}"
|
||||
description: "Permissions based on the assigned role in the selected project"
|
||||
group:
|
||||
title: "Invite group to %{project_name}"
|
||||
description: "Permissions based on the assigned role in the selected project"
|
||||
placeholder_user:
|
||||
title: "Add placeholder user to %{project_name}"
|
||||
title_no_ee: "Placeholder user (Enterprise edition only add-on)"
|
||||
description: "Has no access to the project and no emails are sent out."
|
||||
|
||||
already_member_message: "Already a member of %{project}"
|
||||
principal:
|
||||
no_results_user: "No users were found"
|
||||
invite_user: "Invite:"
|
||||
no_results_placeholder: "No placeholders were found"
|
||||
create_new_placeholder: "Create new placeholder:"
|
||||
no_results_group: "No groups were found"
|
||||
invite_to_project: "Invite to %{project_name}"
|
||||
required:
|
||||
user: "Please select a user"
|
||||
placeholder: "Please select a placeholder"
|
||||
group: "Please select a group"
|
||||
|
||||
role:
|
||||
label: "Role in %{project}"
|
||||
no_roles_found: "No roles were found"
|
||||
description: >
|
||||
This is the role that the user will receive when they join your project. The role defines which actions they are allowed to take and which information they are allowed to see.
|
||||
[Learn more about roles and permissions.](docs_url)
|
||||
required: "Please select a role"
|
||||
|
||||
message:
|
||||
label: "Invitation message"
|
||||
description: "We will send an email to the user, to which you can add a personal message here. An explanation for the invitation could be useful, or prehaps a bit of information regarding the project to help them get started."
|
||||
|
||||
summary:
|
||||
next_button: "Send invitation"
|
||||
|
||||
success_message:
|
||||
user: "The user can now log in to access %{project}. Meanwhile you can already plan with that user and assign work packages for instance."
|
||||
placeholder_user: "The placeholder can now be used in %{project}. Meanwhile you can already plan with that user and assign work packages for instance."
|
||||
group: "The group is now a part of %{project}. Meanwhile you can already plan with that group and assign work packages for instance."
|
||||
page:
|
||||
text: "Text"
|
||||
placeholder_users:
|
||||
@@ -1324,6 +1381,9 @@ en:
|
||||
mode_guideline: "Some modes will overwrite custom theme colors for accessibility and legibility. Please select Light mode for full custom theme support."
|
||||
daily_reminders: "Daily reminders"
|
||||
workdays: "Working days"
|
||||
users/invitation/form_model:
|
||||
principal_type: "Invitation type"
|
||||
id_or_email: "Name or email address"
|
||||
version:
|
||||
effective_date: "Finish date"
|
||||
sharing: "Sharing"
|
||||
|
||||
@@ -1210,82 +1210,8 @@ en:
|
||||
timeline: "Gantt"
|
||||
|
||||
invite_user_modal:
|
||||
back: "Back"
|
||||
invite: "Invite"
|
||||
title:
|
||||
invite: "Invite user"
|
||||
invite_to_project: "Invite %{type} to %{project}"
|
||||
User: "user"
|
||||
Group: "group"
|
||||
PlaceholderUser: "placeholder user"
|
||||
invite_principal_to_project: "Invite %{principal} to %{project}"
|
||||
project:
|
||||
label: "Project"
|
||||
required: "Please select a project"
|
||||
lacking_permission: "Please select a different project since you lack permissions to assign users to the currently selected."
|
||||
lacking_permission_info: "You lack the permission to assign users to the project you are currently in. You need to select a different one."
|
||||
next_button: "Next"
|
||||
no_results: "No projects were found"
|
||||
no_invite_rights: "You are not allowed to invite members to this project"
|
||||
type:
|
||||
required: "Please select the type to be invited"
|
||||
user:
|
||||
title: "User"
|
||||
description: "Permissions based on the assigned role in the selected project"
|
||||
group:
|
||||
title: "Group"
|
||||
description: "Permissions based on the assigned role in the selected project"
|
||||
placeholder:
|
||||
title: "Placeholder user"
|
||||
title_no_ee: "Placeholder user (Enterprise edition only add-on)"
|
||||
description: "Has no access to the project and no emails are sent out."
|
||||
description_no_ee: 'Has no access to the project and no emails are sent out.
|
||||
<br>Check out the <a href="%{eeHref}" target="_blank">Enterprise edition</a>'
|
||||
|
||||
principal:
|
||||
label:
|
||||
name_or_email: "Name or email address"
|
||||
name: "Name"
|
||||
already_member_message: "Already a member of %{project}"
|
||||
no_results_user: "No users were found"
|
||||
invite_user: "Invite:"
|
||||
no_results_placeholder: "No placeholders were found"
|
||||
create_new_placeholder: "Create new placeholder:"
|
||||
no_results_group: "No groups were found"
|
||||
next_button: "Next"
|
||||
required:
|
||||
user: "Please select a user"
|
||||
placeholder: "Please select a placeholder"
|
||||
group: "Please select a group"
|
||||
|
||||
role:
|
||||
label: "Role in %{project}"
|
||||
no_roles_found: "No roles were found"
|
||||
description: >-
|
||||
This is the role that the user will receive when they join your project. The role defines which actions they are allowed to take and which information they are allowed to see.
|
||||
<a
|
||||
href="https://www.openproject.org/docs/system-admin-guide/users-permissions/roles-permissions/#roles-and-permissions"
|
||||
target="_blank">
|
||||
Learn more about roles and permissions.
|
||||
</a>
|
||||
required: "Please select a role"
|
||||
next_button: "Next"
|
||||
|
||||
message:
|
||||
label: "Invitation message"
|
||||
description: "We will send an email to the user, to which you can add a personal message here. An explanation for the invitation could be useful, or prehaps a bit of information regarding the project to help them get started."
|
||||
next_button: "Next"
|
||||
|
||||
summary:
|
||||
next_button: "Send invitation"
|
||||
|
||||
success:
|
||||
title: "%{principal} was invited!"
|
||||
description:
|
||||
user: "The user can now log in to access %{project}. Meanwhile you can already plan with that user and assign work packages for instance."
|
||||
placeholder: "The placeholder can now be used in %{project}. Meanwhile you can already plan with that user and assign work packages for instance."
|
||||
group: "The group is now a part of %{project}. Meanwhile you can already plan with that group and assign work packages for instance."
|
||||
next_button: "Continue"
|
||||
placeholder_add_tag: "Create placeholder user"
|
||||
|
||||
exclusion_info:
|
||||
modal:
|
||||
|
||||
@@ -822,6 +822,11 @@ Rails.application.routes.draw do
|
||||
resources :users, constraints: { id: /(\d+|me)/ }, except: :edit do
|
||||
resources :memberships, controller: "users/memberships", only: %i[update create destroy]
|
||||
|
||||
collection do
|
||||
get "/invite" => "users/invite#start_dialog"
|
||||
post "/invite/step" => "users/invite#step"
|
||||
end
|
||||
|
||||
member do
|
||||
get "/hover_card" => "users/hover_card#show"
|
||||
get "/edit(/:tab)" => "users#edit", as: "edit"
|
||||
|
||||
@@ -171,6 +171,8 @@ storage_docs:
|
||||
troubleshooting:
|
||||
href: https://www.openproject.org/docs/user-guide/file-management/nextcloud-integration/#possible-errors-and-troubleshooting
|
||||
sysadmin_docs:
|
||||
roles_and_permissions:
|
||||
href: https://www.openproject.org/docs/system-admin-guide/users-permissions/roles-permissions/#roles-and-permissions
|
||||
ldap:
|
||||
href: https://www.openproject.org/docs/system-admin-guide/authentication/ldap-connections/
|
||||
oidc:
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
sidebar_navigation:
|
||||
title: Dynamically generated forms
|
||||
description: An introduction on how to generate forms from an API form object
|
||||
keywords: concept, forms, dynamic forms, schemas
|
||||
---
|
||||
|
||||
# Dynamically generated forms
|
||||
|
||||
Starting in OpenProject 11.3.0, the frontend application received a new mechanism to dynamically generate HTML forms from an APIv3 form response. The form response contains the model being changed/created and [a schema resource](../resource-schemas/) to describe the attributes of the resource.
|
||||
|
||||
## Key takeaways
|
||||
|
||||
Dynamic forms are wrappers around the APIv3 form and schema objects to render a full HTML form based on the attribute definitions returned by the API.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The following guides are related:
|
||||
|
||||
- [Schema resources](../resource-schemas/)
|
||||
|
||||
## API overview
|
||||
|
||||
Let us take a look at the new projects form API response to get an overview of how this works.
|
||||
|
||||
With an [access token or other means of API authentication](../../../api/introduction/#authentication), send a `POST` to `/api/v3/projects/form`. This will return a HAL form response that looks something like this:
|
||||
|
||||
```json5
|
||||
{
|
||||
"_type": "Form",
|
||||
"_embedded": {
|
||||
"payload": {
|
||||
"name": ""
|
||||
// ...
|
||||
},
|
||||
"schema": {
|
||||
"name": {
|
||||
"type": "String",
|
||||
"name": "Name",
|
||||
"required": true,
|
||||
"hasDefault": false,
|
||||
"writable": true,
|
||||
"minLength": 1,
|
||||
"maxLength": 255,
|
||||
"options": {}
|
||||
}
|
||||
// ...
|
||||
},
|
||||
"validationErrors": {
|
||||
"name": {
|
||||
"_type": "Error",
|
||||
"errorIdentifier": "urn:openproject-org:api:v3:errors:PropertyConstraintViolation",
|
||||
"message": "Name can't be blank.",
|
||||
"_embedded": {
|
||||
"details": {
|
||||
"attribute": "name"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"_links": {
|
||||
"validate": {
|
||||
"href": "/api/v3/projects/form",
|
||||
"method": "post"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The form has four important segments:
|
||||
|
||||
- **_embedded.payload**: This is the project being created. When you POST to the form, any payload you post will be applied to that project. It is not yet saved, but will transparently show you what the project _would_ look like if you saved it.
|
||||
- **_embedded.schema**: The schema describing the payload object. For every attribute and link of the project, there will be a definition in the schema, telling you what the attribute is and how it should be used. From it, the type of the input field and the available options will be derived.
|
||||
- **_embedded.validationErrors**: This object contains any references to attributes in the payload that are currently erroneous, and a human-readable message. In the example above, the required _name_ of the project is missing.
|
||||
- **_links**: The links section contains HAL links and actions on the form resource itself. For example, it has a `validate` link to validate any pending changes made by the user. If the form has no validation errors, it will show a `commit` link to save the changes to the database. As the example form is invalid, this link is not present.
|
||||
|
||||
## Frontend usage
|
||||
|
||||
Using the dynamic form is incredibly easy once you have a backend that provides a form resource with an embedded schema.
|
||||
|
||||
Take a look at the [ProjectSettingsComponent](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/projects/components/projects/projects.component.html) that renders the settings form. You can simply use the `<op-dynamic-form>` component to render the form and pass it the `formUrl` or `resourcePath` + `resourceId` inputs.
|
||||
|
||||
The dynamic form component will request the form for you, render the form, and handle any saving and validation.
|
||||
|
||||
In case of the projects component, there is a `fieldsSettingsPipe` that allows you to override parts of the rendering. For projects, [it is used](https://github.com/opf/openproject/blob/dev/frontend/src/app/features/projects/components/projects/projects.component.ts#L34-L44) hiding the `identifier` field of the project which is handled by the backend.
|
||||
|
||||
### Basic use case
|
||||
|
||||
For the most basic use case, simply place the `DynamicFormsModule` into your module imports and use the component as follows:
|
||||
|
||||
```html
|
||||
<op-dynamic-form formUrl="/api/v3/projects/:id/form"></op-dynamic-form>
|
||||
```
|
||||
Generated
-21
@@ -54,7 +54,6 @@
|
||||
"@ng-select/ng-option-highlight": "^20.4.1",
|
||||
"@ng-select/ng-select": "^20.1.0",
|
||||
"@ngneat/content-loader": "^7.0.0",
|
||||
"@ngx-formly/core": "^6.1.4",
|
||||
"@openproject/octicons-angular": "^19.29.0",
|
||||
"@openproject/primer-view-components": "^0.75.2",
|
||||
"@openproject/reactivestates": "^3.0.1",
|
||||
@@ -6026,18 +6025,6 @@
|
||||
"webpack": "^5.54.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ngx-formly/core": {
|
||||
"version": "6.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@ngx-formly/core/-/core-6.3.12.tgz",
|
||||
"integrity": "sha512-88MOfn9dM1B33t04jl8x0Glh0Ed0lUKMkhYajicRH7ZHTmwIdla1SQjiblp2C+EcCFvsY7XAU2/JUZQdl56aUw==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/forms": ">=13.2.0",
|
||||
"rxjs": "^6.5.3 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -29730,14 +29717,6 @@
|
||||
"integrity": "sha512-PM3ODWdiYmLfUueJR+jpffuX1qwM6kyEOg/SE9+kfSSyu9dRFt3k5LoAHAzH+gbs1JsvztmG/wfkE/ZlexteKQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@ngx-formly/core": {
|
||||
"version": "6.3.12",
|
||||
"resolved": "https://registry.npmjs.org/@ngx-formly/core/-/core-6.3.12.tgz",
|
||||
"integrity": "sha512-88MOfn9dM1B33t04jl8x0Glh0Ed0lUKMkhYajicRH7ZHTmwIdla1SQjiblp2C+EcCFvsY7XAU2/JUZQdl56aUw==",
|
||||
"requires": {
|
||||
"tslib": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
|
||||
@@ -110,7 +110,6 @@
|
||||
"@ng-select/ng-option-highlight": "^20.4.1",
|
||||
"@ng-select/ng-select": "^20.1.0",
|
||||
"@ngneat/content-loader": "^7.0.0",
|
||||
"@ngx-formly/core": "^6.1.4",
|
||||
"@openproject/octicons-angular": "^19.29.0",
|
||||
"@openproject/primer-view-components": "^0.75.2",
|
||||
"@openproject/reactivestates": "^3.0.1",
|
||||
|
||||
@@ -59,7 +59,6 @@ import {
|
||||
OpenprojectMembersModule,
|
||||
} from 'core-app/shared/components/autocompleter/members-autocompleter/members.module';
|
||||
import { OpenprojectAugmentingModule } from 'core-app/core/augmenting/openproject-augmenting.module';
|
||||
import { OpenprojectInviteUserModalModule } from 'core-app/features/invite-user-modal/invite-user-modal.module';
|
||||
import { OpenprojectModalModule } from 'core-app/shared/components/modal/modal.module';
|
||||
import {
|
||||
RevitAddInSettingsButtonService,
|
||||
@@ -204,7 +203,6 @@ import {
|
||||
import {
|
||||
OpWpDatePickerInstanceComponent,
|
||||
} from 'core-app/shared/components/datepicker/wp-date-picker-modal/wp-date-picker-instance.component';
|
||||
import { OpInviteUserModalAugmentService } from 'core-app/features/invite-user-modal/invite-user-modal-augment.service';
|
||||
import { TimeEntryTimerService } from 'core-app/shared/components/time_entries/services/time-entry-timer.service';
|
||||
import { MyPageComponent } from './features/my-page/my-page.component';
|
||||
import { DashboardComponent } from './features/overview/dashboard.component';
|
||||
@@ -215,7 +213,6 @@ export function initializeServices(injector:Injector) {
|
||||
const keyboardShortcuts = injector.get(KeyboardShortcutService);
|
||||
const contextMenu = injector.get(OPContextMenuService);
|
||||
const currentProject = injector.get(CurrentProjectService);
|
||||
const inviteUserAugmentService = injector.get(OpInviteUserModalAugmentService);
|
||||
const timeEntryTimerService = injector.get(TimeEntryTimerService);
|
||||
|
||||
// Conditionally add the Revit Add-In settings button
|
||||
@@ -224,7 +221,6 @@ export function initializeServices(injector:Injector) {
|
||||
const runOnRenderAndLoad = () => {
|
||||
topMenuService.register();
|
||||
contextMenu.register();
|
||||
inviteUserAugmentService.setupListener();
|
||||
timeEntryTimerService.initialize();
|
||||
currentProject.detect();
|
||||
};
|
||||
@@ -329,9 +325,6 @@ export function runBootstrap(appRef:ApplicationRef) {
|
||||
// Modals
|
||||
OpenprojectModalModule,
|
||||
|
||||
// Invite user modal
|
||||
OpenprojectInviteUserModalModule,
|
||||
|
||||
// Tabs
|
||||
OpenprojectTabsModule,
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiV3FormResource } from 'core-app/core/apiv3/forms/apiv3-form-resource';
|
||||
import { FormResource } from 'core-app/features/hal/resources/form-resource';
|
||||
import { Observable } from 'rxjs';
|
||||
import { HalSource } from 'core-app/features/hal/resources/hal-resource';
|
||||
import { HalSource } from 'core-app/features/hal/interfaces';
|
||||
|
||||
export class ApiV3WorkPackageForm extends ApiV3FormResource {
|
||||
/**
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { HttpClient, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
import { UntypedFormBuilder } from '@angular/forms';
|
||||
import { FormsService } from './forms.service';
|
||||
|
||||
describe('FormsService', () => {
|
||||
let service:FormsService;
|
||||
let httpClient:HttpClient;
|
||||
let httpTestingController:HttpTestingController;
|
||||
const testFormUrl = 'http://op.com/form';
|
||||
const formModel = {
|
||||
name: 'Project 1',
|
||||
_links: {
|
||||
parent: {
|
||||
href: '/api/v3/projects/26',
|
||||
title: 'Parent project',
|
||||
name: 'Parent project',
|
||||
},
|
||||
users: [
|
||||
{
|
||||
href: '/api/v3/users/26',
|
||||
title: 'User 1',
|
||||
name: 'User 1',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const formBuilder = new UntypedFormBuilder();
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [],
|
||||
providers: [provideHttpClient(withInterceptorsFromDi()), provideHttpClientTesting()]
|
||||
});
|
||||
httpClient = TestBed.inject(HttpClient);
|
||||
httpTestingController = TestBed.inject(HttpTestingController);
|
||||
service = TestBed.inject(FormsService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should submit the dynamic form value', () => {
|
||||
const form = formBuilder.group(formModel);
|
||||
const resourceId = '123';
|
||||
|
||||
service
|
||||
.submit$(form, testFormUrl)
|
||||
.subscribe();
|
||||
|
||||
const postReq = httpTestingController.expectOne(testFormUrl);
|
||||
|
||||
expect(postReq.request.method).toEqual('POST', 'should create a new resource when no id is provided');
|
||||
expect(postReq.request.body.name).toEqual('Project 1', 'should upload the primitive values as they are');
|
||||
expect(postReq.request.body._links.parent).toEqual({ href: '/api/v3/projects/26' }, 'should format the resource values to only contain the href');
|
||||
|
||||
postReq.flush('ok response');
|
||||
httpTestingController.verify();
|
||||
|
||||
service
|
||||
.submit$(form, testFormUrl, resourceId)
|
||||
.subscribe();
|
||||
|
||||
const patchReq = httpTestingController.expectOne(`${testFormUrl}/${resourceId}`);
|
||||
|
||||
expect(patchReq.request.method).toEqual('PATCH', 'should update the resource when an id is provided');
|
||||
|
||||
patchReq.flush('ok response');
|
||||
httpTestingController.verify();
|
||||
});
|
||||
|
||||
it('should format the model to fit the backend expectation', () => {
|
||||
// @ts-ignore
|
||||
const formattedModel = service.formatModelToSubmit(formModel);
|
||||
const expectedResult = {
|
||||
name: 'Project 1',
|
||||
_links: {
|
||||
parent: {
|
||||
href: '/api/v3/projects/26',
|
||||
},
|
||||
users: [
|
||||
{
|
||||
href: '/api/v3/users/26',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(formattedModel).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
it('should set the backend errors in the FormGroup', () => {
|
||||
const form = formBuilder.group({
|
||||
...formModel,
|
||||
_links: formBuilder.group(formModel._links),
|
||||
});
|
||||
const backEndErrorResponse = {
|
||||
error: {
|
||||
_type: 'Error',
|
||||
errorIdentifier: 'urn:openproject-org:api:v3:errors:MultipleErrors',
|
||||
message: 'Multiple field constraints have been violated.',
|
||||
_embedded: {
|
||||
errors: [
|
||||
{
|
||||
_type: 'Error',
|
||||
errorIdentifier: 'urn:openproject-org:api:v3:errors:PropertyConstraintViolation',
|
||||
message: "Name can't be blank.",
|
||||
_embedded: {
|
||||
details: {
|
||||
attribute: 'name',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
_type: 'Error',
|
||||
errorIdentifier: 'urn:openproject-org:api:v3:errors:PropertyConstraintViolation',
|
||||
message: "Identifier can't be blank.",
|
||||
_embedded: {
|
||||
details: {
|
||||
attribute: 'parent',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 422,
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
service.handleBackendFormValidationErrors(backEndErrorResponse, form);
|
||||
|
||||
expect(form.get('name')!.invalid).toBe(true);
|
||||
expect(form.get('_links')!.get('parent')!.invalid).toBe(true);
|
||||
expect(form.get('_links')!.get('users')!.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { UntypedFormGroup } from '@angular/forms';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class FormsService {
|
||||
constructor(
|
||||
private _httpClient:HttpClient,
|
||||
) { }
|
||||
|
||||
submit$(form:UntypedFormGroup, resourceEndpoint:string, resourceId?:string, formHttpMethod?:'post' | 'patch', formSchema?:IOPFormSchema):Observable<any> {
|
||||
const modelToSubmit = this.formatModelToSubmit(form.getRawValue(), formSchema);
|
||||
const httpMethod = resourceId ? 'patch' : (formHttpMethod || 'post');
|
||||
const url = resourceId ? `${resourceEndpoint}/${resourceId}` : resourceEndpoint;
|
||||
|
||||
return this._httpClient
|
||||
[httpMethod](
|
||||
url,
|
||||
modelToSubmit,
|
||||
{
|
||||
withCredentials: true,
|
||||
responseType: 'json',
|
||||
},
|
||||
)
|
||||
.pipe(
|
||||
catchError((error:HttpErrorResponse) => {
|
||||
if (error.status == 422) {
|
||||
this.handleBackendFormValidationErrors(error, form);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
validateForm$(form:UntypedFormGroup, resourceEndpoint:string, formSchema?:IOPFormSchema):Observable<any> {
|
||||
const modelToSubmit = this.formatModelToSubmit(form.value, formSchema);
|
||||
|
||||
return this._httpClient
|
||||
.post(
|
||||
`${resourceEndpoint}/form`,
|
||||
modelToSubmit,
|
||||
{
|
||||
withCredentials: true,
|
||||
responseType: 'json',
|
||||
},
|
||||
)
|
||||
.pipe(
|
||||
map((response:HalSource) => this.getFormattedErrors(Object.values(response?._embedded?.validationErrors))),
|
||||
map((formattedErrors:IFormattedValidationError[]) => this.setFormValidationErrors(formattedErrors, form)),
|
||||
);
|
||||
}
|
||||
|
||||
/** HAL resources formatting
|
||||
* The backend form model/payload contains HAL resources nested in the '_links' property.
|
||||
* In order to simplify its use, the model is flatted and HAL resources are placed at
|
||||
* the first level of the model with the 'formatModelToEdit' method.
|
||||
* 'formatModelToSubmit' places HAL resources model back to the '_links' property and formats them
|
||||
* in the shape of '{href:hrefValue}' in order to fit the backend expectations.
|
||||
* */
|
||||
private formatModelToSubmit(formModel:IOPFormModel, formSchema:IOPFormSchema = {}):IOPFormModel {
|
||||
let { _links: linksModel, ...mainModel } = formModel;
|
||||
const resourcesModel = linksModel || Object.keys(formSchema)
|
||||
.filter((formSchemaKey) => !!formSchema[formSchemaKey]?.type && formSchema[formSchemaKey]?.location === '_links')
|
||||
.reduce((result, formSchemaKey) => {
|
||||
const { [formSchemaKey]: keyToRemove, ...mainModelWithoutResource } = mainModel;
|
||||
mainModel = mainModelWithoutResource;
|
||||
|
||||
return { ...result, [formSchemaKey]: formModel[formSchemaKey] };
|
||||
}, {});
|
||||
|
||||
const formattedResourcesModel = Object
|
||||
.keys(resourcesModel)
|
||||
.reduce((result, resourceKey) => {
|
||||
// @ts-ignore
|
||||
const resourceModel = resourcesModel[resourceKey];
|
||||
// Form.payload resources have a HalLinkSource interface while
|
||||
// API resource options have a IAllowedValue interface
|
||||
const formattedResourceModel = Array.isArray(resourceModel)
|
||||
? resourceModel.map((resourceElement) => ({ href: resourceElement?.href || resourceElement?._links?.self?.href || null }))
|
||||
: { href: resourceModel?.href || resourceModel?._links?.self?.href || null };
|
||||
|
||||
return {
|
||||
...result,
|
||||
[resourceKey]: formattedResourceModel,
|
||||
};
|
||||
}, {});
|
||||
|
||||
return {
|
||||
...mainModel,
|
||||
_links: formattedResourcesModel,
|
||||
};
|
||||
}
|
||||
|
||||
/** HAL resources formatting
|
||||
* The backend form model/payload contains HAL resources nested in the '_links' property.
|
||||
* In order to simplify its use, the model is flatted and HAL resources are placed at
|
||||
* the first level of the model. 'NonValue' values are also removed from the model so
|
||||
* default values from the DynamicForm are set.
|
||||
*/
|
||||
formatModelToEdit(formModel:IOPFormModel = {}):IOPFormModel {
|
||||
const { _links: resourcesModel, _meta: metaModel, ...otherElements } = formModel;
|
||||
const otherElementsModel = Object.keys(otherElements)
|
||||
.filter((key) => this.isValue(otherElements[key]))
|
||||
.reduce((model, key) => ({ ...model, [key]: otherElements[key] }), {});
|
||||
|
||||
const model = {
|
||||
...otherElementsModel,
|
||||
_meta: metaModel,
|
||||
...this.getFormattedResourcesModel(resourcesModel),
|
||||
};
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
private handleBackendFormValidationErrors(error:HttpErrorResponse, form:UntypedFormGroup):void {
|
||||
const errors:IOPFormError[] = error?.error?._embedded?.errors
|
||||
? error?.error?._embedded?.errors : [error.error];
|
||||
const formErrors = this.getFormattedErrors(errors);
|
||||
|
||||
this.setFormValidationErrors(formErrors, form);
|
||||
}
|
||||
|
||||
private setFormValidationErrors(errors:IFormattedValidationError[], form:UntypedFormGroup) {
|
||||
errors.forEach((err:any) => {
|
||||
const formControl = form.get(err.key) || form.get('_links')?.get(err.key);
|
||||
|
||||
formControl?.setErrors({ [err.key]: { message: err.message } });
|
||||
});
|
||||
}
|
||||
|
||||
private getAllFormValidationErrors(validationErrors:IOPValidationErrors, formControlKeys?:string | string[]):{ [key:string]:{ message:string } } {
|
||||
const errors = Object.values(validationErrors);
|
||||
const keysToValidate = Array.isArray(formControlKeys) ? formControlKeys : [formControlKeys];
|
||||
const formErrors = this.getFormattedErrors(errors)
|
||||
.filter((error) => {
|
||||
if (!formControlKeys) {
|
||||
return true;
|
||||
}
|
||||
return keysToValidate.includes(error.key);
|
||||
})
|
||||
.reduce((result, { key, message }) => ({
|
||||
...result,
|
||||
[key]: { message },
|
||||
}), {});
|
||||
|
||||
return formErrors;
|
||||
}
|
||||
|
||||
private getFormattedErrors(errors:IOPFormError[]):IFormattedValidationError[] {
|
||||
const formattedErrors = errors.map((err) => ({
|
||||
key: err._embedded.details.attribute,
|
||||
message: err.message,
|
||||
}));
|
||||
|
||||
return formattedErrors;
|
||||
}
|
||||
|
||||
private getFormattedResourcesModel(resourcesModel:IOPFormModel['_links'] = {}):IOPFormModel['_links'] {
|
||||
return Object.keys(resourcesModel).reduce((result, resourceKey) => {
|
||||
const resource = resourcesModel[resourceKey];
|
||||
// ng-select needs a 'name' in order to show the label
|
||||
// We need to add it in case of the form payload (HalLinkSource)
|
||||
const resourceModel = Array.isArray(resource)
|
||||
? resource.map((resourceElement) => ({ ...resourceElement, name: resourceElement?.name || resourceElement?.title }))
|
||||
: { ...resource, name: resource?.name || resource?.title };
|
||||
|
||||
result = {
|
||||
...result,
|
||||
...(this.isValue(resourceModel) && { [resourceKey]: resourceModel }),
|
||||
};
|
||||
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
private isValue(value:any) {
|
||||
return ![null, undefined, ''].includes(value);
|
||||
}
|
||||
}
|
||||
-138
@@ -1,138 +0,0 @@
|
||||
interface IOPFormSettingsResource {
|
||||
_type?:'Form';
|
||||
_embedded:IOPFormSettings;
|
||||
_links?:{
|
||||
self:IOPApiCall;
|
||||
validate:IOPApiCall;
|
||||
commit:IOPApiCall;
|
||||
previewMarkup?:IOPApiCall;
|
||||
};
|
||||
}
|
||||
|
||||
interface IOPFormSettings {
|
||||
payload:IOPFormModel;
|
||||
schema:IOPFormSchema;
|
||||
validationErrors?:IOPValidationErrors;
|
||||
[nonUsedSchemaKeys:string]:any,
|
||||
}
|
||||
|
||||
interface IOPFormSchema {
|
||||
_type?:'Schema';
|
||||
_dependencies?:unknown[];
|
||||
_attributeGroups?:IOPAttributeGroup[];
|
||||
lockVersion?:number;
|
||||
[fieldKey:string]:IOPFieldSchema | any;
|
||||
_links?:{
|
||||
baseSchema?:{
|
||||
href:string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface IOPFormModel {
|
||||
[key:string]:string | number | Object | HalLinkSource | null | undefined;
|
||||
_links?:{
|
||||
[key:string]:IOPFieldModel | IOPFieldModel[] | null;
|
||||
};
|
||||
_meta?:{
|
||||
[key:string]:string|number|Object|HalLinkSource|null|undefined;
|
||||
}
|
||||
}
|
||||
|
||||
interface IOPFieldSchema {
|
||||
type:OPFieldType;
|
||||
writable:boolean;
|
||||
allowedValues?:any;
|
||||
required?:boolean;
|
||||
hasDefault:boolean;
|
||||
name?:string;
|
||||
minLength?:number,
|
||||
maxLength?:number,
|
||||
attributeGroup?:string;
|
||||
location?:'_meta'|'_links'|undefined;
|
||||
options:{
|
||||
[key:string]:any;
|
||||
};
|
||||
_embedded?:{
|
||||
allowedValues?:IOPApiCall | IOPAllowedValue[];
|
||||
};
|
||||
_links?:{
|
||||
allowedValues?:IOPApiCall;
|
||||
};
|
||||
}
|
||||
|
||||
interface IOPFieldSchemaWithKey extends IOPFieldSchema {
|
||||
key:string;
|
||||
}
|
||||
|
||||
interface IOPFieldModel extends Partial<HalSource>{
|
||||
name?:string;
|
||||
}
|
||||
|
||||
type HalSource = {
|
||||
[key:string]:string|number|null|HalSourceLinks,
|
||||
_links:HalSourceLinks
|
||||
};
|
||||
|
||||
interface IOPApiCall {
|
||||
href:string;
|
||||
method?:string;
|
||||
}
|
||||
|
||||
interface IOPApiOption {
|
||||
href:string|null;
|
||||
title?:string;
|
||||
}
|
||||
|
||||
interface IOPAttributeGroup {
|
||||
_type:| 'WorkPackageFormAttributeGroup'
|
||||
| 'WorkPackageFormChildrenQueryGroup'
|
||||
| 'WorkPackageFormRelationQueryGroup'
|
||||
| unknown;
|
||||
name:string;
|
||||
attributes:string[];
|
||||
}
|
||||
|
||||
interface IOPAllowedValue {
|
||||
id?:string;
|
||||
name:string;
|
||||
[key:string]:unknown;
|
||||
_links?:{
|
||||
self:HalSourceLink | IOPApiOption;
|
||||
[key:string]:HalSourceLink;
|
||||
};
|
||||
}
|
||||
|
||||
type OPFieldType = 'String' | 'Integer' | 'Float' | 'Boolean' | 'Date' | 'DateTime' | 'Formattable' |
|
||||
'Priority' | 'Status' | 'Type' | 'User' | 'Version' | 'TimeEntriesActivity' | 'Category' |
|
||||
'CustomOption' | 'Project' | 'ProjectStatus' | 'Password' | 'Link';
|
||||
|
||||
interface IOPFormError {
|
||||
errorIdentifier:string;
|
||||
message:string;
|
||||
_type:string;
|
||||
_embedded:IOPFormErrorDetails;
|
||||
}
|
||||
|
||||
interface IFormattedValidationError {
|
||||
key:string;
|
||||
message:string;
|
||||
}
|
||||
|
||||
interface IOPFormErrorResponse extends IOPFormError {
|
||||
_embedded:IOPFormErrorDetails | IOPFormErrors;
|
||||
}
|
||||
|
||||
interface IOPFormErrorDetails {
|
||||
details:{
|
||||
attribute:string;
|
||||
}
|
||||
}
|
||||
|
||||
interface IOPFormErrors {
|
||||
errors:IOPFormError[];
|
||||
}
|
||||
|
||||
interface IOPValidationErrors {
|
||||
[key:string]:IOPFormError;
|
||||
}
|
||||
@@ -78,6 +78,16 @@ export class PathHelperService {
|
||||
return `${this.ifcModelsPath(projectIdentifier)}/${modelId}/edit`;
|
||||
}
|
||||
|
||||
public inviteUserPath(projectId:string|null) {
|
||||
const path = `${this.staticBase}/users/invite`;
|
||||
|
||||
if (projectId) {
|
||||
return `${path}?user_invitation[project_id]=${projectId}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
public ifcModelsDeletePath(projectIdentifier:string, modelId:number|string) {
|
||||
return `${this.ifcModelsPath(projectIdentifier)}/${modelId}`;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
switchMap,
|
||||
} from 'rxjs';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { IOPFieldSchema } from 'core-app/features/hal/interfaces';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BoardListsService {
|
||||
|
||||
@@ -3,13 +3,7 @@ import { HalResource } from 'core-app/features/hal/resources/hal-resource';
|
||||
import { HalLink } from 'core-app/features/hal/hal-link/hal-link';
|
||||
import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service';
|
||||
import { OpenprojectHalModuleHelpers } from 'core-app/features/hal/helpers/lazy-accessor';
|
||||
|
||||
interface HalSource {
|
||||
_links:any;
|
||||
_embedded:any;
|
||||
_type?:string;
|
||||
type?:any;
|
||||
}
|
||||
import { HalSource } from 'core-app/features/hal/interfaces';
|
||||
|
||||
export function cloneHalResourceCollection<T extends HalResource>(values:T[]|undefined):T[] {
|
||||
if (_.isNil(values)) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface HalSourceLink { href?:string|null, title?:string }
|
||||
|
||||
export type HalSourceLinks = Record<string, HalSourceLink>;
|
||||
|
||||
export interface HalSource {
|
||||
[key:string]:unknown,
|
||||
_links:HalSourceLinks
|
||||
}
|
||||
|
||||
export interface IOPFieldSchema {
|
||||
type:string;
|
||||
writable:boolean;
|
||||
allowedValues?:unknown;
|
||||
required?:boolean;
|
||||
hasDefault:boolean;
|
||||
name?:string;
|
||||
minLength?:number,
|
||||
maxLength?:number,
|
||||
attributeGroup?:string;
|
||||
location?:'_meta'|'_links'|undefined;
|
||||
options:Record<string, unknown>;
|
||||
_embedded?:{
|
||||
allowedValues?:IOPApiCall|IOPAllowedValue[];
|
||||
};
|
||||
_links?:{
|
||||
allowedValues?:IOPApiCall;
|
||||
};
|
||||
}
|
||||
|
||||
interface IOPApiCall {
|
||||
href:string;
|
||||
method?:string;
|
||||
}
|
||||
|
||||
interface IOPApiOption {
|
||||
href:string|null;
|
||||
title?:string;
|
||||
}
|
||||
|
||||
interface IOPAllowedValue {
|
||||
id?:string;
|
||||
name:string;
|
||||
|
||||
[key:string]:unknown;
|
||||
|
||||
_links?:{
|
||||
self:HalSourceLink|IOPApiOption;
|
||||
[key:string]:HalSourceLink;
|
||||
};
|
||||
}
|
||||
@@ -36,24 +36,14 @@ import { ICKEditorContext } from 'core-app/shared/components/editor/components/c
|
||||
import idFromLink from 'core-app/features/hal/helpers/id-from-link';
|
||||
import isNewResource from 'core-app/features/hal/helpers/is-new-resource';
|
||||
|
||||
export interface HalResourceClass<T extends HalResource = HalResource> {
|
||||
new(injector:Injector,
|
||||
source:any,
|
||||
$loaded:boolean,
|
||||
halInitializer:(halResource:T) => void,
|
||||
$halType:string):T;
|
||||
}
|
||||
|
||||
export type HalSourceLink = { href:string|null, title?:string };
|
||||
|
||||
export type HalSourceLinks = {
|
||||
[key:string]:HalSourceLink
|
||||
};
|
||||
|
||||
export type HalSource = {
|
||||
[key:string]:string|number|boolean|null|HalSourceLinks,
|
||||
_links:HalSourceLinks
|
||||
};
|
||||
export type HalResourceClass<T extends HalResource = HalResource> = new(
|
||||
_injector:Injector,
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
_source:any,
|
||||
_$loaded:boolean,
|
||||
_halInitializer:(_:T) => void,
|
||||
_$halType:string,
|
||||
) => T;
|
||||
|
||||
export class HalResource {
|
||||
// TODO this is the source of many issues in the frontend
|
||||
@@ -95,6 +85,7 @@ export class HalResource {
|
||||
*/
|
||||
public constructor(
|
||||
public injector:Injector,
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
public $source:any,
|
||||
public $loaded:boolean,
|
||||
public halInitializer:(halResource:any) => void,
|
||||
@@ -177,7 +168,7 @@ export class HalResource {
|
||||
* @param {HalResource} other
|
||||
* @returns A HalResource with the identitical copied source of other.
|
||||
*/
|
||||
public $copy<T extends HalResource = HalResource>(source:Object = {}):T {
|
||||
public $copy<T extends HalResource = HalResource>(source:object = {}):T {
|
||||
const clone:HalResourceClass<T> = this.constructor as any;
|
||||
|
||||
return new clone(this.injector, _.merge(this.$plain(), source), this.$loaded, this.halInitializer, this.$halType);
|
||||
@@ -252,11 +243,12 @@ export class HalResource {
|
||||
// Otherwise, we risk returning a promise, that will never be resolved.
|
||||
state.putFromPromiseIfPristine(() => this.$loadResource(force));
|
||||
|
||||
return <Promise<this>>state.valuesPromise().then((source:any) => {
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
return state.valuesPromise().then((source:any) => {
|
||||
this.$initialize(source);
|
||||
this.$loaded = true;
|
||||
return this;
|
||||
});
|
||||
}) as Promise<this>;
|
||||
}
|
||||
|
||||
$loadResource(force = false):Promise<this> {
|
||||
|
||||
+12
-14
@@ -1,23 +1,30 @@
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
inject, ChangeDetectionStrategy, OnInit,
|
||||
} from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { CurrentUserService } from 'core-app/core/current-user/current-user.service';
|
||||
import { I18nService } from 'core-app/core/i18n/i18n.service';
|
||||
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
|
||||
import { OpInviteUserModalService } from 'core-app/features/invite-user-modal/invite-user-modal.service';
|
||||
import { OpInviteUserDialogService } from 'core-app/features/invite-user-modal/invite-user-dialog.service';
|
||||
import { OpAutocompleterComponent } from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component';
|
||||
|
||||
@Component({
|
||||
selector: 'op-invite-user-button',
|
||||
templateUrl: './invite-user-button.component.html',
|
||||
styleUrls: ['./invite-user-button.component.sass'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class InviteUserButtonComponent {
|
||||
export class InviteUserButtonComponent implements OnInit {
|
||||
@Input() projectId:string|null;
|
||||
|
||||
readonly I18n = inject(I18nService);
|
||||
readonly opInviteUserModalService = inject(OpInviteUserDialogService);
|
||||
readonly currentProjectService = inject(CurrentProjectService);
|
||||
readonly currentUserService = inject(CurrentUserService);
|
||||
readonly autocompleter = inject(OpAutocompleterComponent);
|
||||
|
||||
/** This component does not provide an output, because both primary usecases were in places where the button was
|
||||
* destroyed before the modal closed, causing the data from the modal to never arrive at the parent.
|
||||
* If you want to do something with the output from the modal that is opened, use the OpInviteUserModalService
|
||||
@@ -29,22 +36,13 @@ export class InviteUserButtonComponent {
|
||||
|
||||
canInviteUsersToProject$:Observable<boolean>;
|
||||
|
||||
constructor(
|
||||
readonly I18n:I18nService,
|
||||
readonly opInviteUserModalService:OpInviteUserModalService,
|
||||
readonly currentProjectService:CurrentProjectService,
|
||||
readonly currentUserService:CurrentUserService,
|
||||
readonly autocompleter:OpAutocompleterComponent,
|
||||
) {
|
||||
}
|
||||
|
||||
public ngOnInit():void {
|
||||
this.projectId = this.projectId || this.currentProjectService.id;
|
||||
this.projectId = this.projectId ?? this.currentProjectService.id;
|
||||
this.canInviteUsersToProject$ = this
|
||||
.currentUserService
|
||||
.hasCapabilities$(
|
||||
'memberships/create',
|
||||
this.projectId || null,
|
||||
this.projectId ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+37
-22
@@ -26,35 +26,50 @@
|
||||
// See COPYRIGHT and LICENSE files for more details.
|
||||
//++
|
||||
|
||||
import { Injectable, EventEmitter } from '@angular/core';
|
||||
import { EventEmitter, inject, Injectable, OnDestroy } from '@angular/core';
|
||||
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
|
||||
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
|
||||
import { OpModalService } from 'core-app/shared/components/modal/modal.service';
|
||||
import { InviteUserModalComponent } from './invite-user.component';
|
||||
import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service';
|
||||
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
|
||||
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
|
||||
|
||||
/**
|
||||
* This service triggers user-invite modals to clicks on elements
|
||||
* with the attribute [invite-user-modal-augment] set.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OpInviteUserModalService {
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OpInviteUserDialogService implements OnDestroy {
|
||||
public close = new EventEmitter<HalResource|HalResource[]>();
|
||||
|
||||
constructor(
|
||||
protected opModalService:OpModalService,
|
||||
protected currentProjectService:CurrentProjectService,
|
||||
) {
|
||||
protected currentProjectService = inject(CurrentProjectService);
|
||||
turboRequests = inject(TurboRequestsService);
|
||||
pathHelper = inject(PathHelperService);
|
||||
apiV3Service = inject(ApiV3Service);
|
||||
|
||||
private closeDialogHandler:EventListener = this.handleDialogClose.bind(this);
|
||||
|
||||
constructor() {
|
||||
document.addEventListener('dialog:close', this.closeDialogHandler);
|
||||
}
|
||||
|
||||
ngOnDestroy():void {
|
||||
document.removeEventListener('dialog:close', this.closeDialogHandler);
|
||||
}
|
||||
|
||||
public open(projectId:string|null = this.currentProjectService.id) {
|
||||
this.opModalService.show(
|
||||
InviteUserModalComponent,
|
||||
'global',
|
||||
{ projectId },
|
||||
).subscribe((modal) => modal
|
||||
.closingEvent
|
||||
.subscribe((modal:InviteUserModalComponent) => {
|
||||
this.close.emit(modal.data);
|
||||
}));
|
||||
void this.turboRequests.request(
|
||||
this.pathHelper.inviteUserPath(projectId),
|
||||
{ method: 'GET' },
|
||||
);
|
||||
}
|
||||
|
||||
private handleDialogClose(event:CustomEvent):void {
|
||||
const {
|
||||
detail: { dialog, submitted, additional },
|
||||
} = event as { detail:{ dialog:HTMLDialogElement; submitted:boolean, additional:{ user_id:number } } };
|
||||
if (dialog.id === 'user-invitation-dialog' && submitted) {
|
||||
this
|
||||
.apiV3Service
|
||||
.principals
|
||||
.id(additional.user_id)
|
||||
.get()
|
||||
.subscribe((user) => this.close.emit(user));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
//-- copyright
|
||||
// OpenProject is an open source project management software.
|
||||
// Copyright (C) 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 COPYRIGHT and LICENSE files for more details.
|
||||
//++
|
||||
|
||||
import { Inject, Injectable, DOCUMENT } from '@angular/core';
|
||||
import { OpModalService } from 'core-app/shared/components/modal/modal.service';
|
||||
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
|
||||
import { InviteUserModalComponent } from './invite-user.component';
|
||||
|
||||
const attributeName = 'invite-user-modal-augment';
|
||||
|
||||
/**
|
||||
* This service triggers user-invite modals to clicks on elements
|
||||
* with the attribute [invite-user-modal-augment] set.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OpInviteUserModalAugmentService {
|
||||
constructor(
|
||||
@Inject(DOCUMENT) protected documentElement:Document,
|
||||
protected opModalService:OpModalService,
|
||||
protected currentProjectService:CurrentProjectService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Create initial listeners for Rails-rendered modals
|
||||
*/
|
||||
public setupListener() {
|
||||
const matches = this.documentElement.querySelectorAll(`[${attributeName}]`);
|
||||
for (let i = 0; i < matches.length; ++i) {
|
||||
const el = matches[i] as HTMLElement;
|
||||
el.addEventListener('click', this.spawnModal.bind(this));
|
||||
el.removeAttribute(attributeName);
|
||||
}
|
||||
}
|
||||
|
||||
private spawnModal(event:MouseEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
this.opModalService.show(
|
||||
InviteUserModalComponent,
|
||||
'global',
|
||||
{ projectId: this.currentProjectService.id },
|
||||
).subscribe((modal) => modal
|
||||
.closingEvent
|
||||
.subscribe((instance:InviteUserModalComponent) => {
|
||||
// Just reload the page for now if we saved anything
|
||||
if (instance.data) {
|
||||
window.location.reload();
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import {
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TextFieldModule } from '@angular/cdk/text-field';
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { CurrentUserModule } from 'core-app/core/current-user/current-user.module';
|
||||
import { OpenprojectModalModule } from 'core-app/shared/components/modal/modal.module';
|
||||
import { InviteUserButtonModule } from 'core-app/features/invite-user-modal/button/invite-user-button.module';
|
||||
import { DynamicFormsModule } from 'core-app/shared/components/dynamic-forms/dynamic-forms.module';
|
||||
import { OpSharedModule } from 'core-app/shared/shared.module';
|
||||
import { OpInviteUserModalService } from 'core-app/features/invite-user-modal/invite-user-modal.service';
|
||||
import { InviteUserModalComponent } from './invite-user.component';
|
||||
import { ProjectSelectionComponent } from './project-selection/project-selection.component';
|
||||
import { PrincipalComponent } from './principal/principal.component';
|
||||
import { PrincipalSearchComponent } from './principal/principal-search.component';
|
||||
import { RoleSearchComponent } from './role/role-search.component';
|
||||
import { SummaryComponent } from './summary/summary.component';
|
||||
import { SuccessComponent } from './success/success.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
OpSharedModule,
|
||||
OpenprojectModalModule,
|
||||
FormsModule,
|
||||
NgSelectModule,
|
||||
ReactiveFormsModule,
|
||||
TextFieldModule,
|
||||
DynamicFormsModule,
|
||||
InviteUserButtonModule,
|
||||
CurrentUserModule,
|
||||
],
|
||||
exports: [
|
||||
InviteUserButtonModule,
|
||||
],
|
||||
declarations: [
|
||||
InviteUserModalComponent,
|
||||
ProjectSelectionComponent,
|
||||
PrincipalComponent,
|
||||
PrincipalSearchComponent,
|
||||
RoleSearchComponent,
|
||||
SuccessComponent,
|
||||
SummaryComponent,
|
||||
],
|
||||
providers: [
|
||||
OpInviteUserModalService,
|
||||
],
|
||||
})
|
||||
export class OpenprojectInviteUserModalModule {
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
@if (loading) {
|
||||
<div
|
||||
class="spot-modal"
|
||||
></div>
|
||||
}
|
||||
@if (!loading && step === Steps.ProjectSelection) {
|
||||
<op-ium-project-selection [project]="project"
|
||||
[type]="type"
|
||||
(save)="onProjectSelectionSave($event)"
|
||||
(close)="closeMe()"
|
||||
tabindex="0"
|
||||
cdkFocusInitial
|
||||
cdkTrapFocus
|
||||
[cdkTrapFocusAutoCapture]="true"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (!loading && step === Steps.Principal) {
|
||||
<op-ium-principal [project]="project"
|
||||
[roleData]="role"
|
||||
[messageData]="message"
|
||||
[principalData]="principalData"
|
||||
[type]="type"
|
||||
(save)="onPrincipalSave($event)"
|
||||
(back)="goTo(Steps.ProjectSelection)"
|
||||
(close)="closeMe()"
|
||||
tabindex="0"
|
||||
cdkFocusInitial
|
||||
cdkTrapFocus
|
||||
[cdkTrapFocusAutoCapture]="true"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (!loading && step === Steps.Summary) {
|
||||
<op-ium-summary [project]="project"
|
||||
[principalData]="principalData"
|
||||
[type]="type"
|
||||
[role]="role"
|
||||
[message]="message"
|
||||
(save)="onSuccessfulSubmission($event)"
|
||||
(back)="goTo(Steps.Principal)"
|
||||
(close)="closeMe()"
|
||||
tabindex="0"
|
||||
cdkFocusInitial
|
||||
cdkTrapFocus
|
||||
[cdkTrapFocusAutoCapture]="true"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (!loading && step === Steps.Success) {
|
||||
<op-ium-success [principal]="principalData.principal"
|
||||
[project]="project"
|
||||
[type]="type"
|
||||
[createdNewPrincipal]="createdNewPrincipal"
|
||||
class="spot-modal"
|
||||
(close)="closeWithPrincipal()"
|
||||
tabindex="0"
|
||||
cdkFocusInitial
|
||||
cdkTrapFocus
|
||||
[cdkTrapFocusAutoCapture]="true"
|
||||
/>
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
.op-modal
|
||||
// Unfortunately, the op-modal contents has been wrapped once here
|
||||
form
|
||||
display: flex
|
||||
flex-direction: column
|
||||
height: 100%
|
||||
flex-grow: 1
|
||||
|
||||
.spot-form-field + .spot-form-field
|
||||
margin-top: 1rem
|
||||
@@ -1,127 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
Inject,
|
||||
OnInit,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import { OpModalLocalsMap } from 'core-app/shared/components/modal/modal.types';
|
||||
import { OpModalComponent } from 'core-app/shared/components/modal/modal.component';
|
||||
import { OpModalLocalsToken } from 'core-app/shared/components/modal/modal.service';
|
||||
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
|
||||
import { PrincipalData } from 'core-app/shared/components/principal/principal-types';
|
||||
import { RoleResource } from 'core-app/features/hal/resources/role-resource';
|
||||
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
|
||||
import { ProjectResource } from 'core-app/features/hal/resources/project-resource';
|
||||
|
||||
enum Steps {
|
||||
ProjectSelection,
|
||||
Principal,
|
||||
Summary,
|
||||
Success,
|
||||
}
|
||||
|
||||
export enum PrincipalType {
|
||||
User = 'User',
|
||||
Placeholder = 'PlaceholderUser',
|
||||
Group = 'Group',
|
||||
}
|
||||
|
||||
@Component({
|
||||
templateUrl: './invite-user.component.html',
|
||||
styleUrls: ['./invite-user.component.sass'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class InviteUserModalComponent extends OpModalComponent implements OnInit {
|
||||
public Steps = Steps;
|
||||
|
||||
public step = Steps.ProjectSelection;
|
||||
|
||||
/* Data that is returned from the modal on close */
|
||||
public data:any = null;
|
||||
|
||||
public type:PrincipalType|null = null;
|
||||
|
||||
public project:ProjectResource|null = null;
|
||||
|
||||
public principalData:PrincipalData = {
|
||||
principal: null,
|
||||
customFields: {},
|
||||
};
|
||||
|
||||
public role:RoleResource|null = null;
|
||||
|
||||
public message = '';
|
||||
|
||||
public createdNewPrincipal = false;
|
||||
|
||||
public get loading():boolean {
|
||||
return this.locals.projectId && !this.project;
|
||||
}
|
||||
|
||||
constructor(
|
||||
@Inject(OpModalLocalsToken) public locals:OpModalLocalsMap,
|
||||
readonly cdRef:ChangeDetectorRef,
|
||||
readonly elementRef:ElementRef,
|
||||
readonly apiV3Service:ApiV3Service,
|
||||
) {
|
||||
super(locals, cdRef, elementRef);
|
||||
}
|
||||
|
||||
ngOnInit():void {
|
||||
super.ngOnInit();
|
||||
|
||||
if (this.locals.projectId) {
|
||||
this.apiV3Service.projects.id(this.locals.projectId).get().subscribe(
|
||||
(data) => {
|
||||
this.project = data;
|
||||
this.cdRef.markForCheck();
|
||||
},
|
||||
() => {
|
||||
this.locals.projectId = null;
|
||||
this.cdRef.markForCheck();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onProjectSelectionSave({ type, project }:{ type:PrincipalType, project:ProjectResource|null }):void {
|
||||
this.type = type;
|
||||
this.project = project;
|
||||
this.goTo(Steps.Principal);
|
||||
}
|
||||
|
||||
onPrincipalSave({
|
||||
principalData, isAlreadyMember, role, message,
|
||||
}:{ principalData:PrincipalData, isAlreadyMember:boolean, role:RoleResource, message:string }):void {
|
||||
this.principalData = principalData;
|
||||
this.role = role;
|
||||
this.message = message;
|
||||
if (isAlreadyMember) {
|
||||
return this.closeWithPrincipal();
|
||||
}
|
||||
|
||||
return this.goTo(Steps.Summary);
|
||||
}
|
||||
|
||||
onSuccessfulSubmission($event:{ principal:HalResource }):void {
|
||||
if (this.principalData.principal !== $event.principal && this.type === PrincipalType.User) {
|
||||
this.createdNewPrincipal = true;
|
||||
}
|
||||
this.principalData.principal = $event.principal;
|
||||
this.goTo(Steps.Success);
|
||||
}
|
||||
|
||||
goTo(step:Steps):void {
|
||||
this.step = step;
|
||||
}
|
||||
|
||||
closeWithPrincipal():void {
|
||||
this.data = this.principalData.principal;
|
||||
this.closeMe();
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
<ng-select
|
||||
appendTo=".spot-modal-overlay"
|
||||
[formControl]="spotFormBinding"
|
||||
[addTag]="showAddTag ? createNewFromInput.bind(this) : false"
|
||||
[typeahead]="input$"
|
||||
[items]="items$ | async"
|
||||
[clearable]="true"
|
||||
[clearOnBackspace]="false"
|
||||
[clearSearchOnAdd]="false"
|
||||
[compareWith]="compareWith"
|
||||
bindValue="principal"
|
||||
#ngselect
|
||||
>
|
||||
<ng-template ng-label-tmp let-item="item">
|
||||
{{ item.principal?.name || item.name }}
|
||||
</ng-template>
|
||||
|
||||
<ng-template ng-option-tmp let-item="item" let-search="searchTerm">
|
||||
@if (item) {
|
||||
<div
|
||||
class="ng-option-label"
|
||||
>
|
||||
<!--Selectable option-->
|
||||
<span
|
||||
data-hover-card-trigger-target="trigger"
|
||||
[attr.data-hover-card-url]="getHoverCardUrl(item.principal)"
|
||||
[ngOptionHighlight]="search">{{ item.principal.name }}</span>
|
||||
@if (item.principal.email) {
|
||||
<span
|
||||
class="op-autocompleter__option-principal-email"
|
||||
[ngOptionHighlight]="search">{{ item.principal.email }}</span>
|
||||
}
|
||||
<!-- Already a member of the project -->
|
||||
@if (item.disabled) {
|
||||
<div
|
||||
class="ellipsis">{{ text.alreadyAMember() }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<!--Nothing found -->
|
||||
<ng-template ng-notfound-tmp>
|
||||
<div class="ng-option disabled">
|
||||
{{ text.noResults[type] }}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
|
||||
<ng-template ng-tag-tmp>
|
||||
<!--Invite a new user by email-->
|
||||
@if (canInviteByEmail$ | async) {
|
||||
<div>
|
||||
<op-icon icon-classes="icon-mail1 icon-context" />
|
||||
<b>{{ text.inviteNewUser }}</b>
|
||||
{{ input }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!--Create a new placeholder by name-->
|
||||
@if (canCreateNewPlaceholder$ | async) {
|
||||
<div>
|
||||
<op-icon icon-classes="icon-add icon-context" />
|
||||
<b>{{ text.createNewPlaceholder }}</b>
|
||||
{{ input }}
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
@@ -1,13 +0,0 @@
|
||||
@import '../../global_styles/openproject/variables'
|
||||
|
||||
.op-autocompleter
|
||||
&__option-principal-email
|
||||
color: var(--fgColor-muted)
|
||||
font-size: var(--font-size-small)
|
||||
margin-left: var(--stack-gap-condensed)
|
||||
|
||||
@media screen and (max-width: $breakpoint-sm)
|
||||
&__option-principal-email
|
||||
display: block
|
||||
margin-left: 0
|
||||
margin-top: var(--control-xsmall-gap)
|
||||
@@ -1,182 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { UntypedFormControl } from '@angular/forms';
|
||||
import { BehaviorSubject, combineLatest, forkJoin, Observable } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged, map, share, shareReplay, switchMap } from 'rxjs/operators';
|
||||
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
|
||||
import { I18nService } from 'core-app/core/i18n/i18n.service';
|
||||
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
|
||||
import { ProjectResource } from 'core-app/features/hal/resources/project-resource';
|
||||
import { UserResource } from 'core-app/features/hal/resources/user-resource';
|
||||
import { PrincipalLike } from 'core-app/shared/components/principal/principal-types';
|
||||
import { CurrentUserService } from 'core-app/core/current-user/current-user.service';
|
||||
import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder';
|
||||
import { PrincipalType } from '../invite-user.component';
|
||||
import { CapabilitiesResourceService } from 'core-app/core/state/capabilities/capabilities.service';
|
||||
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
|
||||
|
||||
interface NgSelectPrincipalOption {
|
||||
principal:PrincipalLike,
|
||||
disabled:boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'op-ium-principal-search',
|
||||
styleUrls: ['./principal-search.component.sass'],
|
||||
templateUrl: './principal-search.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class PrincipalSearchComponent extends UntilDestroyedMixin implements OnInit {
|
||||
@Input() spotFormBinding:UntypedFormControl;
|
||||
|
||||
@Input() type:PrincipalType;
|
||||
|
||||
@Input() project:ProjectResource;
|
||||
|
||||
@Output() createNew = new EventEmitter<PrincipalLike>();
|
||||
|
||||
public input$ = new BehaviorSubject<string>('');
|
||||
|
||||
public input = '';
|
||||
|
||||
public items$:Observable<NgSelectPrincipalOption[]> = this.input$.pipe(
|
||||
this.untilDestroyed(),
|
||||
debounceTime(200),
|
||||
distinctUntilChanged(),
|
||||
switchMap(this.loadPrincipalData.bind(this)),
|
||||
share(),
|
||||
);
|
||||
|
||||
private emailRegExp = /^\S+@\S+\.\S+$/;
|
||||
|
||||
public canInviteByEmail$ = combineLatest(
|
||||
this.items$,
|
||||
this.input$,
|
||||
this.currentUserService.hasCapabilities$('users/create', 'global'),
|
||||
).pipe(
|
||||
map(([elements, input, canCreateUsers]) => canCreateUsers
|
||||
&& this.type === PrincipalType.User
|
||||
&& !!input
|
||||
&& this.emailRegExp.test(input)
|
||||
&& !elements.find((el) => (el.principal as UserResource).email === input)),
|
||||
);
|
||||
|
||||
public canCreateNewPlaceholder$ = combineLatest([
|
||||
this.items$,
|
||||
this.input$,
|
||||
this.currentUserService.hasCapabilities$('placeholder_users/create', 'global'),
|
||||
])
|
||||
.pipe(
|
||||
map(([elements, input, hasCapability]) => {
|
||||
if (!hasCapability) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.type !== PrincipalType.Placeholder) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any
|
||||
return !!input && !elements.find((el:any) => el.name === input);
|
||||
}),
|
||||
);
|
||||
|
||||
public showAddTag = false;
|
||||
|
||||
public text = {
|
||||
alreadyAMember: () => this.I18n.t('js.invite_user_modal.principal.already_member_message', {
|
||||
project: this.project?.name,
|
||||
}),
|
||||
inviteNewUser: this.I18n.t('js.invite_user_modal.principal.invite_user'),
|
||||
createNewPlaceholder: this.I18n.t('js.invite_user_modal.principal.create_new_placeholder'),
|
||||
noResults: {
|
||||
User: this.I18n.t('js.invite_user_modal.principal.no_results_user'),
|
||||
PlaceholderUser: this.I18n.t('js.invite_user_modal.principal.no_results_placeholder'),
|
||||
Group: this.I18n.t('js.invite_user_modal.principal.no_results_group'),
|
||||
},
|
||||
};
|
||||
|
||||
constructor(
|
||||
public I18n:I18nService,
|
||||
readonly elementRef:ElementRef,
|
||||
readonly apiV3Service:ApiV3Service,
|
||||
readonly currentUserService:CurrentUserService,
|
||||
readonly capabilitiesService:CapabilitiesResourceService,
|
||||
readonly pathHelperService:PathHelperService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.input$.subscribe((input:string) => {
|
||||
this.input = input;
|
||||
});
|
||||
|
||||
combineLatest(
|
||||
this.canInviteByEmail$,
|
||||
this.canCreateNewPlaceholder$,
|
||||
).pipe(
|
||||
map(([canInviteByEmail, canCreateNewPlaceholder]:boolean[]) => canInviteByEmail || canCreateNewPlaceholder),
|
||||
).subscribe((showAddTag) => {
|
||||
this.showAddTag = showAddTag;
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
// Make sure we have initial data
|
||||
setTimeout(() => this.input$.next(''));
|
||||
}
|
||||
|
||||
public createNewFromInput() {
|
||||
this.createNew.emit({ name: this.input });
|
||||
}
|
||||
|
||||
private loadPrincipalData(searchTerm:string) {
|
||||
const nonMemberFilter = new ApiV3FilterBuilder();
|
||||
if (searchTerm) {
|
||||
nonMemberFilter.add('any_name_attribute', '~', [searchTerm]);
|
||||
}
|
||||
nonMemberFilter.add('status', '!', [3]);
|
||||
nonMemberFilter.add('type', '=', [this.type]);
|
||||
nonMemberFilter.add('member', '!', [this.project?.id || '']);
|
||||
const nonMembers = this.apiV3Service.principals.filtered(nonMemberFilter).get();
|
||||
|
||||
const memberFilter = new ApiV3FilterBuilder();
|
||||
if (searchTerm) {
|
||||
memberFilter.add('any_name_attribute', '~', [searchTerm]);
|
||||
}
|
||||
memberFilter.add('status', '!', [3]);
|
||||
memberFilter.add('type', '=', [this.type]);
|
||||
memberFilter.add('member', '=', [this.project?.id || '']);
|
||||
const members = this.apiV3Service.principals.filtered(memberFilter).get();
|
||||
|
||||
return forkJoin({
|
||||
members,
|
||||
nonMembers,
|
||||
})
|
||||
.pipe(
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
map(({ members, nonMembers }) => [
|
||||
...nonMembers.elements.map((nonMember:PrincipalLike) => ({
|
||||
principal: nonMember,
|
||||
disabled: false,
|
||||
})),
|
||||
...members.elements.map((member:PrincipalLike) => ({
|
||||
principal: member,
|
||||
disabled: true,
|
||||
})),
|
||||
].slice(0, 5)),
|
||||
shareReplay(1),
|
||||
);
|
||||
}
|
||||
|
||||
compareWith = (a:NgSelectPrincipalOption, b:PrincipalLike) => a.principal.id === b.id;
|
||||
|
||||
public getHoverCardUrl(principal:PrincipalLike) {
|
||||
if (!principal.id) { return ''; }
|
||||
|
||||
if (this.type !== PrincipalType.User) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return this.pathHelperService.userHoverCardPath(principal.id);
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
<form
|
||||
class="spot-modal"
|
||||
[formGroup]="principalForm"
|
||||
(ngSubmit)="onSubmit($event)"
|
||||
>
|
||||
<div id="spotModalTitle" class="spot-modal--header">{{text.principal.title()}}</div>
|
||||
|
||||
<div class="spot-divider"></div>
|
||||
|
||||
<div class="spot-modal--body spot-container">
|
||||
<spot-form-field
|
||||
[label]="textLabel"
|
||||
required
|
||||
>
|
||||
@if (!(hasPrincipalSelected && isNewPrincipal)) {
|
||||
<op-ium-principal-search [spotFormBinding]="principalControl"
|
||||
[type]="type"
|
||||
[project]="project"
|
||||
slot="input"
|
||||
(createNew)="createNewFromInput($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (isNewPrincipal && type === PrincipalType.User) {
|
||||
<p
|
||||
slot="input"
|
||||
>
|
||||
<b>{{ text.principal.inviteUser }}</b> {{ principal.name }}
|
||||
<button
|
||||
type="button"
|
||||
class="spot-link"
|
||||
(click)="principalControl?.setValue(null)"
|
||||
>{{ text.principal.change }}</button>
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (isNewPrincipal && type === PrincipalType.Placeholder) {
|
||||
<p
|
||||
slot="input"
|
||||
>
|
||||
<b>{{ text.principal.createNewPlaceholder }}</b> {{ principal.name }}
|
||||
<button
|
||||
type="button"
|
||||
class="spot-link"
|
||||
(click)="principalControl?.setValue(null)"
|
||||
>{{ text.principal.change }}</button>
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (principalControl?.invalid) {
|
||||
<div
|
||||
slot="errors"
|
||||
class="spot-form-field--error"
|
||||
>
|
||||
{{ text.principal.required[type] }}
|
||||
</div>
|
||||
}
|
||||
</spot-form-field>
|
||||
|
||||
@if (isNewPrincipal && type === PrincipalType.User && userDynamicFieldConfig.schema) {
|
||||
<op-dynamic-form [dynamicFormGroup]="dynamicFieldsControl"
|
||||
[settings]="userDynamicFieldConfig"
|
||||
[formUrl]="apiV3Service.users.form.path"
|
||||
[handleSubmit]="false"
|
||||
/>
|
||||
}
|
||||
|
||||
<spot-form-field
|
||||
[label]="text.role.label()"
|
||||
required
|
||||
>
|
||||
<op-ium-role-search [spotFormBinding]="roleControl"
|
||||
slot="input"
|
||||
/>
|
||||
<p
|
||||
class="spot-form-field--description"
|
||||
slot="action"
|
||||
[innerHtml]="text.role.description()"
|
||||
></p>
|
||||
|
||||
@if (roleControl?.invalid) {
|
||||
<div
|
||||
slot="errors"
|
||||
class="spot-form-field--error"
|
||||
>
|
||||
{{ text.role.required }}
|
||||
</div>
|
||||
}
|
||||
</spot-form-field>
|
||||
|
||||
@if (type !== PrincipalType.Placeholder) {
|
||||
<spot-form-field
|
||||
[label]="text.message.label"
|
||||
>
|
||||
<textarea
|
||||
class="op-input op-ium-invite-message-field"
|
||||
[formControl]="messageControl"
|
||||
slot="input"
|
||||
#input>
|
||||
</textarea>
|
||||
<p
|
||||
class="spot-form-field--description"
|
||||
slot="action"
|
||||
[innerHtml]="text.message.description()">
|
||||
</p>
|
||||
</spot-form-field>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="spot-action-bar">
|
||||
<div class="spot-action-bar--left">
|
||||
<button
|
||||
type="button"
|
||||
class="button button_no-margin spot-action-bar--action"
|
||||
(click)="back.emit()"
|
||||
>
|
||||
<op-icon icon-classes="button--icon icon-arrow-left1" />
|
||||
{{ text.principal.backButton }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="spot-action-bar--right">
|
||||
<button
|
||||
type="button"
|
||||
class="button button_no-margin spot-action-bar--action spot-modal--cancel-button"
|
||||
(click)="close.emit()"
|
||||
[textContent]="text.principal.cancelButton"
|
||||
></button>
|
||||
<button
|
||||
type="submit"
|
||||
class="button button_no-margin -primary spot-action-bar--action"
|
||||
[textContent]="text.principal.nextButton"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,2 +0,0 @@
|
||||
.op-ium-invite-message-field
|
||||
height: 8em
|
||||
@@ -1,272 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnInit,
|
||||
Output,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {
|
||||
AbstractControl,
|
||||
UntypedFormControl,
|
||||
UntypedFormGroup,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { take } from 'rxjs/internal/operators/take';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
|
||||
import { I18nService } from 'core-app/core/i18n/i18n.service';
|
||||
import { DynamicFormComponent } from 'core-app/shared/components/dynamic-forms/components/dynamic-form/dynamic-form.component';
|
||||
import {
|
||||
PrincipalData,
|
||||
PrincipalLike,
|
||||
} from 'core-app/shared/components/principal/principal-types';
|
||||
import { ProjectResource } from 'core-app/features/hal/resources/project-resource';
|
||||
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
|
||||
import { PrincipalType } from '../invite-user.component';
|
||||
import { RoleResource } from 'core-app/features/hal/resources/role-resource';
|
||||
|
||||
function extractCustomFieldsFromSchema(schema:IOPFormSettings['_embedded']['schema']) {
|
||||
return Object.keys(schema)
|
||||
.reduce((fields, name) => {
|
||||
if (name.startsWith('customField') && schema[name].required) {
|
||||
return {
|
||||
...fields,
|
||||
[name]: schema[name],
|
||||
};
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, {});
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'op-ium-principal',
|
||||
templateUrl: './principal.component.html',
|
||||
styleUrls: ['./principal.component.sass'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class PrincipalComponent implements OnInit {
|
||||
@Input() principalData:PrincipalData;
|
||||
|
||||
@Input() project:ProjectResource;
|
||||
|
||||
@Input() type:PrincipalType;
|
||||
|
||||
@Input() roleData:RoleResource;
|
||||
|
||||
@Input() messageData = '';
|
||||
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
@Output() save = new EventEmitter<{
|
||||
principalData:PrincipalData,
|
||||
isAlreadyMember:boolean,
|
||||
role:RoleResource,
|
||||
message:string
|
||||
}>();
|
||||
|
||||
@Output() back = new EventEmitter();
|
||||
|
||||
@ViewChild(DynamicFormComponent) dynamicForm:DynamicFormComponent;
|
||||
|
||||
public PrincipalType = PrincipalType;
|
||||
|
||||
public text = {
|
||||
principal: {
|
||||
title: ():string => this.I18n.t('js.invite_user_modal.title.invite'),
|
||||
label: {
|
||||
User: this.I18n.t('js.invite_user_modal.principal.label.name_or_email'),
|
||||
PlaceholderUser: this.I18n.t('js.invite_user_modal.principal.label.name'),
|
||||
Group: this.I18n.t('js.invite_user_modal.principal.label.name'),
|
||||
Email: this.I18n.t('js.label_email'),
|
||||
},
|
||||
change: this.I18n.t('js.label_change'),
|
||||
inviteUser: this.I18n.t('js.invite_user_modal.principal.invite_user'),
|
||||
createNewPlaceholder: this.I18n.t('js.invite_user_modal.principal.create_new_placeholder'),
|
||||
required: {
|
||||
User: this.I18n.t('js.invite_user_modal.principal.required.user'),
|
||||
PlaceholderUser: this.I18n.t('js.invite_user_modal.principal.required.placeholder'),
|
||||
Group: this.I18n.t('js.invite_user_modal.principal.required.group'),
|
||||
},
|
||||
backButton: this.I18n.t('js.invite_user_modal.back'),
|
||||
nextButton: this.I18n.t('js.invite_user_modal.principal.next_button'),
|
||||
cancelButton: this.I18n.t('js.button_cancel'),
|
||||
},
|
||||
role: {
|
||||
label: ():string => this.I18n.t('js.invite_user_modal.role.label', {
|
||||
project: this.project?.name,
|
||||
}),
|
||||
description: ():string => this.I18n.t('js.invite_user_modal.role.description', {
|
||||
principal: this.principal?.name,
|
||||
}),
|
||||
required: this.I18n.t('js.invite_user_modal.role.required'),
|
||||
},
|
||||
message: {
|
||||
label: this.I18n.t('js.invite_user_modal.message.label'),
|
||||
description: ():string => this.I18n.t('js.invite_user_modal.message.description', {
|
||||
principal: this.principal?.name,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
public principalForm = new UntypedFormGroup({
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
principal: new UntypedFormControl(null, [Validators.required]),
|
||||
userDynamicFields: new UntypedFormGroup({}),
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
role: new UntypedFormControl(null, [Validators.required]),
|
||||
message: new UntypedFormControl(''),
|
||||
});
|
||||
|
||||
public userDynamicFieldConfig:{
|
||||
payload:IOPFormSettings['_embedded']['payload']|null,
|
||||
schema:IOPFormSettings['_embedded']['schema']|null,
|
||||
} = {
|
||||
payload: null,
|
||||
schema: null,
|
||||
};
|
||||
|
||||
get messageControl():AbstractControl|null {
|
||||
return this.principalForm.get('message');
|
||||
}
|
||||
|
||||
get roleControl():AbstractControl|null {
|
||||
return this.principalForm.get('role');
|
||||
}
|
||||
|
||||
get principalControl():AbstractControl|null {
|
||||
return this.principalForm.get('principal');
|
||||
}
|
||||
|
||||
get principal():PrincipalLike|undefined {
|
||||
return this.principalControl?.value as PrincipalLike|undefined;
|
||||
}
|
||||
|
||||
get role():RoleResource|undefined {
|
||||
return this.roleControl?.value as RoleResource|undefined;
|
||||
}
|
||||
|
||||
get message():string|undefined {
|
||||
return this.messageControl?.value as string|undefined;
|
||||
}
|
||||
|
||||
get dynamicFieldsControl():AbstractControl|null {
|
||||
return this.principalForm.get('userDynamicFields');
|
||||
}
|
||||
|
||||
get customFields():{ [key:string]:any } {
|
||||
return this.dynamicFieldsControl?.value;
|
||||
}
|
||||
|
||||
get hasPrincipalSelected():boolean {
|
||||
return !!this.principal;
|
||||
}
|
||||
|
||||
get textLabel():string {
|
||||
if (this.type === PrincipalType.User && this.isNewPrincipal) {
|
||||
return this.text.principal.label.Email;
|
||||
}
|
||||
return this.text.principal.label[this.type];
|
||||
}
|
||||
|
||||
get isNewPrincipal():boolean {
|
||||
return this.hasPrincipalSelected && !(this.principal instanceof HalResource);
|
||||
}
|
||||
|
||||
get isMemberOfCurrentProject():boolean {
|
||||
return !!this.principalControl?.value?.memberships?.elements?.find((mem:any) => mem.project.id === this.project.id);
|
||||
}
|
||||
|
||||
constructor(
|
||||
readonly I18n:I18nService,
|
||||
readonly httpClient:HttpClient,
|
||||
readonly apiV3Service:ApiV3Service,
|
||||
readonly cdRef:ChangeDetectorRef,
|
||||
) {}
|
||||
|
||||
ngOnInit():void {
|
||||
this.principalControl?.setValue(this.principalData.principal);
|
||||
this.roleControl?.setValue(this.roleData);
|
||||
this.messageControl?.setValue(this.messageData);
|
||||
|
||||
if (this.type === PrincipalType.User) {
|
||||
const payload = this.isNewPrincipal ? this.principalData.customFields : {};
|
||||
this
|
||||
.apiV3Service
|
||||
.users
|
||||
.form
|
||||
.post(payload)
|
||||
.pipe(
|
||||
take(1),
|
||||
// The subsequent code expects to not work with a HalResource but rather with the raw
|
||||
// api response.
|
||||
map((formResource) => formResource.$source),
|
||||
)
|
||||
.subscribe((formConfig) => {
|
||||
this.userDynamicFieldConfig.schema = extractCustomFieldsFromSchema(formConfig._embedded?.schema);
|
||||
this.userDynamicFieldConfig.payload = formConfig._embedded?.payload;
|
||||
this.cdRef.detectChanges();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
createNewFromInput(input:PrincipalLike):void {
|
||||
this.principalControl?.setValue(input);
|
||||
}
|
||||
|
||||
onSubmit($e:Event):void {
|
||||
$e.preventDefault();
|
||||
|
||||
if (this.dynamicForm) {
|
||||
this.dynamicForm.validateForm().subscribe(() => {
|
||||
this.onValidatedSubmit();
|
||||
});
|
||||
} else {
|
||||
this.onValidatedSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
onValidatedSubmit():void {
|
||||
if (this.principalForm.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The code below transforms the model value as it comes from the dynamic form to the value accepted by the API.
|
||||
// This is not just necessary for submit, but also so that we can reseed the initial values to the payload
|
||||
// when going back to this step after having completed it once.
|
||||
const fieldsSchema = this.userDynamicFieldConfig.schema || {};
|
||||
const customFields = Object.keys(fieldsSchema)
|
||||
.reduce((result, fieldKey) => {
|
||||
const fieldSchema = fieldsSchema[fieldKey];
|
||||
let fieldValue = this.customFields[fieldKey];
|
||||
|
||||
if (fieldSchema.location === '_links' && !!fieldValue) {
|
||||
fieldValue = Array.isArray(fieldValue)
|
||||
? fieldValue.map((opt:any) => (opt._links ? opt._links.self : opt))
|
||||
: (fieldValue._links ? fieldValue._links.self : fieldValue);
|
||||
}
|
||||
|
||||
result = {
|
||||
...result,
|
||||
[fieldKey]: fieldValue,
|
||||
};
|
||||
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
this.save.emit({
|
||||
principalData: {
|
||||
customFields,
|
||||
principal: this.principal as PrincipalLike,
|
||||
},
|
||||
isAlreadyMember: this.isMemberOfCurrentProject,
|
||||
role: this.role as RoleResource,
|
||||
message: this.message as string,
|
||||
});
|
||||
}
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
import { AbstractControl } from '@angular/forms';
|
||||
import {
|
||||
Observable,
|
||||
of,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
catchError,
|
||||
map,
|
||||
take,
|
||||
} from 'rxjs/operators';
|
||||
import idFromLink from 'core-app/features/hal/helpers/id-from-link';
|
||||
import { CurrentUserService } from 'core-app/core/current-user/current-user.service';
|
||||
|
||||
export const ProjectAllowedValidator = (currentUser:CurrentUserService) => (control:AbstractControl):Observable<null|{ lackingPermission:boolean }> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const href = (control.value?.href || control.value?.$links?.self.href) as string;
|
||||
const id = (href ? idFromLink(href) : control.value) as string;
|
||||
return currentUser
|
||||
.hasCapabilities$(
|
||||
'memberships/create',
|
||||
id,
|
||||
)
|
||||
.pipe(
|
||||
take(1),
|
||||
map((isAllowed) => (isAllowed ? null : { lackingPermission: true })),
|
||||
catchError(() => of(null)),
|
||||
);
|
||||
};
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
<form
|
||||
class="spot-modal"
|
||||
[formGroup]="projectAndTypeForm"
|
||||
(ngSubmit)="onSubmit($event)"
|
||||
>
|
||||
<div id="spotModalTitle" class="spot-modal--header">{{text.title}}</div>
|
||||
|
||||
<div class="spot-divider"></div>
|
||||
|
||||
<div class="spot-modal--body spot-container">
|
||||
<spot-form-field
|
||||
[label]="text.project.label"
|
||||
required
|
||||
>
|
||||
<op-project-autocompleter slot="input"
|
||||
formControlName="project"
|
||||
[filters]="APIFiltersForProjects"
|
||||
[mapResultsFn]="projectFilterFn.bind(this)"
|
||||
appendTo=".spot-modal-overlay"
|
||||
/>
|
||||
|
||||
@if (projectControl.errors?.lackingPermission) {
|
||||
<div
|
||||
slot="action"
|
||||
>
|
||||
{{ text.project.lackingPermissionInfo }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (projectControl.errors?.required) {
|
||||
<div
|
||||
slot="errors"
|
||||
class="spot-form-field--error"
|
||||
>
|
||||
{{ text.project.required }}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (projectControl.errors?.lackingPermission) {
|
||||
<div
|
||||
slot="errors"
|
||||
class="spot-form-field--error"
|
||||
>
|
||||
{{ text.project.lackingPermission }}
|
||||
</div>
|
||||
}
|
||||
</spot-form-field>
|
||||
|
||||
<spot-form-field>
|
||||
<op-option-list [options]="typeOptions"
|
||||
formControlName="type"
|
||||
slot="input"
|
||||
/>
|
||||
|
||||
@if (projectAndTypeForm?.dirty && typeControl?.invalid) {
|
||||
<div
|
||||
class="spot-form-field--errors"
|
||||
slot="errors"
|
||||
>
|
||||
<div class="spot-form-field--error">
|
||||
{{ text.type.required }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</spot-form-field>
|
||||
</div>
|
||||
|
||||
<div class="spot-action-bar">
|
||||
<div class="spot-action-bar--right">
|
||||
<button
|
||||
type="button"
|
||||
class="button spot-action-bar--action spot-modal--cancel-button"
|
||||
(click)="close.emit()"
|
||||
[textContent]="text.cancelButton"
|
||||
></button>
|
||||
<button
|
||||
type="submit"
|
||||
class="button -primary spot-action-bar--action"
|
||||
[textContent]="text.nextButton"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
-180
@@ -1,180 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnInit,
|
||||
Output,
|
||||
} from '@angular/core';
|
||||
import { AbstractControl, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import { I18nService } from 'core-app/core/i18n/i18n.service';
|
||||
import { BannersService } from 'core-app/core/enterprise/banners.service';
|
||||
import { CurrentUserService } from 'core-app/core/current-user/current-user.service';
|
||||
import { IOpOptionListOption } from 'core-app/shared/components/option-list/option-list.component';
|
||||
import { cloneHalResource } from 'core-app/features/hal/helpers/hal-resource-builder';
|
||||
import { ProjectResource } from 'core-app/features/hal/resources/project-resource';
|
||||
import { PrincipalType } from '../invite-user.component';
|
||||
import { ProjectAllowedValidator } from './project-allowed.validator';
|
||||
import { map } from 'rxjs/operators';
|
||||
import {
|
||||
IProjectAutocompleteItem,
|
||||
} from 'core-app/shared/components/autocompleter/project-autocompleter/project-autocomplete-item';
|
||||
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
|
||||
import idFromLink from 'core-app/features/hal/helpers/id-from-link';
|
||||
import { ICapability } from 'core-app/core/state/capabilities/capability.model';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { IAPIFilter } from 'core-app/shared/components/autocompleter/op-autocompleter/typings';
|
||||
|
||||
@Component({
|
||||
selector: 'op-ium-project-selection',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './project-selection.component.html',
|
||||
standalone: false,
|
||||
})
|
||||
export class ProjectSelectionComponent implements OnInit {
|
||||
@Input() type:PrincipalType;
|
||||
|
||||
@Input() project:ProjectResource|null;
|
||||
|
||||
// eslint-disable-next-line @angular-eslint/no-output-native
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
@Output() save = new EventEmitter<{ project:ProjectResource|null, type:string }>();
|
||||
|
||||
public text = {
|
||||
title: this.I18n.t('js.invite_user_modal.title.invite'),
|
||||
project: {
|
||||
label: this.I18n.t('js.invite_user_modal.project.label'),
|
||||
required: this.I18n.t('js.invite_user_modal.project.required'),
|
||||
lackingPermission: this.I18n.t('js.invite_user_modal.project.lacking_permission'),
|
||||
lackingPermissionInfo: this.I18n.t('js.invite_user_modal.project.lacking_permission_info'),
|
||||
noInviteRights: this.I18n.t('js.invite_user_modal.project.no_invite_rights'),
|
||||
},
|
||||
type: {
|
||||
required: this.I18n.t('js.invite_user_modal.type.required'),
|
||||
},
|
||||
nextButton: this.I18n.t('js.invite_user_modal.project.next_button'),
|
||||
cancelButton: this.I18n.t('js.button_cancel'),
|
||||
};
|
||||
|
||||
public typeOptions:IOpOptionListOption<string>[] = [
|
||||
{
|
||||
value: PrincipalType.User,
|
||||
title: this.I18n.t('js.invite_user_modal.type.user.title'),
|
||||
description: this.I18n.t('js.invite_user_modal.type.user.description'),
|
||||
},
|
||||
{
|
||||
value: PrincipalType.Group,
|
||||
title: this.I18n.t('js.invite_user_modal.type.group.title'),
|
||||
description: this.I18n.t('js.invite_user_modal.type.group.description'),
|
||||
},
|
||||
];
|
||||
|
||||
projectAndTypeForm = new UntypedFormGroup({
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
type: new UntypedFormControl(PrincipalType.User, [Validators.required]),
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
project: new UntypedFormControl(null, [Validators.required], ProjectAllowedValidator(this.currentUserService)),
|
||||
});
|
||||
|
||||
get typeControl():AbstractControl {
|
||||
return this.projectAndTypeForm.get('type') as AbstractControl;
|
||||
}
|
||||
|
||||
get projectControl():AbstractControl {
|
||||
return this.projectAndTypeForm.get('project') as AbstractControl;
|
||||
}
|
||||
|
||||
private projectInviteCapabilities:ICapability[] = [];
|
||||
|
||||
constructor(
|
||||
readonly I18n:I18nService,
|
||||
readonly elementRef:ElementRef,
|
||||
readonly bannersService:BannersService,
|
||||
readonly apiV3Service:ApiV3Service,
|
||||
readonly currentUserService:CurrentUserService,
|
||||
readonly cdRef:ChangeDetectorRef,
|
||||
) {}
|
||||
|
||||
ngOnInit():void {
|
||||
this.typeControl.setValue(this.type);
|
||||
|
||||
if (this.project) {
|
||||
this.projectControl.setValue(cloneHalResource<ProjectResource>(this.project));
|
||||
}
|
||||
|
||||
this.setPlaceholderOption();
|
||||
|
||||
this
|
||||
.currentUserService
|
||||
.capabilities$(['memberships/create'], null)
|
||||
.pipe(
|
||||
map((capabilities) => capabilities.filter((c) => c._links.action.href.endsWith('/memberships/create'))),
|
||||
)
|
||||
.subscribe((projectInviteCapabilities) => {
|
||||
this.projectInviteCapabilities = projectInviteCapabilities;
|
||||
this.cdRef.detectChanges();
|
||||
});
|
||||
}
|
||||
|
||||
private setPlaceholderOption():void {
|
||||
if (this.bannersService.allowsTo('placeholder_users')) {
|
||||
this.typeOptions.push({
|
||||
value: PrincipalType.Placeholder,
|
||||
title: this.I18n.t('js.invite_user_modal.type.placeholder.title'),
|
||||
description: this.I18n.t('js.invite_user_modal.type.placeholder.description'),
|
||||
disabled: false,
|
||||
});
|
||||
} else {
|
||||
this.typeOptions.push({
|
||||
value: PrincipalType.Placeholder,
|
||||
title: this.I18n.t('js.invite_user_modal.type.placeholder.title_no_ee'),
|
||||
description: this.I18n.t('js.invite_user_modal.type.placeholder.description_no_ee', {
|
||||
eeHref: this.bannersService.getEnterPriseEditionUrl({
|
||||
referrer: 'placeholder-users',
|
||||
hash: 'placeholder-users',
|
||||
}),
|
||||
}),
|
||||
disabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async onSubmit($e:Event):Promise<void> {
|
||||
$e.preventDefault();
|
||||
if (this.projectAndTypeForm.invalid) {
|
||||
this.projectAndTypeForm.markAsDirty();
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const projectId = idFromLink(this.projectControl?.value?.href);
|
||||
const project = await firstValueFrom(this.apiV3Service.projects.id(projectId).get());
|
||||
|
||||
this.save.emit({
|
||||
project,
|
||||
type: this.typeControl.value as string,
|
||||
});
|
||||
}
|
||||
|
||||
APIFiltersForProjects:IAPIFilter[] = [{ name: 'active', operator: '=', values: ['t'] }];
|
||||
|
||||
projectFilterFn(projects:IProjectAutocompleteItem[]):IProjectAutocompleteItem[] {
|
||||
const mapped = projects.map((project) => {
|
||||
const disabled = !this.projectInviteCapabilities.find((cap) => idFromLink(cap._links.context.href) === project.id.toString());
|
||||
return {
|
||||
...project,
|
||||
disabled,
|
||||
disabledReason: disabled ? this.text.project.noInviteRights : '',
|
||||
};
|
||||
});
|
||||
|
||||
mapped.sort(
|
||||
(a, b) => (a.disabled ? 1 : 0) - (b.disabled ? 1 : 0),
|
||||
);
|
||||
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<ng-select
|
||||
appendTo=".spot-modal-overlay"
|
||||
[formControl]="spotFormBinding"
|
||||
[typeahead]="input$"
|
||||
[items]="items$ | async"
|
||||
[virtualScroll]="true"
|
||||
[clearable]="true"
|
||||
[clearOnBackspace]="false"
|
||||
[clearSearchOnAdd]="false"
|
||||
bindLabel="name"
|
||||
#ngselect
|
||||
>
|
||||
<!--Selectable option-->
|
||||
<ng-template ng-option-tmp let-item="item" let-search="searchTerm">
|
||||
<div [ngOptionHighlight]="search" class="ng-option-label ellipsis">{{ item.name }}</div>
|
||||
</ng-template>
|
||||
|
||||
<!--Nothing found -->
|
||||
<ng-template ng-notfound-tmp let-searchTerm="searchTerm">
|
||||
<div class="ng-option disabled">
|
||||
{{ text.noRolesFound }}
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
@@ -1,73 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
Input,
|
||||
OnInit,
|
||||
} from '@angular/core';
|
||||
import { UntypedFormControl } from '@angular/forms';
|
||||
import {
|
||||
combineLatest,
|
||||
Observable,
|
||||
Subject,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
} from 'rxjs/operators';
|
||||
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
|
||||
import { I18nService } from 'core-app/core/i18n/i18n.service';
|
||||
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
|
||||
import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder';
|
||||
|
||||
/* eslint-disable-next-line change-detection-strategy/on-push */
|
||||
@Component({
|
||||
selector: 'op-ium-role-search',
|
||||
templateUrl: './role-search.component.html',
|
||||
standalone: false,
|
||||
})
|
||||
export class RoleSearchComponent extends UntilDestroyedMixin implements OnInit {
|
||||
@Input() spotFormBinding:UntypedFormControl;
|
||||
|
||||
public input$ = new Subject<string|null>();
|
||||
|
||||
public roles$ = new Subject<any[]>();
|
||||
|
||||
public items$:Observable<any[]>;
|
||||
|
||||
public text = {
|
||||
noRolesFound: this.I18n.t('js.invite_user_modal.role.no_roles_found'),
|
||||
};
|
||||
|
||||
constructor(
|
||||
readonly I18n:I18nService,
|
||||
readonly elementRef:ElementRef,
|
||||
readonly apiV3Service:ApiV3Service,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.items$ = combineLatest(
|
||||
this.input$
|
||||
.pipe(
|
||||
this.untilDestroyed(),
|
||||
debounceTime(200),
|
||||
filter((input) => typeof input === 'string'),
|
||||
map((input:string) => input.toLowerCase()),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
this.roles$,
|
||||
).pipe(
|
||||
map(([input, roles]:[string, any[]]) => roles.filter((role) => !input || role.name.toLowerCase().indexOf(input) !== -1)),
|
||||
);
|
||||
}
|
||||
|
||||
ngOnInit():void {
|
||||
const filters = new ApiV3FilterBuilder();
|
||||
filters.add('grantable', '=', true);
|
||||
filters.add('unit', '=', ['project']);
|
||||
this.apiV3Service.roles.filtered(filters).get().subscribe(({ elements }) => this.roles$.next(elements));
|
||||
|
||||
setTimeout(() => this.input$.next(''));
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<div
|
||||
id="spotModalTitle"
|
||||
class="spot-modal--header"
|
||||
[textContent]="text.title()"
|
||||
></div>
|
||||
|
||||
<div class="spot-divider"></div>
|
||||
|
||||
<div class="spot-modal--body spot-container">
|
||||
<img class="op-ium-success-image"
|
||||
[src]="type === PrincipalType.Placeholder ? placeholder_image : user_image"/>
|
||||
|
||||
<span [textContent]="text.description[type]()"></span>
|
||||
</div>
|
||||
|
||||
<div class="spot-action-bar">
|
||||
<div class="spot-action-bar--right">
|
||||
<button
|
||||
type="button"
|
||||
class="button -primary spot-action-bar--action"
|
||||
(click)="close.emit()"
|
||||
[textContent]="text.nextButton"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,2 +0,0 @@
|
||||
.op-ium-success-image
|
||||
height: 4rem
|
||||
@@ -1,56 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
EventEmitter,
|
||||
Output,
|
||||
ElementRef,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
|
||||
import { I18nService } from 'core-app/core/i18n/i18n.service';
|
||||
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
|
||||
import { ProjectResource } from 'core-app/features/hal/resources/project-resource';
|
||||
import { imagePath } from 'core-app/shared/helpers/images/path-helper';
|
||||
import { PrincipalType } from '../invite-user.component';
|
||||
|
||||
@Component({
|
||||
selector: 'op-ium-success',
|
||||
templateUrl: './success.component.html',
|
||||
styleUrls: ['./success.component.sass'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class SuccessComponent {
|
||||
@Input() principal:HalResource;
|
||||
|
||||
@Input() project:ProjectResource;
|
||||
|
||||
@Input() type:PrincipalType;
|
||||
|
||||
@Input() createdNewPrincipal:boolean;
|
||||
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
public PrincipalType = PrincipalType;
|
||||
|
||||
user_image = imagePath('invite-user-modal/successful-invite.svg');
|
||||
|
||||
placeholder_image = imagePath('invite-user-modal/placeholder-added.svg');
|
||||
|
||||
public text = {
|
||||
title: () => this.I18n.t('js.invite_user_modal.success.title', {
|
||||
principal: this.createdNewPrincipal ? this.principal.email : this.principal.name,
|
||||
}),
|
||||
description: {
|
||||
User: () => this.I18n.t('js.invite_user_modal.success.description.user', { project: this.project?.name }),
|
||||
PlaceholderUser: () => this.I18n.t('js.invite_user_modal.success.description.placeholder', { project: this.project?.name }),
|
||||
Group: () => this.I18n.t('js.invite_user_modal.success.description.group', { project: this.project?.name }),
|
||||
},
|
||||
nextButton: this.I18n.t('js.invite_user_modal.success.next_button'),
|
||||
};
|
||||
|
||||
constructor(
|
||||
readonly I18n:I18nService,
|
||||
readonly elementRef:ElementRef,
|
||||
) {}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
<form
|
||||
name="sendUserInviteForm"
|
||||
(submit)="onSubmit($event)"
|
||||
class="op-ium-summary spot-modal"
|
||||
>
|
||||
<div id="spotModalTitle" class="spot-modal--header">{{text.title()}}</div>
|
||||
|
||||
<div class="spot-divider"></div>
|
||||
|
||||
<div class="spot-modal--body spot-container">
|
||||
|
||||
<spot-form-field [label]="text.projectLabel">
|
||||
<p slot="input" class="spot-body-small op-ium-summary--details">{{ project.name }}</p>
|
||||
</spot-form-field>
|
||||
|
||||
<spot-form-field [label]="text.principalLabel[type]">
|
||||
<p slot="input" class="spot-body-small op-ium-summary--details">{{ principal?.name }}</p>
|
||||
</spot-form-field>
|
||||
|
||||
<spot-form-field [label]="text.roleLabel()">
|
||||
<p slot="input" class="spot-body-small op-ium-summary--details">{{ role.name }}</p>
|
||||
</spot-form-field>
|
||||
|
||||
@if (type !== PrincipalType.Placeholder && message) {
|
||||
<spot-form-field
|
||||
[label]="text.messageLabel"
|
||||
>
|
||||
<p slot="input" class="spot-body-small op-ium-summary--message">{{ message }}</p>
|
||||
</spot-form-field>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="spot-action-bar">
|
||||
<div class="spot-action-bar--left">
|
||||
<button
|
||||
type="button"
|
||||
class="button button_no-margin spot-action-bar--action"
|
||||
(click)="back.emit()"
|
||||
>
|
||||
<op-icon icon-classes="button--icon icon-arrow-left1" />
|
||||
{{ text.backButton }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="spot-action-bar--right">
|
||||
<button
|
||||
type="button"
|
||||
class="button button_no-margin spot-action-bar--action spot-modal--cancel-button"
|
||||
(click)="close.emit()"
|
||||
>{{ text.cancelButton }}</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="button button_no-margin -primary spot-action-bar--action"
|
||||
>{{ text.nextButton() }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,6 +0,0 @@
|
||||
.op-ium-summary
|
||||
&--message
|
||||
white-space: pre-wrap
|
||||
|
||||
&--details
|
||||
margin-bottom: 0
|
||||
@@ -1,121 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
EventEmitter,
|
||||
Output,
|
||||
ElementRef,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { mapTo, switchMap } from 'rxjs/operators';
|
||||
import { I18nService } from 'core-app/core/i18n/i18n.service';
|
||||
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
|
||||
import { RoleResource } from 'core-app/features/hal/resources/role-resource';
|
||||
import { PrincipalData, PrincipalLike } from 'core-app/shared/components/principal/principal-types';
|
||||
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
|
||||
import { ProjectResource } from 'core-app/features/hal/resources/project-resource';
|
||||
import { PrincipalType } from '../invite-user.component';
|
||||
|
||||
@Component({
|
||||
selector: 'op-ium-summary',
|
||||
templateUrl: './summary.component.html',
|
||||
styleUrls: ['./summary.component.sass'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class SummaryComponent {
|
||||
@Input() type:PrincipalType;
|
||||
|
||||
@Input() project:ProjectResource;
|
||||
|
||||
@Input() role:RoleResource;
|
||||
|
||||
@Input() principalData:PrincipalData;
|
||||
|
||||
@Input() message = '';
|
||||
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
@Output() back = new EventEmitter<void>();
|
||||
|
||||
@Output() save = new EventEmitter();
|
||||
|
||||
public PrincipalType = PrincipalType;
|
||||
|
||||
public text = {
|
||||
title: ():string => this.I18n.t('js.invite_user_modal.title.invite'),
|
||||
projectLabel: this.I18n.t('js.invite_user_modal.project.label'),
|
||||
principalLabel: {
|
||||
User: this.I18n.t('js.invite_user_modal.principal.label.name_or_email'),
|
||||
PlaceholderUser: this.I18n.t('js.invite_user_modal.principal.label.name'),
|
||||
Group: this.I18n.t('js.invite_user_modal.principal.label.name'),
|
||||
},
|
||||
roleLabel: ():string => this.I18n.t('js.invite_user_modal.role.label', {
|
||||
project: this.project?.name,
|
||||
}),
|
||||
messageLabel: this.I18n.t('js.invite_user_modal.message.label'),
|
||||
backButton: this.I18n.t('js.invite_user_modal.back'),
|
||||
cancelButton: this.I18n.t('js.button_cancel'),
|
||||
nextButton: ():string => this.I18n.t('js.invite_user_modal.summary.next_button', {
|
||||
type: this.type,
|
||||
principal: this.principal,
|
||||
}),
|
||||
};
|
||||
|
||||
public get principal():PrincipalLike|null {
|
||||
return this.principalData.principal;
|
||||
}
|
||||
|
||||
constructor(
|
||||
readonly I18n:I18nService,
|
||||
readonly elementRef:ElementRef,
|
||||
readonly api:ApiV3Service,
|
||||
) { }
|
||||
|
||||
invite():Observable<HalResource> {
|
||||
return of(this.principalData)
|
||||
.pipe(
|
||||
switchMap((principalData:PrincipalData) => this.createPrincipal(principalData)),
|
||||
switchMap((principal:HalResource) => this.api.memberships
|
||||
.post({
|
||||
principal,
|
||||
project: this.project,
|
||||
roles: [this.role],
|
||||
notificationMessage: {
|
||||
raw: this.message,
|
||||
},
|
||||
})
|
||||
.pipe(
|
||||
mapTo(principal),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
private createPrincipal(principalData:PrincipalData):Observable<HalResource> {
|
||||
const { principal, customFields } = principalData;
|
||||
if (principal instanceof HalResource) {
|
||||
return of(principal);
|
||||
}
|
||||
|
||||
switch (this.type) {
|
||||
case PrincipalType.User:
|
||||
return this.api.users.post({
|
||||
email: (principal as PrincipalLike).name,
|
||||
status: 'invited',
|
||||
...customFields,
|
||||
});
|
||||
case PrincipalType.Placeholder:
|
||||
return this.api.placeholder_users.post({ name: (principal as PrincipalLike).name });
|
||||
default:
|
||||
throw new Error('Unsupported PrincipalType given');
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit($e:Event):void {
|
||||
$e.preventDefault();
|
||||
|
||||
this
|
||||
.invite()
|
||||
.subscribe((principal) => this.save.emit({ principal }));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,11 +1,11 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { UntypedFormArray } from '@angular/forms';
|
||||
import { I18nService } from 'core-app/core/i18n/i18n.service';
|
||||
import { HalSourceLink } from 'core-app/features/hal/resources/hal-resource';
|
||||
import {
|
||||
IProjectAutocompleteItem,
|
||||
} from 'core-app/shared/components/autocompleter/project-autocompleter/project-autocomplete-item';
|
||||
import { IAPIFilter } from 'core-app/shared/components/autocompleter/op-autocompleter/typings';
|
||||
import { HalSourceLink } from 'core-app/features/hal/interfaces';
|
||||
|
||||
export interface NotificationSettingProjectOption {
|
||||
name:string;
|
||||
|
||||
+1
-3
@@ -1,14 +1,12 @@
|
||||
// noinspection ES6UnusedImports
|
||||
|
||||
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
|
||||
import { UntypedFormArray, UntypedFormControl, UntypedFormGroup } from '@angular/forms';
|
||||
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
|
||||
import { I18nService } from 'core-app/core/i18n/i18n.service';
|
||||
import idFromLink from 'core-app/features/hal/helpers/id-from-link';
|
||||
import { HalSourceLink } from 'core-app/features/hal/resources/hal-resource';
|
||||
import { BannersService } from 'core-app/core/enterprise/banners.service';
|
||||
import { overDueReminderTimes, reminderAvailableTimeframes } from '../overdue-reminder-available-times';
|
||||
import { ConfigurationService } from 'core-app/core/config/configuration.service';
|
||||
import { HalSourceLink } from 'core-app/features/hal/interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'op-notification-settings-table',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HalSourceLink } from 'core-app/features/hal/resources/hal-resource';
|
||||
import { HalSourceLink } from 'core-app/features/hal/interfaces';
|
||||
|
||||
export interface INotificationSetting {
|
||||
_links:{ project:HalSourceLink };
|
||||
|
||||
@@ -52,12 +52,13 @@ import URI from 'urijs';
|
||||
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
|
||||
import { splitViewRoute } from 'core-app/features/work-packages/routing/split-view-routes.helper';
|
||||
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
|
||||
import { HalResource, HalSource } from 'core-app/features/hal/resources/hal-resource';
|
||||
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
|
||||
import { OpTitleService } from 'core-app/core/html/op-title.service';
|
||||
import { WorkPackageCreateService } from './wp-create.service';
|
||||
import { HalError } from 'core-app/features/hal/services/hal-error';
|
||||
import idFromLink from 'core-app/features/hal/helpers/id-from-link';
|
||||
import { CurrentProjectService } from 'core-app/core/current-project/current-project.service';
|
||||
import { HalSource } from 'core-app/features/hal/interfaces';
|
||||
|
||||
@Directive()
|
||||
export class WorkPackageCreateComponent extends UntilDestroyedMixin implements OnInit {
|
||||
|
||||
@@ -54,8 +54,6 @@ import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destr
|
||||
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
|
||||
import {
|
||||
HalResource,
|
||||
HalSource,
|
||||
HalSourceLink,
|
||||
} from 'core-app/features/hal/resources/hal-resource';
|
||||
import idFromLink from 'core-app/features/hal/helpers/id-from-link';
|
||||
import { SchemaResource } from 'core-app/features/hal/resources/schema-resource';
|
||||
@@ -64,6 +62,7 @@ import { HalResourceService } from 'core-app/features/hal/services/hal-resource.
|
||||
import { ResourceChangeset } from 'core-app/shared/components/fields/changeset/resource-changeset';
|
||||
import { AttachmentsResourceService } from 'core-app/core/state/attachments/attachments.service';
|
||||
import { AttachmentCollectionResource } from 'core-app/features/hal/resources/attachment-collection-resource';
|
||||
import { HalSource, HalSourceLink } from 'core-app/features/hal/interfaces';
|
||||
|
||||
export const newWorkPackageHref = '/api/v3/work_packages/new';
|
||||
|
||||
|
||||
-3
@@ -44,7 +44,6 @@ import { ComponentType } from '@angular/cdk/overlay';
|
||||
import { Ng2StateDeclaration } from '@uirouter/angular';
|
||||
import { I18nService } from 'core-app/core/i18n/i18n.service';
|
||||
import { OpModalService } from 'core-app/shared/components/modal/modal.service';
|
||||
import { InviteUserModalComponent } from 'core-app/features/invite-user-modal/invite-user.component';
|
||||
import { WorkPackageFilterContainerComponent } from 'core-app/features/work-packages/components/filters/filter-container/filter-container.directive';
|
||||
import isPersistedResource from 'core-app/features/hal/helpers/is-persisted-resource';
|
||||
import { UIRouterGlobals } from '@uirouter/core';
|
||||
@@ -275,8 +274,6 @@ export class PartitionedQuerySpacePageComponent extends WorkPackagesViewBase imp
|
||||
}
|
||||
}
|
||||
|
||||
protected inviteModal = InviteUserModalComponent;
|
||||
|
||||
protected loadQuery(firstPage = false):Promise<QueryResource> {
|
||||
let promise:Promise<QueryResource>;
|
||||
const query = this.currentQuery;
|
||||
|
||||
+47
-4
@@ -1,20 +1,63 @@
|
||||
import { Observable } from 'rxjs';
|
||||
import { firstValueFrom, Observable } from 'rxjs';
|
||||
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
|
||||
import { HttpParams } from '@angular/common/http';
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, inject, Input, OnInit } from '@angular/core';
|
||||
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
|
||||
import {
|
||||
IUserAutocompleteItem,
|
||||
UserAutocompleterComponent,
|
||||
} from 'core-app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component';
|
||||
import { URLParamsEncoder } from 'core-app/features/hal/services/url-params-encoder';
|
||||
import { PrincipalType } from 'core-app/shared/components/principal/principal-helper';
|
||||
import { CurrentUserService } from 'core-app/core/current-user/current-user.service';
|
||||
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ID } from '@datorama/akita';
|
||||
|
||||
@Component({
|
||||
templateUrl: '../op-autocompleter/op-autocompleter.component.html',
|
||||
standalone: false,
|
||||
})
|
||||
export class MembersAutocompleterComponent extends UserAutocompleterComponent {
|
||||
@InjectField() pathHelper:PathHelperService;
|
||||
export class MembersAutocompleterComponent extends UserAutocompleterComponent implements OnInit {
|
||||
@Input() principalType?:PrincipalType;
|
||||
|
||||
readonly pathHelper = inject(PathHelperService);
|
||||
readonly currentUser = inject(CurrentUserService);
|
||||
readonly apiV3Service = inject(ApiV3Service);
|
||||
|
||||
ngOnInit() {
|
||||
super.ngOnInit();
|
||||
|
||||
if (this.principalType === 'placeholder_user') {
|
||||
this
|
||||
.currentUser
|
||||
.hasCapabilities$('placeholder_users/create', 'global')
|
||||
.subscribe((canCreate) => {
|
||||
if (canCreate) {
|
||||
this.addTag = this.createPlaceholderUser.bind(this);
|
||||
this.addTagText = this.I18n.t('js.invite_user_modal.placeholder_add_tag');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public createPlaceholderUser(searchTerm:string):Promise<IUserAutocompleteItem> {
|
||||
const request = this
|
||||
.apiV3Service
|
||||
.placeholder_users
|
||||
.post({ name: searchTerm })
|
||||
.pipe(
|
||||
map((principal) => {
|
||||
return {
|
||||
id: principal.id as ID,
|
||||
name: principal.name,
|
||||
href: principal.href,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return firstValueFrom(request);
|
||||
}
|
||||
|
||||
public getAvailableUsers(searchTerm:string):Observable<IUserAutocompleteItem[]> {
|
||||
return this
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@ export class OpAutocompleterComponent<T extends IAutocompleteItem = IAutocomplet
|
||||
|
||||
@Input() public placeholder:string = this.I18n.t('js.autocompleter.placeholder');
|
||||
@Input() public notFoundText:string = this.I18n.t('js.autocompleter.notFoundText');
|
||||
@Input() public addTagText?:string;
|
||||
@Input() public addTagText?:string = this.I18n.t('js.autocomplete_ng_select.add_tag');
|
||||
@Input() public ariaLabel?:string = this.I18n.t('js.autocompleter.search');
|
||||
|
||||
@Input() public loadingText:string = this.I18n.t('js.ajax.loading');
|
||||
|
||||
-4
@@ -28,7 +28,6 @@
|
||||
|
||||
import { ChangeDetectionStrategy, Component, forwardRef, OnInit } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { OpInviteUserModalService } from 'core-app/features/invite-user-modal/invite-user-modal.service';
|
||||
import {
|
||||
OpAutocompleterComponent,
|
||||
} from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component';
|
||||
@@ -59,9 +58,6 @@ const RECENT_TIME_ENTRIES_MAGIC_NUMBER = 30;
|
||||
useExisting: forwardRef(() => TimeEntriesWorkPackageAutocompleterComponent),
|
||||
multi: true,
|
||||
},
|
||||
// Provide a new version of the modal invite service,
|
||||
// as otherwise the close event will be shared across all instances
|
||||
OpInviteUserModalService,
|
||||
],
|
||||
standalone: false,
|
||||
})
|
||||
|
||||
+12
-17
@@ -31,29 +31,27 @@ import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
forwardRef,
|
||||
inject,
|
||||
Input,
|
||||
OnInit,
|
||||
Output,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
filter,
|
||||
map,
|
||||
} from 'rxjs/operators';
|
||||
import {
|
||||
ControlValueAccessor,
|
||||
NG_VALUE_ACCESSOR,
|
||||
} from '@angular/forms';
|
||||
import { filter, map } from 'rxjs/operators';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { ID } from '@datorama/akita';
|
||||
import { OpInviteUserModalService } from 'core-app/features/invite-user-modal/invite-user-modal.service';
|
||||
import { OpInviteUserDialogService } from 'core-app/features/invite-user-modal/invite-user-dialog.service';
|
||||
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
|
||||
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
|
||||
import { IHALCollection } from 'core-app/core/apiv3/types/hal-collection.type';
|
||||
import { OpAutocompleterComponent } from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component';
|
||||
import {
|
||||
OpAutocompleterComponent,
|
||||
} from 'core-app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component';
|
||||
import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder';
|
||||
import { addFiltersToPath } from 'core-app/core/apiv3/helpers/add-filters-to-path';
|
||||
import { UserAutocompleterTemplateComponent } from 'core-app/shared/components/autocompleter/user-autocompleter/user-autocompleter-template.component';
|
||||
import {
|
||||
UserAutocompleterTemplateComponent,
|
||||
} from 'core-app/shared/components/autocompleter/user-autocompleter/user-autocompleter-template.component';
|
||||
import { IUser } from 'core-app/core/state/principals/user.model';
|
||||
import { compareByAttribute } from 'core-app/shared/helpers/angular/tracking-functions';
|
||||
|
||||
@@ -77,9 +75,6 @@ export interface IUserAutocompleteItem {
|
||||
useExisting: forwardRef(() => UserAutocompleterComponent),
|
||||
multi: true,
|
||||
},
|
||||
// Provide a new version of the modal invite service,
|
||||
// as otherwise the close event will be shared across all instances
|
||||
OpInviteUserModalService,
|
||||
],
|
||||
styleUrls: ['./user-autocompleter.component.sass'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
@@ -97,7 +92,7 @@ export class UserAutocompleterComponent extends OpAutocompleterComponent<IUserAu
|
||||
|
||||
@Output() public userInvited = new EventEmitter<HalResource>();
|
||||
|
||||
@InjectField(OpInviteUserModalService) opInviteUserModalService:OpInviteUserModalService;
|
||||
readonly opInviteUserDialogService = inject(OpInviteUserDialogService);
|
||||
|
||||
getOptionsFn = this.getAvailableUsers.bind(this);
|
||||
|
||||
@@ -111,7 +106,7 @@ export class UserAutocompleterComponent extends OpAutocompleterComponent<IUserAu
|
||||
});
|
||||
|
||||
this
|
||||
.opInviteUserModalService
|
||||
.opInviteUserDialogService
|
||||
.close
|
||||
.pipe(
|
||||
this.untilDestroyed(),
|
||||
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
@if (to?.collapsibleFieldGroups) {
|
||||
<fieldset
|
||||
class="op-fieldset op-fieldset_collapsible"
|
||||
[ngClass]="{'op-fieldset_collapsed': to.collapsibleFieldGroupsCollapsed}"
|
||||
>
|
||||
<legend class="op-fieldset--legend">
|
||||
<button
|
||||
title="Show/hide"
|
||||
type="button"
|
||||
class="op-fieldset--toggle"
|
||||
(click)="to.collapsibleFieldGroupsCollapsed = !to.collapsibleFieldGroupsCollapsed"
|
||||
>
|
||||
{{ to.label }}
|
||||
</button>
|
||||
</legend>
|
||||
<div class="op-fieldset--fields">
|
||||
<ng-container #fieldComponent />
|
||||
</div>
|
||||
</fieldset>
|
||||
}
|
||||
|
||||
@if (!to?.collapsibleFieldGroups) {
|
||||
<fieldset
|
||||
class="op-fieldset"
|
||||
>
|
||||
<legend class="op-fieldset--legend">{{ to.label }}</legend>
|
||||
<div class="op-fieldset--fields">
|
||||
<ng-container #fieldComponent />
|
||||
</div>
|
||||
</fieldset>
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { FieldWrapper } from '@ngx-formly/core';
|
||||
|
||||
@Component({
|
||||
selector: 'op-dynamic-field-group-wrapper',
|
||||
templateUrl: './dynamic-field-group-wrapper.component.html',
|
||||
styleUrls: ['./dynamic-field-group-wrapper.component.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class DynamicFieldGroupWrapperComponent extends FieldWrapper {
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
@if (field.type !== 'booleanInput') {
|
||||
<spot-form-field
|
||||
[control]="field?.formControl"
|
||||
[label]="to?.label"
|
||||
[hidden]="field?.hide"
|
||||
[noWrapLabel]="to?.noWrapLabel"
|
||||
[required]="to?.required"
|
||||
[helpTextAttribute]="to?.property"
|
||||
[helpTextAttributeScope]="to?.helpTextAttributeScope || dynamicFormComponent?.helpTextAttributeScope"
|
||||
[showValidationErrorOn]="to?.showValidationErrorOn || dynamicFormComponent?.showValidationErrorsOn"
|
||||
[attr.data-qa-field-name]="to?.property"
|
||||
>
|
||||
<ng-container #fieldComponent slot="input" />
|
||||
<attribute-help-text slot="help-text"
|
||||
class="spot-form-field--help-text"
|
||||
[attribute]="to?.property"
|
||||
[attributeScope]="to?.helpTextAttributeScope || dynamicFormComponent?.helpTextAttributeScope"
|
||||
/>
|
||||
<formly-validation-message class="spot-form-field--error"
|
||||
[field]="field"
|
||||
slot="errors"
|
||||
/>
|
||||
</spot-form-field>
|
||||
}
|
||||
|
||||
@if (field.type === 'booleanInput') {
|
||||
<spot-selector-field
|
||||
[control]="field?.formControl"
|
||||
[label]="to?.label"
|
||||
[hidden]="field?.hide"
|
||||
[required]="to?.required"
|
||||
[showValidationErrorOn]="to?.showValidationErrorOn || dynamicFormComponent?.showValidationErrorsOn"
|
||||
[attr.data-qa-field-name]="to?.property"
|
||||
>
|
||||
<ng-container #fieldComponent slot="input" />
|
||||
<attribute-help-text slot="help-text"
|
||||
class="spot-form-field--help-text"
|
||||
[attribute]="to?.property"
|
||||
[attributeScope]="to?.helpTextAttributeScope || dynamicFormComponent?.helpTextAttributeScope"
|
||||
/>
|
||||
<formly-validation-message class="spot-form-field--error"
|
||||
[field]="field"
|
||||
slot="errors"
|
||||
/>
|
||||
</spot-selector-field>
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, Optional } from '@angular/core';
|
||||
import { FieldWrapper } from '@ngx-formly/core';
|
||||
import { DynamicFormComponent } from 'core-app/shared/components/dynamic-forms/components/dynamic-form/dynamic-form.component';
|
||||
|
||||
@Component({
|
||||
selector: 'op-dynamic-field-wrapper',
|
||||
templateUrl: './dynamic-field-wrapper.component.html',
|
||||
styleUrls: ['./dynamic-field-wrapper.component.sass'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: false,
|
||||
})
|
||||
export class DynamicFieldWrapperComponent extends FieldWrapper {
|
||||
constructor(
|
||||
@Optional() public dynamicFormComponent:DynamicFormComponent,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
@if (form && handleSubmit) {
|
||||
<form
|
||||
data-qa="op-form--container"
|
||||
class="op-form"
|
||||
[formGroup]="form"
|
||||
(submit)="submitForm(form)"
|
||||
>
|
||||
<formly-form [form]="form"
|
||||
[model]="innerModel"
|
||||
[fields]="fields"
|
||||
(modelChange)="onModelChange($event)"
|
||||
class="op-form--fieldset"
|
||||
/>
|
||||
@if (handleSubmit) {
|
||||
<div class="op-form--submit"
|
||||
>
|
||||
<button type="submit"
|
||||
class="button -primary"
|
||||
[disabled]="inFlight">
|
||||
{{text.save}}
|
||||
</button>
|
||||
<button type="button"
|
||||
class="button"
|
||||
(click)="handleCancel()">
|
||||
{{text.cancel}}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
|
||||
<!-- When used as a FormControl, the Dynamic Form doesn't need a wrapping form -->
|
||||
<!-- TODO: Issue: sharing the form as an ng-template between this two HTML blocks doesn't work because
|
||||
the nested OpFormFieldComponent doesn't find the injected FormGroupDirective. --->
|
||||
@if (form && !handleSubmit) {
|
||||
<formly-form data-qa="op-form--container"
|
||||
class="op-form--fieldset"
|
||||
[form]="form"
|
||||
[model]="innerModel"
|
||||
[fields]="fields"
|
||||
(modelChange)="onModelChange($event)"
|
||||
/>
|
||||
}
|
||||
-511
@@ -1,511 +0,0 @@
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { NgOptionHighlightDirective } from '@ng-select/ng-option-highlight';
|
||||
import {
|
||||
Component,
|
||||
forwardRef,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
ComponentFixture,
|
||||
fakeAsync,
|
||||
flush,
|
||||
TestBed,
|
||||
} from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import {
|
||||
defer,
|
||||
of,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
NG_VALUE_ACCESSOR,
|
||||
ReactiveFormsModule,
|
||||
UntypedFormControl,
|
||||
UntypedFormGroup,
|
||||
} from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormlyModule } from '@ngx-formly/core';
|
||||
import { DynamicFormComponent } from 'core-app/shared/components/dynamic-forms/components/dynamic-form/dynamic-form.component';
|
||||
import { DynamicFormService } from 'core-app/shared/components/dynamic-forms/services/dynamic-form/dynamic-form.service';
|
||||
import { DynamicFieldsService } from 'core-app/shared/components/dynamic-forms/services/dynamic-fields/dynamic-fields.service';
|
||||
import { I18nService } from 'core-app/core/i18n/i18n.service';
|
||||
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
|
||||
import { ToastService } from 'core-app/shared/components/toaster/toast.service';
|
||||
import { TextInputComponent } from 'core-app/shared/components/dynamic-forms/components/dynamic-inputs/text-input/text-input.component';
|
||||
import { IntegerInputComponent } from 'core-app/shared/components/dynamic-forms/components/dynamic-inputs/integer-input/integer-input.component';
|
||||
import { SelectInputComponent } from 'core-app/shared/components/dynamic-forms/components/dynamic-inputs/select-input/select-input.component';
|
||||
import { BooleanInputComponent } from 'core-app/shared/components/dynamic-forms/components/dynamic-inputs/boolean-input/boolean-input.component';
|
||||
import { DateInputComponent } from 'core-app/shared/components/dynamic-forms/components/dynamic-inputs/date-input/date-input.component';
|
||||
import { FormattableTextareaInputComponent } from 'core-app/shared/components/dynamic-forms/components/dynamic-inputs/formattable-textarea-input/formattable-textarea-input.component';
|
||||
import { DynamicFieldGroupWrapperComponent } from 'core-app/shared/components/dynamic-forms/components/dynamic-field-group-wrapper/dynamic-field-group-wrapper.component';
|
||||
import { SpotFormFieldComponent } from 'core-app/spot/components/form-field/form-field.component';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { DynamicFieldWrapperComponent } from 'core-app/shared/components/dynamic-forms/components/dynamic-field-wrapper/dynamic-field-wrapper.component';
|
||||
import { ConfirmDialogService } from "core-app/shared/components/modals/confirm-dialog/confirm-dialog.service";
|
||||
import { IOPDynamicFormSettings } from 'core-app/shared/components/dynamic-forms/typings';
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<op-dynamic-form [formControl]="control" />`,
|
||||
providers: [],
|
||||
standalone: false,
|
||||
})
|
||||
class DynamicFormsTestingComponent {
|
||||
control = new UntypedFormControl('');
|
||||
|
||||
@ViewChild(DynamicFormComponent) dynamicFormControl:DynamicFormComponent;
|
||||
}
|
||||
|
||||
describe('DynamicFormComponent', () => {
|
||||
let component:DynamicFormComponent;
|
||||
let fixture:ComponentFixture<DynamicFormComponent>;
|
||||
const formSchema:any = {
|
||||
_type: 'Form',
|
||||
_embedded: {
|
||||
payload: {
|
||||
name: 'Project 1',
|
||||
_links: {
|
||||
parent: {
|
||||
href: '/api/v3/projects/26',
|
||||
title: 'Parent project',
|
||||
},
|
||||
},
|
||||
},
|
||||
schema: {
|
||||
_type: 'Schema',
|
||||
_dependencies: [],
|
||||
name: {
|
||||
type: 'String',
|
||||
name: 'Name',
|
||||
required: true,
|
||||
hasDefault: false,
|
||||
writable: true,
|
||||
minLength: 1,
|
||||
maxLength: 255,
|
||||
options: {},
|
||||
},
|
||||
parent: {
|
||||
type: 'Project',
|
||||
name: 'Subproject of',
|
||||
required: false,
|
||||
hasDefault: false,
|
||||
writable: true,
|
||||
_links: {
|
||||
allowedValues: {
|
||||
href: '/api/v3/projects/available_parent_projects?of=25',
|
||||
},
|
||||
},
|
||||
},
|
||||
_links: {},
|
||||
},
|
||||
validationErrors: {},
|
||||
},
|
||||
_links: {
|
||||
self: {
|
||||
href: '/api/v3/projects/25/form',
|
||||
method: 'post',
|
||||
},
|
||||
validate: {
|
||||
href: '/api/v3/projects/25/form',
|
||||
method: 'post',
|
||||
},
|
||||
commit: {
|
||||
href: '/api/v3/projects/25',
|
||||
method: 'patch',
|
||||
},
|
||||
},
|
||||
};
|
||||
const dynamicFormSettings:IOPDynamicFormSettings = {
|
||||
fields: [
|
||||
{
|
||||
type: 'textInput',
|
||||
key: 'name',
|
||||
templateOptions: {
|
||||
required: true,
|
||||
label: 'Name',
|
||||
type: 'text',
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'integerInput',
|
||||
key: 'quantity',
|
||||
templateOptions: {
|
||||
required: true,
|
||||
label: 'Quantity',
|
||||
type: 'number',
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'textInput',
|
||||
key: 'identifier',
|
||||
templateOptions: {
|
||||
required: true,
|
||||
label: 'Identifier',
|
||||
type: 'text',
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'formattableInput',
|
||||
key: 'description',
|
||||
templateOptions: {
|
||||
required: false,
|
||||
label: 'Description',
|
||||
editorType: 'full',
|
||||
inlineLabel: true,
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'booleanInput',
|
||||
key: 'public',
|
||||
templateOptions: {
|
||||
required: true,
|
||||
label: 'Public',
|
||||
type: 'checkbox',
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'booleanInput',
|
||||
key: 'active',
|
||||
templateOptions: {
|
||||
required: true,
|
||||
label: 'Active',
|
||||
type: 'checkbox',
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'selectInput',
|
||||
expressionProperties: {},
|
||||
key: 'status',
|
||||
templateOptions: {
|
||||
required: false,
|
||||
label: 'Status',
|
||||
type: 'number',
|
||||
locale: 'en',
|
||||
bindLabel: 'title',
|
||||
searchable: false,
|
||||
virtualScroll: true,
|
||||
typeahead: false,
|
||||
clearOnBackspace: false,
|
||||
clearSearchOnAdd: false,
|
||||
hideSelected: false,
|
||||
text: {
|
||||
add_new_action: 'Create',
|
||||
},
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
clearable: true,
|
||||
multiple: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'formattableInput',
|
||||
key: 'statusExplanation',
|
||||
templateOptions: {
|
||||
required: false,
|
||||
label: 'Status description',
|
||||
editorType: 'full',
|
||||
inlineLabel: true,
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'selectInput',
|
||||
expressionProperties: {},
|
||||
key: '_links.parent',
|
||||
templateOptions: {
|
||||
required: false,
|
||||
label: 'Subproject of',
|
||||
type: 'number',
|
||||
locale: 'en',
|
||||
bindLabel: 'title',
|
||||
searchable: false,
|
||||
virtualScroll: true,
|
||||
typeahead: false,
|
||||
clearOnBackspace: false,
|
||||
clearSearchOnAdd: false,
|
||||
hideSelected: false,
|
||||
text: {
|
||||
add_new_action: 'Create',
|
||||
},
|
||||
options: of([]),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'dateInput',
|
||||
key: 'customField12',
|
||||
templateOptions: {
|
||||
required: false,
|
||||
label: 'Date',
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
model: {
|
||||
identifier: 'test11',
|
||||
name: 'qwe',
|
||||
active: true,
|
||||
public: false,
|
||||
description: {
|
||||
format: 'markdown',
|
||||
raw: 'asdadsad',
|
||||
html: '<p class="op-uc-p">asdadsad</p>',
|
||||
},
|
||||
status: null,
|
||||
statusExplanation: {
|
||||
format: 'markdown',
|
||||
raw: null,
|
||||
html: '',
|
||||
},
|
||||
customField12: null,
|
||||
_links: {
|
||||
parent: {
|
||||
href: '/api/v3/projects/23',
|
||||
title: 'qweqwe',
|
||||
name: 'qweqwe',
|
||||
},
|
||||
},
|
||||
},
|
||||
form: new UntypedFormGroup({}),
|
||||
};
|
||||
const I18nServiceStub = {
|
||||
t(key:string) {
|
||||
return 'test translation';
|
||||
},
|
||||
};
|
||||
const apiV3Base = 'http://www.openproject.com/api/v3/';
|
||||
const IPathHelperServiceStub = { api: { v3: { apiV3Base } } };
|
||||
let toastService:jasmine.SpyObj<ToastService>;
|
||||
let dynamicFormService:jasmine.SpyObj<DynamicFormService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const toastServiceSpy = jasmine.createSpyObj('ToastService', ['addError', 'addSuccess']);
|
||||
const dynamicFormServiceSpy = jasmine.createSpyObj('DynamicFormService', ['getSettings', 'getSettingsFromBackend$', 'registerForm', 'submit$']);
|
||||
const confirmDialogServiceSpy = jasmine.createSpyObj('ConfirmDialogService', ['confirm']);
|
||||
|
||||
await TestBed
|
||||
.configureTestingModule({
|
||||
declarations: [
|
||||
DynamicFormComponent,
|
||||
SpotFormFieldComponent,
|
||||
TextInputComponent,
|
||||
IntegerInputComponent,
|
||||
SelectInputComponent,
|
||||
BooleanInputComponent,
|
||||
DynamicFormsTestingComponent,
|
||||
DynamicFieldGroupWrapperComponent,
|
||||
DynamicFieldWrapperComponent,
|
||||
// Skip adding DateInputComponent and FormattableTextareaInputComponent
|
||||
// to keep it simple (inheritance test issues).
|
||||
],
|
||||
imports: [CommonModule,
|
||||
ReactiveFormsModule,
|
||||
FormlyModule.forRoot({
|
||||
types: [
|
||||
{ name: 'textInput', component: TextInputComponent },
|
||||
{ name: 'integerInput', component: IntegerInputComponent },
|
||||
{ name: 'selectInput', component: SelectInputComponent },
|
||||
{ name: 'booleanInput', component: BooleanInputComponent },
|
||||
{ name: 'dateInput', component: DateInputComponent },
|
||||
{ name: 'formattableInput', component: FormattableTextareaInputComponent },
|
||||
],
|
||||
wrappers: [
|
||||
{
|
||||
name: 'op-dynamic-field-group-wrapper',
|
||||
component: DynamicFieldGroupWrapperComponent,
|
||||
},
|
||||
{
|
||||
name: 'op-dynamic-field-wrapper',
|
||||
component: DynamicFieldWrapperComponent,
|
||||
},
|
||||
],
|
||||
}),
|
||||
NgSelectModule,
|
||||
NgOptionHighlightDirective],
|
||||
providers: [
|
||||
DynamicFieldsService,
|
||||
{ provide: I18nService, useValue: I18nServiceStub },
|
||||
{ provide: PathHelperService, useValue: IPathHelperServiceStub },
|
||||
{ provide: ToastService, useValue: toastServiceSpy },
|
||||
{ provide: ConfirmDialogService, useValue: confirmDialogServiceSpy },
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
})
|
||||
// Set component providers
|
||||
.overrideComponent(
|
||||
DynamicFormComponent,
|
||||
{
|
||||
set: {
|
||||
providers: [
|
||||
{
|
||||
provide: DynamicFormService,
|
||||
useValue: dynamicFormServiceSpy,
|
||||
},
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
multi: true,
|
||||
useExisting: forwardRef(() => DynamicFormComponent),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DynamicFormComponent);
|
||||
component = fixture.componentInstance;
|
||||
toastService = fixture.debugElement.injector.get(ToastService) as jasmine.SpyObj<ToastService>;
|
||||
dynamicFormService = fixture.debugElement.injector.get(DynamicFormService) as jasmine.SpyObj<DynamicFormService>;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should get the form schema from the backend when no @Input settings', fakeAsync(() => {
|
||||
// @ts-ignore
|
||||
dynamicFormService.getSettingsFromBackend$.and.returnValue(defer(() => Promise.resolve(dynamicFormSettings)));
|
||||
|
||||
component.resourcePath = '/api/v3/projects/1234/form';
|
||||
component.ngOnChanges({ resourcePath: { currentValue: '/api/v3/projects/1234/form' } } as any);
|
||||
|
||||
expect(dynamicFormService.getSettingsFromBackend$).toHaveBeenCalled();
|
||||
|
||||
fixture.detectChanges();
|
||||
flush();
|
||||
|
||||
expect(fixture.debugElement.query(By.css('[data-qa="op-form--container"]'))).toBeTruthy();
|
||||
expect(fixture.debugElement.queryAll(By.css('formly-form')).length).toEqual(1);
|
||||
expect(fixture.debugElement.queryAll(By.css('formly-field * > formly-field')).length).toEqual(10);
|
||||
expect(fixture.debugElement.queryAll(By.css('op-text-input')).length).toEqual(2);
|
||||
expect(fixture.debugElement.queryAll(By.css('op-formattable-textarea-input')).length).toEqual(2);
|
||||
expect(fixture.debugElement.queryAll(By.css('op-select-input')).length).toEqual(2);
|
||||
expect(fixture.debugElement.queryAll(By.css('op-date-input')).length).toEqual(1);
|
||||
expect(fixture.debugElement.queryAll(By.css('op-boolean-input')).length).toEqual(2);
|
||||
expect(fixture.debugElement.queryAll(By.css('op-integer-input')).length).toEqual(1);
|
||||
expect(fixture.debugElement.query(By.css('button[type=submit]'))).toBeTruthy();
|
||||
}));
|
||||
|
||||
it('should get the form schema from @Input settings when present', fakeAsync(() => {
|
||||
// @ts-ignore
|
||||
dynamicFormService.getSettings.and.returnValue(dynamicFormSettings);
|
||||
|
||||
component.resourcePath = '/api/v3/projects/1234/form';
|
||||
component.settings = {
|
||||
payload: formSchema._embedded.payload,
|
||||
schema: formSchema._embedded.schema,
|
||||
};
|
||||
|
||||
component.ngOnChanges({ settings: { currentValue: component.settings } } as any);
|
||||
|
||||
expect(dynamicFormService.getSettings).toHaveBeenCalled();
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.debugElement.query(By.css('[data-qa="op-form--container"]'))).toBeTruthy();
|
||||
expect(fixture.debugElement.queryAll(By.css('formly-form')).length).toEqual(1);
|
||||
expect(fixture.debugElement.queryAll(By.css('formly-field * > formly-field')).length).toEqual(10);
|
||||
expect(fixture.debugElement.queryAll(By.css('op-text-input')).length).toEqual(2);
|
||||
expect(fixture.debugElement.queryAll(By.css('op-formattable-textarea-input')).length).toEqual(2);
|
||||
expect(fixture.debugElement.queryAll(By.css('op-select-input')).length).toEqual(2);
|
||||
expect(fixture.debugElement.queryAll(By.css('op-date-input')).length).toEqual(1);
|
||||
expect(fixture.debugElement.queryAll(By.css('op-boolean-input')).length).toEqual(2);
|
||||
expect(fixture.debugElement.queryAll(By.css('op-integer-input')).length).toEqual(1);
|
||||
expect(fixture.debugElement.query(By.css('button[type=submit]'))).toBeTruthy();
|
||||
}));
|
||||
|
||||
it('should submit the form and notify the user', fakeAsync(() => {
|
||||
// @ts-ignore
|
||||
dynamicFormService.getSettingsFromBackend$.and.returnValue(defer(() => Promise.resolve(dynamicFormSettings)));
|
||||
dynamicFormService.submit$.and.returnValue(defer(() => Promise.resolve('ok')));
|
||||
let submitButton;
|
||||
|
||||
// Should not show notifications when showNotifications === false
|
||||
component.showNotifications = false;
|
||||
|
||||
component.resourcePath = '/api/v3/projects/1234/form';
|
||||
component.ngOnChanges({ resourcePath: { currentValue: '/api/v3/projects/1234/form' } } as any);
|
||||
flush();
|
||||
fixture.detectChanges();
|
||||
submitButton = fixture.debugElement.query(By.css('button[type=submit]'));
|
||||
submitButton.nativeElement.click();
|
||||
|
||||
flush();
|
||||
|
||||
expect(dynamicFormService.submit$).toHaveBeenCalled();
|
||||
expect(toastService.addSuccess).not.toHaveBeenCalled();
|
||||
|
||||
// Should not show notifications when showNotifications === true
|
||||
component.showNotifications = true;
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
submitButton = fixture.debugElement.query(By.css('button[type=submit]'));
|
||||
submitButton.nativeElement.click();
|
||||
|
||||
flush();
|
||||
|
||||
expect(dynamicFormService.submit$).toHaveBeenCalled();
|
||||
expect(toastService.addSuccess).toHaveBeenCalled();
|
||||
|
||||
dynamicFormService.submit$.and.returnValue(defer(() => {
|
||||
throw new Error('Error');
|
||||
}));
|
||||
|
||||
submitButton.nativeElement.click();
|
||||
|
||||
flush();
|
||||
|
||||
expect(toastService.addError).toHaveBeenCalled();
|
||||
|
||||
dynamicFormService.submit$.and.returnValue(defer(() => Promise.resolve('ok')));
|
||||
}));
|
||||
|
||||
// Moving the DynamicForm.form assignment out of the _setupDynamicForm breaks the
|
||||
// expressionProperties execution
|
||||
it('should run expressionProperties', fakeAsync(() => {
|
||||
const [firstField, ...restOfFields] = dynamicFormSettings.fields;
|
||||
const expressionPropertiesSpy = jasmine.createSpy('expressionPropertiesSpy');
|
||||
const firstFieldCopy = {
|
||||
...firstField,
|
||||
expressionProperties: {
|
||||
'templateOptions.test': expressionPropertiesSpy,
|
||||
},
|
||||
};
|
||||
const dynamicFormSettingsForSubmit = {
|
||||
...dynamicFormSettings,
|
||||
fields: [
|
||||
firstFieldCopy,
|
||||
...restOfFields,
|
||||
],
|
||||
};
|
||||
// @ts-ignore
|
||||
dynamicFormService.getSettingsFromBackend$.and.returnValue(defer(() => Promise.resolve(dynamicFormSettingsForSubmit)));
|
||||
dynamicFormService.submit$.and.returnValue(defer(() => Promise.resolve('ok')));
|
||||
|
||||
component.resourcePath = '/api/v3/projects/1234/form';
|
||||
component.ngOnChanges({ resourcePath: { currentValue: '/api/v3/projects/1234/form' } } as any);
|
||||
flush();
|
||||
fixture.detectChanges();
|
||||
const submitButton = fixture.debugElement.query(By.css('button[type=submit]'));
|
||||
submitButton.nativeElement.click();
|
||||
|
||||
flush();
|
||||
|
||||
expect(expressionPropertiesSpy).toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
-416
@@ -1,416 +0,0 @@
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { FormlyForm } from '@ngx-formly/core';
|
||||
import { I18nService } from 'core-app/core/i18n/i18n.service';
|
||||
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
|
||||
import {
|
||||
catchError,
|
||||
finalize,
|
||||
} from 'rxjs/operators';
|
||||
import { HalSource } from 'core-app/features/hal/resources/hal-resource';
|
||||
import { ToastService } from 'core-app/shared/components/toaster/toast.service';
|
||||
import { DynamicFieldsService } from 'core-app/shared/components/dynamic-forms/services/dynamic-fields/dynamic-fields.service';
|
||||
import { UntypedFormGroup } from '@angular/forms';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
|
||||
import { ConfirmDialogService } from 'core-app/shared/components/modals/confirm-dialog/confirm-dialog.service';
|
||||
import {
|
||||
IDynamicFieldGroupConfig,
|
||||
IOPDynamicFormSettings,
|
||||
IOPFormlyFieldSettings,
|
||||
} from '../../typings';
|
||||
import { DynamicFormService } from '../../services/dynamic-form/dynamic-form.service';
|
||||
import idFromLink from 'core-app/features/hal/helpers/id-from-link';
|
||||
|
||||
/**
|
||||
* SETTINGS:
|
||||
* The DynamicFormComponent can get its settings (payload and fields) in two ways:
|
||||
*
|
||||
* - @Input settings:
|
||||
* Passing down an object that mimics a backend form configuration (IOPFormSettings),
|
||||
* with and easier format (not _embedded) through the 'settings' @Input.
|
||||
*
|
||||
* ```
|
||||
* <op-dynamic-form [settings]="formSettings">
|
||||
* </op-dynamic-form>
|
||||
* ```
|
||||
*
|
||||
* - Backend settings:
|
||||
* In order to fetch its settings from the backend, the DynamicFormComponent will
|
||||
* always need a backend endpoint to target. It can be provided in two ways:
|
||||
* - Through the 'resourcePath' @Input and, optionally, the 'resourceId' @Input if
|
||||
* we are editing an existing form.
|
||||
*
|
||||
* ```
|
||||
* <op-dynamic-form [resourcePath]="resourcePath">
|
||||
* </op-dynamic-form>
|
||||
* ```
|
||||
*
|
||||
* - Through the the 'formUrl' @Input. In this case we'll need to also provide the
|
||||
* formHttpMethod @Input if it is not POST.
|
||||
*
|
||||
* ```
|
||||
* <op-dynamic-form [formUrl]="formUrl"
|
||||
* [formHttpMethod]="formHttpMethod">
|
||||
* </op-dynamic-form>
|
||||
* ```
|
||||
*
|
||||
* USE CASES:
|
||||
* The DynamicFormComponent can be used in two ways:
|
||||
*
|
||||
* - Standalone Form:
|
||||
* In order to work as an standalone form, handling the submit operation,
|
||||
* the DynamicFormComponent will need a backend endpoint to target as explained above.
|
||||
*
|
||||
* ```
|
||||
* <op-dynamic-form [resourcePath]="resourcePath">
|
||||
* </op-dynamic-form>
|
||||
* ```
|
||||
*
|
||||
* - FormGroup:
|
||||
* In order to use the DynamicFormComponent as a formGroup, it will need a
|
||||
* FormGroup to be passed through the dynamicFormGroup @Input.
|
||||
*
|
||||
* ```
|
||||
* <op-dynamic-form [dynamicFormGroup]="dynamicFormGroup">
|
||||
* </op-dynamic-form>`
|
||||
* ```
|
||||
*
|
||||
* FORM SETTINGS CUSTOMIZATIONS:
|
||||
* The form settings can be customized in different ways:
|
||||
*
|
||||
* - initialPayload @Input:
|
||||
* Allows to provide and initial payload to the form settings request. Checkout
|
||||
* the [forms documentation](https://www.openproject.org/docs/api/forms/).
|
||||
*
|
||||
* - model @Input:
|
||||
* Allows to change model of the form.
|
||||
*
|
||||
* - fieldsSettingsPipe:
|
||||
* Allows to modify the dynamicFormFields settings before the form is rendered.
|
||||
*
|
||||
* ```
|
||||
* <op-dynamic-form [formUrl]="formUrl"
|
||||
* [formHttpMethod]="formHttpMethod"
|
||||
* [initialPayload]="initialPayload">
|
||||
* </op-dynamic-form>
|
||||
* ```
|
||||
*
|
||||
* - fieldGroups:
|
||||
* Allows to create field groups programmatically. For example, the following group would
|
||||
* create an 'Advanced settings' field group with all the fields that are not 'name'
|
||||
* or 'parent' overriding the default collapsibleFieldGroupsCollapsed (showing them
|
||||
* uncollapsed).
|
||||
*
|
||||
* ```
|
||||
* const fieldGroups = [{
|
||||
* name: 'Advanced settings',
|
||||
* fieldsFilter: (field) => !['name', 'parent'].includes(field.templateOptions?.property!),
|
||||
* settings: {
|
||||
* templateOptions: {
|
||||
* collapsibleFieldGroupsCollapsed: false
|
||||
* }
|
||||
* }
|
||||
* }];
|
||||
* ```
|
||||
*/
|
||||
|
||||
@Component({
|
||||
selector: 'op-dynamic-form',
|
||||
templateUrl: './dynamic-form.component.html',
|
||||
styleUrls: ['./dynamic-form.component.scss'],
|
||||
providers: [
|
||||
DynamicFormService,
|
||||
DynamicFieldsService,
|
||||
],
|
||||
standalone: false,
|
||||
})
|
||||
export class DynamicFormComponent extends UntilDestroyedMixin implements OnChanges {
|
||||
/** Backend form URL (e.g. https://community.openproject.org/api/v3/projects/dev-large/form) */
|
||||
@Input() formUrl?:string;
|
||||
|
||||
/** When using the formUrl @Input(), set the http method to use if it is not 'POST' */
|
||||
@Input() formHttpMethod?:'post'|'patch' = 'post';
|
||||
|
||||
/** Part of the URL that belongs to the resource type (e.g. '/projects' in the previous example)
|
||||
* Use this option when you don't have a form URL, the DynamicForm will build it from the resourcePath
|
||||
* for you (⌐■_■).
|
||||
*/
|
||||
@Input() resourcePath?:string;
|
||||
|
||||
/** Pass the resourceId in case you are editing an existing resource and you don't have the Form URL. */
|
||||
@Input() resourceId?:string;
|
||||
|
||||
@Input() settings?:IOPFormSettings;
|
||||
|
||||
@Input() dynamicFormGroup?:UntypedFormGroup;
|
||||
|
||||
/** Initial payload to POST to the form */
|
||||
@Input() initialPayload:Object = {};
|
||||
|
||||
@Input() set model(payload:IOPFormModel) {
|
||||
if (!this.innerModel && !payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedModel = this.dynamicFormService.formatModelToEdit(payload);
|
||||
|
||||
this.form.patchValue(formattedModel);
|
||||
}
|
||||
|
||||
/** Chance to modify the dynamicFormFields settings before the form is rendered */
|
||||
@Input() fieldsSettingsPipe?:(dynamicFieldsSettings:IOPFormlyFieldSettings[]) => IOPFormlyFieldSettings[];
|
||||
|
||||
/** Create fieldGroups programmatically */
|
||||
@Input() fieldGroups?:IDynamicFieldGroupConfig[];
|
||||
|
||||
@Input() showNotifications = true;
|
||||
|
||||
@Input() showValidationErrorsOn:'change'|'blur'|'submit'|'never' = 'submit';
|
||||
|
||||
@Input() handleSubmit = true;
|
||||
|
||||
@Input() helpTextAttributeScope?:string;
|
||||
|
||||
@Output() modelChange = new EventEmitter<IOPFormModel>();
|
||||
|
||||
@Output() submitted = new EventEmitter<HalSource>();
|
||||
|
||||
@Output() errored = new EventEmitter<IOPFormErrorResponse>();
|
||||
|
||||
form:UntypedFormGroup;
|
||||
|
||||
fields:IOPFormlyFieldSettings[];
|
||||
|
||||
formEndpoint?:string;
|
||||
|
||||
inFlight:boolean;
|
||||
|
||||
text = {
|
||||
save: this.I18n.t('js.button_save'),
|
||||
cancel: this.I18n.t('js.button_cancel'),
|
||||
load_error_message: this.I18n.t('js.forms.load_error_message'),
|
||||
successful_update: this.I18n.t('js.notice_successful_update'),
|
||||
successful_create: this.I18n.t('js.notice_successful_create'),
|
||||
job_started: this.I18n.t('js.notice_job_started'),
|
||||
};
|
||||
|
||||
noSettingsSourceErrorMessage = `DynamicFormComponent needs a settings, formUrl or resourcePath @Input
|
||||
in order to fetch its setting. Please provide one.`;
|
||||
|
||||
noPathToSubmitToError = `DynamicForm needs a resourcePath or formUrl @Input in order to be submitted
|
||||
and validated. Please provide one.`;
|
||||
|
||||
innerModel:IOPFormModel;
|
||||
|
||||
get model() {
|
||||
return this.form.getRawValue();
|
||||
}
|
||||
|
||||
@ViewChild(FormlyForm)
|
||||
set dynamicForm(dynamicForm:FormlyForm) {
|
||||
this.dynamicFormService.registerForm(dynamicForm);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private dynamicFormService:DynamicFormService,
|
||||
private dynamicFieldsService:DynamicFieldsService,
|
||||
private I18n:I18nService,
|
||||
private pathHelperService:PathHelperService,
|
||||
private toastService:ToastService,
|
||||
private changeDetectorRef:ChangeDetectorRef,
|
||||
private confirmDialogService:ConfirmDialogService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
setDisabledState(disabled:boolean):void {
|
||||
disabled ? this.form.disable() : this.form.enable();
|
||||
}
|
||||
|
||||
ngOnChanges(changes:SimpleChanges) {
|
||||
if (
|
||||
changes.settings
|
||||
|| changes.resourcePath
|
||||
|| changes.resourceId
|
||||
|| changes.formUrl
|
||||
|| changes.formHttpMethod
|
||||
|| changes.dynamicFormGroup
|
||||
|| changes.initialPayload
|
||||
|| changes.fieldsSettingsPipe
|
||||
|| changes.fieldGroups
|
||||
) {
|
||||
this.initializeDynamicForm(
|
||||
this.settings,
|
||||
this.resourcePath,
|
||||
this.resourceId,
|
||||
this.formUrl,
|
||||
this.initialPayload,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onModelChange(changes:any) {
|
||||
this.modelChange.emit(changes);
|
||||
}
|
||||
|
||||
submitForm(form:UntypedFormGroup) {
|
||||
if (!this.handleSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.formEndpoint) {
|
||||
throw new Error(this.noPathToSubmitToError);
|
||||
}
|
||||
|
||||
this.inFlight = true;
|
||||
this.dynamicFormService
|
||||
.submit$(form, this.formEndpoint, this.resourceId, this.formHttpMethod)
|
||||
.pipe(
|
||||
finalize(() => this.inFlight = false),
|
||||
)
|
||||
.subscribe(
|
||||
(formResponse:HalSource|any) => {
|
||||
this.submitted.emit(formResponse);
|
||||
this.showNotifications && this.showSuccessNotification(formResponse);
|
||||
},
|
||||
(error:HttpErrorResponse) => {
|
||||
this.errored.emit(error?.error || error);
|
||||
this.showNotifications && this.toastService.addError(error?.error?.message || error?.message);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
validateForm() {
|
||||
if (!this.formEndpoint) {
|
||||
throw new Error(this.noPathToSubmitToError);
|
||||
}
|
||||
|
||||
return this.dynamicFormService.validateForm$(this.form, this.formEndpoint);
|
||||
}
|
||||
|
||||
handleCancel() {
|
||||
if (this.form.dirty) {
|
||||
this.confirmDialogService.confirm({
|
||||
text: {
|
||||
title: this.I18n.t('js.text_are_you_sure'),
|
||||
text: this.I18n.t('js.text_data_lost'),
|
||||
},
|
||||
}).then(() => {
|
||||
this.goBack();
|
||||
})
|
||||
.catch(() => {});
|
||||
} else {
|
||||
this.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
private goBack() {
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
private initializeDynamicForm(
|
||||
settings?:IOPFormSettings,
|
||||
resourcePath?:string,
|
||||
resourceId?:string,
|
||||
formUrl?:string,
|
||||
payload?:Object,
|
||||
) {
|
||||
const formEndPoint = this.getFormEndPoint(formUrl, resourcePath);
|
||||
if (!formEndPoint) {
|
||||
throw new Error(this.noSettingsSourceErrorMessage);
|
||||
}
|
||||
|
||||
const isNewEndpoint = formEndPoint !== this.formEndpoint;
|
||||
if (isNewEndpoint) {
|
||||
this.formEndpoint = formEndPoint;
|
||||
}
|
||||
|
||||
if (settings) {
|
||||
this.setupDynamicFormFromSettings(settings);
|
||||
} else {
|
||||
this.setupDynamicFormFromBackend(this.formEndpoint, resourceId, payload);
|
||||
}
|
||||
}
|
||||
|
||||
private getFormEndPoint(formUrl?:string, resourcePath?:string):string {
|
||||
if (formUrl) {
|
||||
return formUrl.endsWith('/form')
|
||||
? formUrl.replace('/form', '')
|
||||
: formUrl;
|
||||
}
|
||||
|
||||
if (resourcePath) {
|
||||
return resourcePath;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private setupDynamicFormFromBackend(formEndpoint?:string, resourceId?:string, payload?:Object) {
|
||||
this.dynamicFormService
|
||||
.getSettingsFromBackend$(formEndpoint, resourceId, payload)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
this.toastService.addError(this.text.load_error_message);
|
||||
throw error;
|
||||
}),
|
||||
)
|
||||
.subscribe((dynamicFormSettings) => this.setupDynamicForm(dynamicFormSettings));
|
||||
}
|
||||
|
||||
private setupDynamicFormFromSettings(settings:IOPFormSettings) {
|
||||
const formattedSettings:IOPFormSettingsResource = {
|
||||
_embedded: {
|
||||
payload: settings?.payload,
|
||||
schema: settings?.schema,
|
||||
},
|
||||
};
|
||||
const dynamicFormSettings = this.dynamicFormService.getSettings(formattedSettings);
|
||||
|
||||
this.setupDynamicForm(dynamicFormSettings);
|
||||
}
|
||||
|
||||
private setupDynamicForm({ fields, model, form }:IOPDynamicFormSettings) {
|
||||
if (this.fieldsSettingsPipe) {
|
||||
fields = this.fieldsSettingsPipe(fields);
|
||||
}
|
||||
|
||||
if (this.fieldGroups) {
|
||||
fields = this.dynamicFieldsService.getFormlyFormWithFieldGroups(this.fieldGroups, fields);
|
||||
}
|
||||
|
||||
// We pass the resourceId through because some of the inputComponents need it to pass to their subcomponents
|
||||
// (e.g. the userInputComponent)
|
||||
const id = this.resourceId || idFromLink(this.resourcePath || null);
|
||||
model.id = id;
|
||||
|
||||
this.fields = fields;
|
||||
this.innerModel = model;
|
||||
this.form = this.dynamicFormGroup || form;
|
||||
|
||||
this.changeDetectorRef.detectChanges();
|
||||
}
|
||||
|
||||
private showSuccessNotification(formResponse:HalSource|any):void {
|
||||
const submitMessage = (() => {
|
||||
if (formResponse?.jobId) {
|
||||
const title = formResponse?.payload?.title;
|
||||
|
||||
return `${title || ''} ${this.text.job_started}`;
|
||||
} else {
|
||||
return this.formHttpMethod === 'patch' ? this.text.successful_update : this.text.successful_create;
|
||||
}
|
||||
})();
|
||||
|
||||
this.toastService.addSuccess(submitMessage);
|
||||
}
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
[attr.aria-required]="to.required"
|
||||
[formControl]="formControl"
|
||||
[formlyAttributes]="field"
|
||||
>
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import { fakeAsync } from '@angular/core/testing';
|
||||
import {
|
||||
createDynamicInputFixture,
|
||||
testDynamicInputControValueAccessor,
|
||||
} from 'core-app/shared/components/dynamic-forms/spec/helpers';
|
||||
import { IOPFormlyFieldSettings } from 'core-app/shared/components/dynamic-forms/typings';
|
||||
|
||||
describe('BooleanInputComponent', () => {
|
||||
it('should load the field', fakeAsync(() => {
|
||||
const fieldsConfig:IOPFormlyFieldSettings[] = [
|
||||
{
|
||||
type: 'booleanInput',
|
||||
key: 'testControl',
|
||||
templateOptions: {
|
||||
required: true,
|
||||
label: 'testControl',
|
||||
},
|
||||
},
|
||||
];
|
||||
const formModel:IOPFormModel = {
|
||||
testControl: true,
|
||||
};
|
||||
const testModel = {
|
||||
initialValue: true,
|
||||
changedValue: false,
|
||||
};
|
||||
|
||||
const fixture = createDynamicInputFixture(fieldsConfig, formModel);
|
||||
|
||||
testDynamicInputControValueAccessor(fixture, testModel, 'op-boolean-input input');
|
||||
}));
|
||||
});
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { FieldType } from '@ngx-formly/core';
|
||||
|
||||
@Component({
|
||||
selector: 'op-boolean-input',
|
||||
templateUrl: './boolean-input.component.html',
|
||||
styleUrls: ['./boolean-input.component.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class BooleanInputComponent extends FieldType {
|
||||
}
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
<op-basic-single-date-picker [required]="to.required"
|
||||
[disabled]="to.disabled"
|
||||
[formControl]="formControl"
|
||||
[formlyAttributes]="field"
|
||||
/>
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DateInputComponent } from './date-input.component';
|
||||
|
||||
xdescribe('DateInputComponent', () => {
|
||||
let component:DateInputComponent;
|
||||
let fixture:ComponentFixture<DateInputComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [DateInputComponent],
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(DateInputComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core';
|
||||
import { FieldType } from '@ngx-formly/core';
|
||||
|
||||
@Component({
|
||||
selector: 'op-date-input',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './date-input.component.html',
|
||||
styleUrls: ['./date-input.component.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class DateInputComponent extends FieldType {
|
||||
@HostBinding('class') get class() {
|
||||
return (this.model?.id === 'projects' && this.key.toString().startsWith('customField'))
|
||||
? 'form--date-picker-container -xslim'
|
||||
: null;
|
||||
}
|
||||
}
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
<div class="op-ckeditor--wrapper op-ckeditor-element">
|
||||
<op-ckeditor [context]="ckEditorContext"
|
||||
[content]="value?.raw"
|
||||
(contentChanged)="onContentChange($event)"
|
||||
(initializationFailed)="initializationError = true"
|
||||
(initializeDone)="onCkeditorSetup($event)"
|
||||
/>
|
||||
</div>
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FormattableControlComponent } from './formattable-control.component';
|
||||
|
||||
xdescribe('FormattableControlComponent', () => {
|
||||
let component:FormattableControlComponent;
|
||||
let fixture:ComponentFixture<FormattableControlComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [FormattableControlComponent],
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(FormattableControlComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
import { Component, forwardRef, Input, OnInit, ViewChild } from '@angular/core';
|
||||
import { I18nService } from 'core-app/core/i18n/i18n.service';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { OpCkeditorComponent } from 'core-app/shared/components/editor/components/ckeditor/op-ckeditor.component';
|
||||
import {
|
||||
ICKEditorContext,
|
||||
ICKEditorInstance,
|
||||
} from 'core-app/shared/components/editor/components/ckeditor/ckeditor.types';
|
||||
import { IOPFormlyTemplateOptions } from 'core-app/shared/components/dynamic-forms/typings';
|
||||
|
||||
@Component({
|
||||
selector: 'op-formattable-control',
|
||||
templateUrl: './formattable-control.component.html',
|
||||
styleUrls: ['./formattable-control.component.scss'],
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => FormattableControlComponent),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
standalone: false,
|
||||
})
|
||||
export class FormattableControlComponent implements ControlValueAccessor, OnInit {
|
||||
@Input() templateOptions:IOPFormlyTemplateOptions;
|
||||
|
||||
@ViewChild(OpCkeditorComponent, { static: true }) editor:OpCkeditorComponent;
|
||||
|
||||
text:{ [key:string]:string };
|
||||
|
||||
value:{ raw:string };
|
||||
|
||||
disabled = false;
|
||||
|
||||
touched:boolean;
|
||||
|
||||
// Detect when inner component could not be initialized
|
||||
initializationError = false;
|
||||
|
||||
onChange:(_any:unknown) => void = () => undefined;
|
||||
|
||||
onTouch:() => void = () => undefined;
|
||||
|
||||
public get ckEditorContext():ICKEditorContext {
|
||||
return {
|
||||
type: this.templateOptions.editorType!,
|
||||
field: this.templateOptions.name,
|
||||
// This is a very project resource specific hack to allow macros on description and statusExplanation but
|
||||
// disable it for custom fields. As the formly based approach is currently limited to projects, and that is to be removed,
|
||||
// such a "pragmatic" approach should be ok.
|
||||
macros: this.templateOptions.property?.startsWith('customField') ? 'none' : 'resource',
|
||||
options: { rtl: this.templateOptions.rtl },
|
||||
};
|
||||
}
|
||||
|
||||
constructor(
|
||||
readonly I18n:I18nService,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit():void {
|
||||
this.text = {
|
||||
attachmentLabel: this.I18n.t('js.label_formattable_attachment_hint'),
|
||||
save: this.I18n.t('js.inplace.button_save', { attribute: this.templateOptions.name }),
|
||||
cancel: this.I18n.t('js.inplace.button_cancel', { attribute: this.templateOptions.name }),
|
||||
};
|
||||
}
|
||||
|
||||
writeValue(value:{ raw:string }):void {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
registerOnChange(fn:(_:unknown) => void):void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
|
||||
registerOnTouched(fn:() => void):void {
|
||||
this.onTouch = fn;
|
||||
}
|
||||
|
||||
setDisabledState(disabled:boolean):void {
|
||||
this.disabled = disabled;
|
||||
|
||||
this.syncCKEditorReadonlyMode();
|
||||
}
|
||||
|
||||
onContentChange(value:string) {
|
||||
const valueToEmit = { raw: value };
|
||||
|
||||
this.onTouch();
|
||||
this.onChange(valueToEmit);
|
||||
}
|
||||
|
||||
syncCKEditorReadonlyMode() {
|
||||
const { ckEditorInstance } = this.editor;
|
||||
if (!ckEditorInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.disabled) {
|
||||
ckEditorInstance.enableReadOnlyMode('formattable-control');
|
||||
} else {
|
||||
ckEditorInstance.disableReadOnlyMode('formattable-control');
|
||||
}
|
||||
}
|
||||
|
||||
onCkeditorSetup(_editor:ICKEditorInstance) {
|
||||
this.syncCKEditorReadonlyMode();
|
||||
this.editor.ckEditorInstance.ui.focusTracker.on(
|
||||
'change:isFocused',
|
||||
(evt:unknown, name:unknown, isFocused:unknown) => {
|
||||
if (!isFocused && !this.touched) {
|
||||
this.touched = true;
|
||||
this.onTouch();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormattableControlComponent } from 'core-app/shared/components/dynamic-forms/components/dynamic-inputs/formattable-textarea-input/components/formattable-control/formattable-control.component';
|
||||
import { OpenprojectEditorModule } from 'core-app/shared/components/editor/openproject-editor.module';
|
||||
import { FormattableEditFieldModule } from 'core-app/shared/components/fields/edit/field-types/formattable-edit-field/formattable-edit-field.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
FormattableControlComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
OpenprojectEditorModule,
|
||||
FormattableEditFieldModule,
|
||||
],
|
||||
exports: [
|
||||
FormattableControlComponent,
|
||||
],
|
||||
})
|
||||
export class FormattableControlModule { }
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
<op-formattable-control [templateOptions]="to"
|
||||
[formControl]="formControl"
|
||||
[formlyAttributes]="field"
|
||||
/>
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
:host {
|
||||
overflow: hidden;
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
import { discardPeriodicTasks, fakeAsync, flush, tick } from '@angular/core/testing';
|
||||
import { createDynamicInputFixture } from 'core-app/shared/components/dynamic-forms/spec/helpers';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { IOPFormlyFieldSettings } from 'core-app/shared/components/dynamic-forms/typings';
|
||||
|
||||
describe('FormattableTextareaInputComponent', () => {
|
||||
it('should load the field', fakeAsync(() => {
|
||||
const fieldsConfig:IOPFormlyFieldSettings[] = [
|
||||
{
|
||||
type: 'formattableInput',
|
||||
key: 'testControl',
|
||||
templateOptions: {
|
||||
required: true,
|
||||
label: 'testControl',
|
||||
type: 'text',
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
bindLabel: 'name',
|
||||
bindValue: 'value',
|
||||
noWrapLabel: true,
|
||||
editorType: 'full'
|
||||
},
|
||||
},
|
||||
];
|
||||
const formModel:IOPFormModel = {
|
||||
testControl: {
|
||||
html: '<p>tesValue</p>',
|
||||
raw: 'tesValue',
|
||||
},
|
||||
};
|
||||
const testModel = {
|
||||
initialValue: formModel.testControl,
|
||||
changedValue: 'testValue2',
|
||||
};
|
||||
const fixture = createDynamicInputFixture(fieldsConfig, formModel);
|
||||
const dynamicInput = fixture.debugElement.query(By.css('.document-editor__editable-container')).nativeElement;
|
||||
const dynamicForm = fixture.componentInstance.dynamicForm.field.formControl;
|
||||
const dynamicDebugElement = fixture.debugElement.query(By.css('op-formattable-control'));
|
||||
const dynamicElement = dynamicDebugElement.nativeElement;
|
||||
|
||||
// Test ControlValueAccessor
|
||||
// Write Value
|
||||
expect(dynamicForm.value.testControl).toEqual(testModel.initialValue);
|
||||
expect(dynamicElement.classList.contains('ng-untouched')).toBeTrue();
|
||||
expect(dynamicElement.classList.contains('ng-valid')).toBeTrue();
|
||||
expect(dynamicElement.classList.contains('ng-pristine')).toBeTrue();
|
||||
|
||||
fixture.detectChanges();
|
||||
tick(1000);
|
||||
flush();
|
||||
|
||||
// Discard any editor intervals
|
||||
discardPeriodicTasks();
|
||||
}));
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user