Merge pull request #20593 from opf/feature/primerize-invitation-dialog

[#64879] Primerise user invitation flow
This commit is contained in:
Oliver Günther
2025-10-23 10:17:33 +02:00
committed by GitHub
135 changed files with 1478 additions and 5498 deletions
@@ -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
+16 -13
View File
@@ -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
+8 -6
View File
@@ -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
+150
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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
+72
View File
@@ -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
+8 -2
View File
@@ -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
View File
@@ -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"
+1 -75
View File
@@ -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:
+5
View File
@@ -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"
+2
View File
@@ -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>
```
-21
View File
@@ -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",
-1
View File
@@ -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",
-7
View File
@@ -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
View File
@@ -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> {
@@ -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,
);
}
@@ -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,
});
}
}
@@ -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)),
);
};
@@ -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>
@@ -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,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,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';
@@ -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;
@@ -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
@@ -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');
@@ -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,
})
@@ -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(),
@@ -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>
}
@@ -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 {
}
@@ -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>
}
@@ -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();
}
}
@@ -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)"
/>
}
@@ -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();
}));
});
@@ -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);
}
}
@@ -1,6 +0,0 @@
<input
type="checkbox"
[attr.aria-required]="to.required"
[formControl]="formControl"
[formlyAttributes]="field"
>
@@ -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');
}));
});
@@ -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 {
}
@@ -1,5 +0,0 @@
<op-basic-single-date-picker [required]="to.required"
[disabled]="to.disabled"
[formControl]="formControl"
[formlyAttributes]="field"
/>
@@ -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();
});
});
@@ -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;
}
}
@@ -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>
@@ -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();
});
});
@@ -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();
}
},
);
}
}
@@ -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 { }
@@ -1,4 +0,0 @@
<op-formattable-control [templateOptions]="to"
[formControl]="formControl"
[formlyAttributes]="field"
/>
@@ -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