Implement copy form in formly (#9218)

* Implement copy form in formly

* Add meta property handling to dynamic form

* Remove copy projects controller, merge into projects

* Fix copying of container attachments

* Fix project copy path

* Fix remainder of copy spec
pull/9224/head
Oliver Günther 4 years ago committed by GitHub
parent 293b8935a2
commit 2adce94853
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 85
      app/controllers/copy_projects_controller.rb
  2. 6
      app/controllers/projects_controller.rb
  3. 2
      app/helpers/projects_helper.rb
  4. 9
      app/services/copy/concerns/copy_attachments.rb
  5. 1
      app/services/projects/copy/wiki_dependent_service.rb
  6. 2
      app/services/projects/copy/wiki_page_attachments_dependent_service.rb
  7. 2
      app/services/projects/copy/work_package_attachments_dependent_service.rb
  8. 50
      app/views/copy_projects/copy_from_admin.html.erb
  9. 50
      app/views/copy_projects/copy_from_settings.html.erb
  10. 35
      app/views/copy_projects/copy_settings/_block_checkbox.html.erb
  11. 76
      app/views/copy_projects/copy_settings/_copy_associations.html.erb
  12. 37
      app/views/copy_projects/copy_settings/_hidden_custom_fields.html.erb
  13. 39
      app/views/copy_projects/copy_settings/_hidden_types.html.erb
  14. 2
      app/views/projects/_toolbar.html.erb
  15. 8
      app/views/projects/copy.html.erb
  16. 2
      config/initializers/permissions.rb
  17. 1
      config/locales/en.yml
  18. 2
      config/locales/js-en.yml
  19. 5
      config/routes.rb
  20. 5
      frontend/src/app/core/services/forms/typings.d.ts
  21. 41
      frontend/src/app/modules/apiv3/endpoints/projects/apiv3-project-copy-paths.ts
  22. 4
      frontend/src/app/modules/apiv3/endpoints/projects/apiv3-project-paths.ts
  23. 25
      frontend/src/app/modules/common/dynamic-forms/services/dynamic-fields/dynamic-fields.service.ts
  24. 6
      frontend/src/app/modules/projects/components/copy-project/copy-project.component.html
  25. 104
      frontend/src/app/modules/projects/components/copy-project/copy-project.component.ts
  26. 43
      frontend/src/app/modules/projects/components/new-project/new-project.component.ts
  27. 47
      frontend/src/app/modules/projects/form-helpers/form-attribute-groups.ts
  28. 2
      frontend/src/app/modules/projects/openproject-projects.module.ts
  29. 11
      frontend/src/app/modules/projects/projects-routes.ts
  30. 8
      frontend/src/app/modules/router/openproject.routes.ts
  31. 173
      spec/controllers/copy_projects_controller_spec.rb
  32. 23
      spec/controllers/projects_controller_spec.rb
  33. 40
      spec/features/projects/copy_spec.rb
  34. 5
      spec/permissions/copy_projects_spec.rb
  35. 23
      spec/routing/project_routing_spec.rb

@ -1,85 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
class CopyProjectsController < ApplicationController
before_action :find_project
before_action :authorize
def copy
request_params = params
.permit(:send_notifications, only: [])
.to_h
.merge(target_project_params: target_project_params)
call = Projects::EnqueueCopyService
.new(user: current_user, model: @project)
.call(request_params)
if call.success?
job = call.result
copy_started_notice
redirect_to job_status_path job.job_id
else
@copy_project = call.result
@errors = call.errors
render action: copy_action
end
end
def copy_project
@copy_project = Projects::CopyService
.new(user: current_user, source: @project)
.call(target_project_params: target_project_params, attributes_only: true)
.result
render action: copy_action
end
private
def target_project_params
params[:project] ? permitted_params.project.to_h : {}
end
def copy_action
from = (%w(admin settings).include?(params[:coming_from]) ? params[:coming_from] : 'settings')
"copy_from_#{from}"
end
def origin
params[:coming_from] == 'admin' ? projects_path : settings_generic_project_path(@project.id)
end
def copy_started_notice
flash[:notice] = I18n.t('copy_project.started',
source_project_name: @project.name,
target_project_name: permitted_params.project[:name])
end
end

@ -33,7 +33,7 @@ class ProjectsController < ApplicationController
menu_item :roadmap, only: :roadmap
before_action :find_project, except: %i[index level_list new]
before_action :authorize, only: %i[modules types custom_fields]
before_action :authorize, only: %i[modules types custom_fields copy]
before_action :authorize_global, only: %i[new]
before_action :require_admin, only: %i[archive unarchive destroy destroy_info]
@ -83,6 +83,10 @@ class ProjectsController < ApplicationController
end
end
def copy
render
end
def update_identifier
service_call = Projects::UpdateService
.new(user: current_user,

@ -117,7 +117,7 @@ module ProjectsHelper
def project_more_menu_copy_item(project)
if User.current.allowed_to?(:copy_projects, project) && !project.archived?
[t(:button_copy),
copy_from_project_path(project, :admin),
copy_project_path(project),
{ class: 'icon-context icon-copy',
title: t(:button_copy) }]
end

@ -3,15 +3,16 @@ module Copy
module CopyAttachments
##
# Tries to copy the given attachment between containers
def copy_attachments(from_container, to_container_id)
from_container_id = from_container.is_a?(ApplicationRecord) ? from_container.id : from_container
Attachment.where(container_id: from_container_id).find_each do |old_attachment|
def copy_attachments(container_type, from_id:, to_id:)
Attachment
.where(container_type: container_type, container_id: from_id)
.find_each do |old_attachment|
copied = old_attachment.dup
old_attachment.file.copy_to(copied)
copied.author = user
copied.container_type = old_attachment.container_type
copied.container_id = to_container_id
copied.container_id = to_id
unless copied.save
Rails.logger.error { "Attachments ##{old_attachment.id} could not be copied: #{copied.errors.full_messages} " }

@ -30,7 +30,6 @@
module Projects::Copy
class WikiDependentService < Dependency
include ::Copy::Concerns::CopyAttachments
def self.human_name
I18n.t(:label_wiki_page_plural)

@ -47,7 +47,7 @@ module Projects::Copy
return unless state.wiki_page_id_lookup
state.wiki_page_id_lookup.each do |old_id, new_id|
copy_attachments(old_id, new_id)
copy_attachments('WikiPage', from_id: old_id, to_id: new_id)
end
end
end

@ -47,7 +47,7 @@ module Projects::Copy
return unless state.work_package_id_lookup
state.work_package_id_lookup.each do |old_wp_id, new_wp_id|
copy_attachments(old_wp_id, new_wp_id)
copy_attachments('WorkPackage', from_id: old_wp_id, to_id: new_wp_id)
end
end
end

@ -1,50 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2021 the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See docs/COPYRIGHT.rdoc for more details.
++#%>
<%= error_messages_for 'copy_project' %>
<%= toolbar title: t(:label_project_new) %>
<%= labelled_tabular_form_for @copy_project, url: { action: 'copy', coming_from: 'admin' } do |f| %>
<%= hidden_field_tag :coming_from, 'admin' %>
<%= render partial: 'projects/form', locals: { f: f,
project: @copy_project,
errors: @errors,
is_copy: true,
render_advanced: true,
render_types: false,
render_modules: false,
render_custom_fields: false
} %>
<%= render partial: "copy_projects/copy_settings/copy_associations", locals: { project: @project } %>
<%= submit_tag t(:button_copy), class: 'button -highlight' %>
<% end %>

@ -1,50 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2021 the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See docs/COPYRIGHT.rdoc for more details.
++#%>
<%= error_messages_for_contract @copy_project, @errors %>
<%= toolbar title: t(:label_project_new) %>
<%= labelled_tabular_form_for @copy_project, url: { action: 'copy', coming_from: 'settings' } do |f| %>
<%= hidden_field_tag :coming_from, 'settings' %>
<%= render partial: 'projects/form', locals: { f: f,
project: @copy_project,
errors: @errors,
is_copy: true,
render_advanced: true,
render_types: false,
render_modules: false,
render_custom_fields: false,
no_error_messages: true
} %>
<%= render partial: "copy_projects/copy_settings/copy_associations", locals: { project: @project } %>
<%= submit_tag t(:button_copy), class: 'button -highlight' %>
<% end %>

@ -1,35 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2021 the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See docs/COPYRIGHT.rdoc for more details.
++#%>
<% if count.nil? || count > 0 %>
<label class="block">
<%= check_box_tag 'only[]', name, checked, id: "only_#{name}" %>
<% count_label = count ? " (#{count})" : "" %>
<%= "#{label}#{count_label}" %>
</label>
<% end %>

@ -1,76 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2021 the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See docs/COPYRIGHT.rdoc for more details.
++#%>
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%= t(:button_copy) %></legend>
<%= render partial: "copy_projects/copy_settings/block_checkbox",
locals: { name: "overview", checked: true, label: t(:label_overview), count: nil } %>
<%= render partial: "copy_projects/copy_settings/block_checkbox",
locals: { name: "queries", checked: true, label: t(:label_query_plural),
count: project.queries.count } %>
<%= render partial: "copy_projects/copy_settings/block_checkbox",
locals: { name: "boards", checked: true, label: t('boards.label_boards'),
count: ::Boards::Grid.where(project: project).count } %>
<%= render partial: "copy_projects/copy_settings/block_checkbox",
locals: { name: "forums", checked: false, label: t(:label_forum_plural),
count: project.forums.count } %>
<%= render partial: "copy_projects/copy_settings/block_checkbox",
locals: { name: "members", checked: true, label: t(:label_member_plural),
count: project.members.count } %>
<%= render partial: "copy_projects/copy_settings/block_checkbox",
locals: { name: "versions", checked: true, label: t(:label_version_plural),
count: project.versions.count } %>
<% if project.wiki.present? %>
<%= render partial: "copy_projects/copy_settings/block_checkbox",
locals: { name: "wiki", checked: true, label: t(:label_wiki_page_plural),
count: project.wiki.nil? ? 0 : project.wiki.pages.count } %>
<%= render partial: "copy_projects/copy_settings/block_checkbox",
locals: { name: "wiki_page_attachments", checked: false, label: t(:label_wiki_page_attachments),
count: project.wiki.pages.joins(:attachments).count('attachments.id') } %>
<% end %>
<%= render partial: "copy_projects/copy_settings/block_checkbox",
locals: { name: "work_packages", checked: true, label: t(:label_work_package_plural),
count: project.work_packages.count } %>
<%= render partial: "copy_projects/copy_settings/block_checkbox",
locals: { name: "work_package_attachments", checked: true, label: t(:label_work_package_attachments),
count: project.work_packages.joins(:attachments).count('attachments.id') } %>
<%= render partial: "copy_projects/copy_settings/block_checkbox",
locals: { name: "categories", checked: true,
label: t(:label_work_package_category_plural),
count: project.categories.count } %>
<%= hidden_field_tag 'only[]', '' %>
<br />
<label class="block">
<%= check_box_tag 'send_notifications', 1, params[:send_notifications] %>
<%= t(:label_project_copy_notifications) %>
</label>
</fieldset>

@ -1,37 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2021 the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See docs/COPYRIGHT.rdoc for more details.
++#%>
<% issue_custom_fields.each do |custom_field| %>
<% if @project.all_work_package_custom_fields.include?(custom_field) %>
<%= render partial: "projects/form/attributes/hidden_field",
locals: { form: form, name: :'work_package_custom_field_ids',
options: {multiple: true},
value: custom_field.id } %>
<% end %>
<% end %>

@ -1,39 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2021 the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See docs/COPYRIGHT.rdoc for more details.
++#%>
<% unless types.empty? %>
<% types.each do |type| %>
<% if project.types.include?(type) %>
<%= render partial: "projects/form/attributes/hidden_field",
locals: { form: form, name: :'type_ids',
options: {multiple: true},
value: type.id } %>
<% end %>
<% end %>
<% end %>

@ -47,7 +47,7 @@ See docs/COPYRIGHT.rdoc for more details.
</li>
<% if @project.copy_allowed? %>
<li class="toolbar-item hidden-for-mobile">
<%= link_to copy_from_project_path(@project, coming_from: :settings), class: 'button copy', accesskey: accesskey(:copy) do %>
<%= link_to copy_project_path(@project), class: 'button copy', accesskey: accesskey(:copy) do %>
<%= op_icon('button--icon icon-copy') %>
<span class="button--text"><%= t(:button_copy) %></span>
<% end %>

@ -27,6 +27,8 @@ See docs/COPYRIGHT.rdoc for more details.
++#%>
<% project.enabled_module_names.each do |m| %>
<%= hidden_field_tag "enabled_modules[]", m, id: "enabled_module_names_#{m}" %>
<% end %>
<% title = t("copy_project.title", source_project_name: @project.name) %>
<% html_title title %>
<%= toolbar title: title %>
<openproject-base></openproject-base>

@ -121,7 +121,7 @@ OpenProject::AccessControl.map do |map|
map.permission :copy_projects,
{
copy_projects: %i[copy copy_project]
projects: %i[copy]
},
require: :member,
contract_actions: { projects: %i[copy] }

@ -991,6 +991,7 @@ en:
update_consent_last_time: "Last update of consent: %{update_time}"
copy_project:
title: 'Copy project "%{source_project_name}"'
started: "Started to copy project \"%{source_project_name}\" to \"%{target_project_name}\". You will be informed by mail as soon as \"%{target_project_name}\" is available."
failed: "Cannot copy project %{source_project_name}"
failed_internal: "Copying failed due to an internal error."

@ -574,6 +574,8 @@ en:
click_to_switch_context: 'Open this work package in that project.'
confirm_template_load: 'Switching the template will reload the page and you will lose all input to this form. Continue?'
use_template: "Use template"
copy:
copy_options: "Copy options"
autocompleter:
label: 'Project autocompletion'

@ -176,10 +176,7 @@ OpenProject::Application.routes.draw do
get 'identifier', action: 'identifier'
patch 'identifier', action: 'update_identifier'
match 'copy_project_from_(:coming_from)' => 'copy_projects#copy_project', via: :get, as: :copy_from,
constraints: { coming_from: /(admin|settings)/ }
match 'copy_from_(:coming_from)' => 'copy_projects#copy', via: :post, as: :copy,
constraints: { coming_from: /(admin|settings)/ }
get :copy
put :modules
put :custom_fields

@ -34,6 +34,9 @@ interface IOPFormModel {
_links?: {
[key: string]: IOPFieldModel | IOPFieldModel[] | null;
};
_meta?:{
[key:string]:string|number|Object|HalLinkSource|null|undefined;
}
}
interface IOPFieldSchema {
@ -46,7 +49,7 @@ interface IOPFieldSchema {
minLength?: number,
maxLength?: number,
attributeGroup?: string;
location?: '_links' | string;
location?: '_meta'|'_links'|undefined;
options: {
[key: string]: any;
};

@ -0,0 +1,41 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
import {APIv3FormResource} from "core-app/modules/apiv3/forms/apiv3-form-resource";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {SimpleResource} from "core-app/modules/apiv3/paths/path-resources";
export class APIv3ProjectCopyPaths extends SimpleResource {
constructor(protected apiRoot:APIV3Service,
public basePath:string) {
super(basePath, 'copy');
}
// /api/v3/projects/:project_id/copy/form
public readonly form = new APIv3FormResource(this.apiRoot, this.path, 'form');
}

@ -35,6 +35,7 @@ import { MultiInputState } from "reactivestates";
import { APIv3VersionsPaths } from "core-app/modules/apiv3/endpoints/versions/apiv3-versions-paths";
import { StateCacheService } from "core-app/modules/apiv3/cache/state-cache.service";
import { APIv3ProjectsPaths } from "core-app/modules/apiv3/endpoints/projects/apiv3-projects-paths";
import {APIv3ProjectCopyPaths} from "core-app/modules/apiv3/endpoints/projects/apiv3-project-copy-paths";
export class APIv3ProjectPaths extends CachableAPIV3Resource<ProjectResource> {
// /api/v3/projects/:project_id/available_assignees
@ -52,6 +53,9 @@ export class APIv3ProjectPaths extends CachableAPIV3Resource<ProjectResource> {
// /api/v3/projects/:project_id/versions
public readonly versions = new APIv3VersionsPaths(this.apiRoot, this.path);
// /api/v3/projects/:project_id/copy
public readonly copy = new APIv3ProjectCopyPaths(this.apiRoot, this.path);
protected createCache():StateCacheService<ProjectResource> {
return (this.parent as APIv3ProjectsPaths).cache;
}

@ -101,7 +101,8 @@ export class DynamicFieldsService {
constructor(
private _httpClient:HttpClient,
) { }
) {
}
getConfig(formSchema:IOPFormSchema, formPayload:IOPFormModel):IOPFormlyFieldSettings[] {
const formFieldGroups = formSchema._attributeGroups;
@ -119,9 +120,10 @@ export class DynamicFieldsService {
}
getFormattedFieldsModel(formModel:IOPFormModel = {}):IOPFormModel {
const {_links:resourcesModel, ...otherElementsModel} = formModel;
const { _links: resourcesModel, _meta: metaModel, ...otherElementsModel } = formModel;
const model = {
...otherElementsModel,
_meta: metaModel,
_links: this._getFormattedResourcesModel(resourcesModel),
}
@ -133,9 +135,7 @@ export class DynamicFieldsService {
.map(fieldSchemaKey => {
const fieldSchema = {
...formSchema[fieldSchemaKey],
key: this._isResourceSchema(formSchema[fieldSchemaKey]) ?
`_links.${fieldSchemaKey}` :
fieldSchemaKey
key: this.getAttributeKey(formSchema[fieldSchemaKey], fieldSchemaKey)
};
return fieldSchema;
@ -143,8 +143,14 @@ export class DynamicFieldsService {
.filter(fieldSchema => this._isFieldSchema(fieldSchema) && fieldSchema.writable);
}
private _isResourceSchema(fieldSchema: IOPFieldSchema):boolean {
return fieldSchema.location === '_links';
private getAttributeKey(fieldSchema:IOPFieldSchema, key:string):string {
switch (fieldSchema.location) {
case "_links":
case "_meta":
return `${fieldSchema.location}.${key}`;
default:
return key;
}
}
private _isFieldSchema(schemaValue:IOPFieldSchemaWithKey|any):boolean {
@ -157,7 +163,10 @@ export class DynamicFieldsService {
// 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?.href && { ...resourceElement, name: resourceElement?.name || resourceElement?.title }) :
resource.map(resourceElement => resourceElement?.href && {
...resourceElement,
name: resourceElement?.name || resourceElement?.title
}) :
resource?.href && { ...resource, name: resource?.name || resource?.title };
result = {

@ -0,0 +1,6 @@
<op-dynamic-form
[formUrl]="formUrl"
[fieldsSettingsPipe]="dynamicFieldsSettingsPipe"
(submitted)="onSubmitted($event)"
>
</op-dynamic-form>

@ -0,0 +1,104 @@
import {ChangeDetectionStrategy, Component, OnInit} from '@angular/core';
import {StateService, UIRouterGlobals} from "@uirouter/core";
import {UntilDestroyedMixin} from "core-app/helpers/angular/until-destroyed.mixin";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {HalSource} from "core-app/modules/hal/resources/hal-resource";
import {IOPFormlyFieldSettings, IOPFormlyTemplateOptions} from "core-app/modules/common/dynamic-forms/typings";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {JobStatusModal} from "core-app/modules/job-status/job-status-modal/job-status.modal";
import {OpModalService} from "core-app/modules/modal/modal.service";
import {ProjectFormAttributeGroups} from "core-app/modules/projects/form-helpers/form-attribute-groups";
interface CopyFormFieldConfig {
firstLevelFields:IOPFormlyFieldSettings[];
metaLevelFields:IOPFormlyFieldSettings[];
advancedSettingsFields:IOPFormlyFieldSettings[];
}
@Component({
selector: 'op-copy-project',
templateUrl: './copy-project.component.html'
})
export class CopyProjectComponent extends UntilDestroyedMixin implements OnInit {
dynamicFieldsSettingsPipe = this.fieldSettingsPipe.bind(this);
formUrl:string;
hiddenFields:string[] = [
'identifier',
'active'
];
text = {
advancedSettingsLabel: this.I18n.t("js.forms.advanced_settings"),
copySettingsLabel: this.I18n.t("js.project.copy.copy_options"),
}
constructor(
private apiV3Service:APIV3Service,
private uIRouterGlobals:UIRouterGlobals,
private pathHelperService:PathHelperService,
private modalService:OpModalService,
private $state:StateService,
private I18n:I18nService,
) {
super();
}
ngOnInit():void {
this.formUrl = this.apiV3Service.projects.id(this.uIRouterGlobals.params.projectPath).copy.form.path;
}
onSubmitted(response:HalSource) {
this.modalService.show(JobStatusModal, 'global', { jobId: response.jobId });
}
private isHiddenField(key:string|undefined):boolean {
return !!key && this.hiddenFields.includes(key);
}
private fieldSettingsPipe(dynamicFieldsSettings:IOPFormlyFieldSettings[]):IOPFormlyFieldSettings[] {
const fieldsLayoutConfig = dynamicFieldsSettings
.reduce((result, field) => {
field = {
...field,
hide: this.isHiddenField(field.key),
}
const to = field.templateOptions;
if (this.isMeta(to?.property)) {
result.metaLevelFields = [...result.metaLevelFields, field];
} else if (to && this.isPrimaryAttribute(to)) {
result.firstLevelFields = [...result.firstLevelFields, field];
} else {
result.advancedSettingsFields = [...result.advancedSettingsFields, field];
}
return result;
}, {
firstLevelFields: [],
metaLevelFields: [],
advancedSettingsFields: []
} as CopyFormFieldConfig
);
return [
...fieldsLayoutConfig.firstLevelFields,
ProjectFormAttributeGroups.collapsibleFieldset(fieldsLayoutConfig.advancedSettingsFields, this.text.advancedSettingsLabel),
ProjectFormAttributeGroups.collapsibleFieldset(fieldsLayoutConfig.metaLevelFields, this.text.copySettingsLabel),
];
}
private isPrimaryAttribute(to:IOPFormlyTemplateOptions):boolean {
return (to.required &&
!to.hasDefault &&
to.payloadValue == null) ||
to.property === 'name' ||
to.property === 'parent';
}
private isMeta(property:string|undefined):boolean {
return !!property && (property.startsWith('copy') || property == 'sendNotifications');
}
}

@ -1,4 +1,4 @@
import {Component, OnInit} from '@angular/core';
import {ChangeDetectionStrategy, Component, OnInit} from '@angular/core';
import {StateService, UIRouterGlobals} from "@uirouter/core";
import {UntilDestroyedMixin} from "core-app/helpers/angular/until-destroyed.mixin";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
@ -13,6 +13,7 @@ import {Observable} from "rxjs";
import {JobStatusModal} from "core-app/modules/job-status/job-status-modal/job-status.modal";
import {OpModalService} from "core-app/modules/modal/modal.service";
import { FormlyFieldConfig } from "@ngx-formly/core";
import {ProjectFormAttributeGroups} from "core-app/modules/projects/form-helpers/form-attribute-groups";
export interface ProjectTemplateOption {
href:string|null;
@ -21,7 +22,7 @@ export interface ProjectTemplateOption {
@Component({
selector: 'op-new-project',
templateUrl: './new-project.component.html',
templateUrl: './new-project.component.html'
})
export class NewProjectComponent extends UntilDestroyedMixin implements OnInit {
resourcePath:string;
@ -32,6 +33,7 @@ export class NewProjectComponent extends UntilDestroyedMixin implements OnInit {
formUrl:string;
text = {
use_template: this.I18n.t('js.project.use_template'),
advancedSettingsLabel: this.I18n.t("js.forms.advanced_settings"),
};
hiddenFields:string[] = [
@ -144,38 +146,9 @@ export class NewProjectComponent extends UntilDestroyedMixin implements OnInit {
advancedSettingsFields: []
} as { firstLevelFields:IOPFormlyFieldSettings[], advancedSettingsFields:IOPFormlyFieldSettings[] });
const advancedSettingsGroup = {
fieldGroup: fieldsLayoutConfig.advancedSettingsFields,
fieldGroupClassName: "op-form--field-group",
templateOptions: {
label: this.I18n.t("js.forms.advanced_settings"),
isFieldGroup: true,
collapsibleFieldGroups: true,
collapsibleFieldGroupsCollapsed: true,
},
type: "formly-group" as "formly-group",
wrappers: ["op-dynamic-field-group-wrapper"],
expressionProperties: {
'templateOptions.collapsibleFieldGroupsCollapsed': (model:unknown, formState:unknown, field:FormlyFieldConfig) => {
// Uncollapse field groups when the form has errors and has been submitted
if (
field.type !== 'formly-group' ||
!field.templateOptions?.collapsibleFieldGroups ||
!field.templateOptions?.collapsibleFieldGroupsCollapsed
) {
return;
} else {
return !(
field.fieldGroup?.some(groupField =>
groupField?.formControl?.errors &&
!groupField.hide &&
field.options?.parentForm?.submitted
));
}
},
}
}
return [...fieldsLayoutConfig.firstLevelFields, advancedSettingsGroup];
return [
...fieldsLayoutConfig.firstLevelFields,
ProjectFormAttributeGroups.collapsibleFieldset(fieldsLayoutConfig.advancedSettingsFields, this.text.advancedSettingsLabel),
];
}
}

@ -0,0 +1,47 @@
import {IOPFormlyFieldSettings} from "core-app/modules/common/dynamic-forms/typings";
import {FormlyFieldConfig} from "@ngx-formly/core";
export namespace ProjectFormAttributeGroups {
/**
* Create a collapsible formly fieldset for the given fields
* @param {IOPFormlyFieldSettings[]} fields Fields to include in the fieldset
* @param {string} label Label of the fieldset
*/
export function collapsibleFieldset(
fields:IOPFormlyFieldSettings[],
label:string,
):IOPFormlyFieldSettings {
return {
fieldGroup: fields,
fieldGroupClassName: "op-form--field-group",
templateOptions: {
label: label,
isFieldGroup: true,
collapsibleFieldGroups: true,
collapsibleFieldGroupsCollapsed: true,
},
type: "formly-group" as "formly-group",
wrappers: ["op-dynamic-field-group-wrapper"],
expressionProperties: {
'templateOptions.collapsibleFieldGroupsCollapsed': (model:unknown, formState:unknown, field:FormlyFieldConfig) => {
// Uncollapse field groups when the form has errors and has been submitted
if (
field.type !== 'formly-group' ||
!field.templateOptions?.collapsibleFieldGroups ||
!field.templateOptions?.collapsibleFieldGroupsCollapsed
) {
return;
} else {
return !(
field.fieldGroup?.some(groupField =>
groupField?.formControl?.errors &&
!groupField.hide &&
field.options?.parentForm?.submitted
));
}
},
}
};
}
}

@ -36,6 +36,7 @@ import { DynamicFormsModule } from "core-app/modules/common/dynamic-forms/dynami
import { NewProjectComponent } from "core-app/modules/projects/components/new-project/new-project.component";
import { ReactiveFormsModule } from "@angular/forms";
import { OpenprojectCommonModule } from "core-app/modules/common/openproject-common.module";
import {CopyProjectComponent} from "core-app/modules/projects/components/copy-project/copy-project.component";
@NgModule({
@ -55,6 +56,7 @@ import { OpenprojectCommonModule } from "core-app/modules/common/openproject-com
declarations: [
ProjectsComponent,
NewProjectComponent,
CopyProjectComponent,
]
})
export class OpenprojectProjectsModule {

@ -1,14 +1,21 @@
import { Ng2StateDeclaration, UIRouter } from "@uirouter/angular";
import { ProjectsComponent } from "core-app/modules/projects/components/projects/projects.component";
import { NewProjectComponent } from "core-app/modules/projects/components/new-project/new-project.component";
import {CopyProjectComponent} from "core-app/modules/projects/components/copy-project/copy-project.component";
export const PROJECTS_ROUTES:Ng2StateDeclaration[] = [
{
name: 'projects',
url: '/settings/generic/',
name: 'project_settings',
parent: 'root',
url: '/settings/generic/',
component: ProjectsComponent,
},
{
name: 'project_copy',
parent: 'root',
url: '/copy',
component: CopyProjectComponent,
},
{
name: 'new_project',
url: '/projects/new?parent_id',

@ -93,11 +93,17 @@ export const OPENPROJECT_ROUTES:Ng2StateDeclaration[] = [
loadChildren: () => import('../job-status/openproject-job-status.module').then(m => m.OpenProjectJobStatusModule)
},
{
name: 'projects.**',
name: 'project_settings.**',
parent: 'root',
url: '/settings/generic',
loadChildren: () => import('../projects/openproject-projects.module').then(m => m.OpenprojectProjectsModule)
},
{
name: 'project_copy.**',
parent: 'root',
url: '/copy',
loadChildren: () => import('../projects/openproject-projects.module').then(m => m.OpenprojectProjectsModule)
},
];
/**

@ -1,173 +0,0 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe CopyProjectsController, type: :controller do
let(:current_user) { FactoryBot.create(:admin) }
let(:permission) { :copy_projects }
let(:project) { FactoryBot.create(:project, public: false) }
let(:copy_project_params) do
{
description: 'Some pretty description',
public: false
}
end
before do
allow(User).to receive(:current).and_return current_user
# Prevent actually setting User.current.
# Otherwise the set user might be used in the next spec.
allow(User).to receive(:current=)
end
describe 'copy_from_settings uses correct project to copy from' do
before do
get 'copy_project', params: { id: project.id, coming_from: :settings }
end
it { expect(assigns(:project)).to eq(project) }
it { expect(assigns(:copy_project).id).to be_nil }
it { expect(response).to render_template('copy_from_settings') }
end
describe 'copy_from_settings without valid project' do
before { get 'copy_project', params: { id: 'invalid' } }
it { expect(response.code).to eq('404') }
end
describe 'copy_from_settings without name and identifier' do
before do
post 'copy',
params: { id: project.id, project: copy_project_params }
end
it { expect(response).to render_template('copy_from_settings') }
it 'should display error validation messages' do
expect(assigns(:errors)).not_to be_empty
end
end
describe 'copy_from_settings permissions' do
def fetch
get 'copy_project', params: { id: project.id, coming_from: :settings }
end
it_should_behave_like 'a controller action which needs project permissions'
end
shared_examples_for 'successful copy' do
it {
expect(flash[:notice]).to eq(I18n.t('copy_project.started', source_project_name: source_project.name,
target_project_name: target_project_name))
}
end
def copy_project(project)
post 'copy',
params: {
id: project.id,
project: copy_project_params.merge(identifier: 'copy', name: 'copy')
}
perform_enqueued_jobs
end
describe 'copy creates a new project' do
before { copy_project(project) }
def expect_redirect_to
true
end
it { expect(Project.count).to eq(2) }
it 'copied project enables modules of source project' do
expect(Project.order(:id).last.enabled_modules.map(&:name))
.to match_array(project.enabled_modules.map(&:name) - %w[repository])
end
it_behaves_like 'successful copy' do
let(:source_project) { project }
let(:target_project_name) { 'copy' }
end
it 'should redirect to job status' do
expect(response).to redirect_to /\/job_statuses\/[\w-]+/
end
end
describe 'copy permissions' do
def fetch
post 'copy',
params: {
id: project.id,
project: copy_project_params.merge(identifier: 'copy', name: 'copy')
}
end
def expect_redirect_to
true
end
let(:permission) { %i[copy_projects add_project] }
let(:project) { FactoryBot.create(:project, public: false) }
it_should_behave_like 'a controller action which needs project permissions'
end
describe 'copy sends eMail' do
let(:maildouble) { double('Mail::Message', deliver: true) }
before do
allow(maildouble).to receive(:deliver_now).and_return nil
end
context 'on success' do
it 'user receives success mail' do
expect(ProjectMailer).to receive(:copy_project_succeeded).and_return(maildouble)
copy_project(project)
end
end
context 'on error' do
before do
allow(ProjectMailer).to receive(:with_deliveries).and_raise(ActiveRecord::RecordNotFound)
end
it 'user receives success mail' do
expect(ProjectMailer).to receive(:copy_project_failed).and_return(maildouble)
copy_project(project)
end
end
end
end

@ -327,4 +327,27 @@ describe ProjectsController, type: :controller do
expect(project).not_to be_archived
end
end
describe '#copy' do
let(:project) { FactoryBot.create :project, identifier: 'blog' }
it "renders 'copy'" do
get 'copy', params: { id: project.id }
expect(response).to be_successful
expect(response).to render_template 'copy'
end
context 'as non authorized user' do
let(:user) { FactoryBot.build_stubbed :user }
before do
login_as user
end
it "shows an error" do
get 'copy', params: { id: project.id }
expect(response.status).to eq 403
end
end
end
end

@ -119,6 +119,8 @@ describe 'Projects copy',
attachments: [FactoryBot.build(:attachment, container: nil, filename: 'attachment.pdf')]
end
let(:parent_field) { ::FormFields::SelectFormField.new :parent }
before do
login_as user
@ -132,22 +134,22 @@ describe 'Projects copy',
original_settings_page = Pages::Projects::Settings.new(project)
original_settings_page.visit!
click_link 'Copy'
fill_in 'Name', with: 'Copied project'
find('.toolbar a', text: 'Copy').click
expect(page).to have_text "Copy project \"#{project.name}\""
fill_in 'Name', with: 'Copied project', wait: 10
# Check copy wiki page attachments
check 'only_wiki_page_attachments'
# Expand advanced settings
click_on 'Advanced settings'
# the value of the custom field should be preselected
editor = ::Components::WysiwygEditor.new ".form--field.custom_field_#{project_custom_field.id}"
editor = ::Components::WysiwygEditor.new "[data-qa-field-name='customField#{project_custom_field.id}']"
editor.expect_value 'some text cf'
click_button 'Copy'
click_button 'Save'
original_settings_page.expect_notification message: I18n.t('copy_project.started',
source_project_name: project.name,
target_project_name: 'Copied project'),
type: 'notice'
expect(page).to have_text 'The job has been queued and will be processed shortly.'
perform_enqueued_jobs
@ -160,14 +162,12 @@ describe 'Projects copy',
copied_settings_page.visit!
# has the parent of the original project
page.within('label', text: 'Subproject of') do
expect(page)
.to have_selector('.ng-value', text: parent_project.name)
end
parent_field.expect_selected parent_project.name
# copies over the value of the custom field
# has the parent of the original project
expect(page).to have_selector('.op-uc-container', text: 'some text cf')
editor = ::Components::WysiwygEditor.new "[data-qa-field-name='customField#{project_custom_field.id}']"
editor.expect_value 'some text cf'
# has wp custom fields of original project active
copied_settings_page.visit_tab!('custom_fields')
@ -281,15 +281,13 @@ describe 'Projects copy',
original_settings_page = Pages::Projects::Settings.new(project)
original_settings_page.visit!
click_link 'Copy'
find('.toolbar a', text: 'Copy').click
fill_in 'Name', with: 'Copied project'
click_button 'Copy'
click_button 'Save'
original_settings_page.expect_notification message: I18n.t('copy_project.started',
source_project_name: project.name,
target_project_name: 'Copied project'),
type: 'notice'
expect(page).to have_text 'The job has been queued and will be processed shortly.'
perform_enqueued_jobs

@ -29,9 +29,8 @@
require 'spec_helper'
require File.expand_path('../support/permission_specs', __dir__)
describe CopyProjectsController, 'copy_projects permission', type: :controller do
describe ProjectsController, 'copy_projects permission', type: :controller do
include PermissionSpecs
check_permission_required_for('copy_projects#copy', :copy_projects)
check_permission_required_for('copy_projects#copy_project', :copy_projects)
check_permission_required_for('projects#copy', :copy_projects)
end

@ -105,27 +105,8 @@ describe ProjectsController, type: :routing do
end
it do
expect(get('projects/123/copy_project_from_settings')).to route_to(
controller: 'copy_projects', action: 'copy_project', id: '123',
coming_from: 'settings'
)
end
it do
expect(post('projects/123/copy_from_settings')).to route_to(
controller: 'copy_projects',
action: 'copy',
id: '123',
coming_from: 'settings'
)
end
it do
expect(post('projects/123/copy_from_admin')).to route_to(
controller: 'copy_projects',
action: 'copy',
id: '123',
coming_from: 'admin'
expect(get('projects/123/copy')).to route_to(
controller: 'projects', action: 'copy', id: '123'
)
end
end

Loading…
Cancel
Save