[37026] Add new project form with template selection (#9193)

* Projects form working with formly 50%

* Removed console.log

* Working with formattable

* Working with formattable

* Input with id and label

* Input with id and label

* Useless dependencies removed

* Saving forms + required labels with *

* First backend validation approach

* Removed reload on type change + keep model on route changes

* Handlig backend validations with setError

* Formatting the form model to submit

* Make up refactor

* working with op-form-field

* Form creation moved to the service

* Working with op-form-field wrapper

* Working with validation and op-form-field

* Working with []CustomFields

* Clean up

* Clean up

* Clean up

* Clean up

* Form routing working

* Notification on form error and success

* Refactor + removed useless dynamic form observable

* DynamicFieldsService with tests

* Refactor: inputs catalog + catch form load error

* Filter out non writable fields

* Refactor: naming consistency

* Cleaning comments

* dynamic-fields-service tests + wrapper component

* DynamicForm Tests

* @ngx-formly/core dependency added

* Cleaning up

* Provide DynamicFieldsService in root so it can be used independently

* DynamicForm working as a FormControl

* Getting route params sync

* Global FormsService: submit + formatting + error handling

* Fix: @Optional() FormGroupDirective in OpFormFieldComponent

* Code climate fix

* Removed CdkTextareaAutosize because of CDK issue 22469

* DynamicFormComponent tests

* Dynamic input test helpers + boolean and text tests

* Refactor edit fields to avoid circular dependencies in the dynamic forms

* Naming fix

* IntegerInputComponent tests

* SelectInputComponent tests

* Fix: duplicated identifier on inputs

* Extract toolbar to be reused for now

Still TBD whether we want to move them right now to the frontend?

* Create new project route and redirect to rails view after saving

* fieldsSettingsPipe + hide 'identifier' on projects

* Handling multi-values (also as links) and passwords

* Some TODOs removed

* FormattableTextareaInputComponent tests

* Projects form working with formly 50%

* Removed console.log

* Working with formattable

* Working with formattable

* Input with id and label

* Input with id and label

* Useless dependencies removed

* Saving forms + required labels with *

* First backend validation approach

* Removed reload on type change + keep model on route changes

* Handlig backend validations with setError

* Formatting the form model to submit

* Make up refactor

* working with op-form-field

* Form creation moved to the service

* Working with op-form-field wrapper

* Working with validation and op-form-field

* Working with []CustomFields

* Clean up

* Clean up

* Clean up

* Clean up

* Form routing working

* Notification on form error and success

* Refactor + removed useless dynamic form observable

* DynamicFieldsService with tests

* Refactor: inputs catalog + catch form load error

* Filter out non writable fields

* Refactor: naming consistency

* Cleaning comments

* dynamic-fields-service tests + wrapper component

* DynamicForm Tests

* @ngx-formly/core dependency added

* Cleaning up

* DynamicForm working as a FormControl

* Getting route params sync

* Global FormsService: submit + formatting + error handling

* Fix: @Optional() FormGroupDirective in OpFormFieldComponent

* Code climate fix

* Removed CdkTextareaAutosize because of CDK issue 22469

* DynamicFormComponent tests

* Dynamic input test helpers + boolean and text tests

* Refactor edit fields to avoid circular dependencies in the dynamic forms

* Naming fix

* IntegerInputComponent tests

* SelectInputComponent tests

* Fix: duplicated identifier on inputs

* Extract toolbar to be reused for now

Still TBD whether we want to move them right now to the frontend?

* Create new project route and redirect to rails view after saving

* fieldsSettingsPipe + hide 'identifier' on projects

* Handling multi-values (also as links) and passwords

* Some TODOs removed

* FormattableTextareaInputComponent tests

* _isResourceSchema based on parent?.location

* Scope DynamicFieldsService to DynamicFormComponent

* Added backend validation method to FormsService

* Remove form from DynamicForm when not isStandaloneForm

* Allow multiple form keys to validate

* Remove form from non standalone forms

* Remove duplicated button

* Fix: dynamic form with ng-template not showing validations

* DynamicForm programatic validation

* Add new project component that posts to the copy form

* Hide the submit button input

* Unify ckeditor spacings with other dynamic-form elements

* Basic fixes to invalid dynamic form fields

* Load the templated projects with newly added action

* Fix invalid styles for dynamic inputs

* Updates to form styles, add op-form and op-fieldset

* Soften inputs to 2px

* Update input styles globally

* Redirect to job status modal when response is job status

* fix: load projects routes

* Collapsible groups working

* Fix: setTimeout datepicker to wait DOM ready

* resourceId added to project settings form

* fieldsLayoutConfig

* formUrl & formHttpMethod @Inputs

* Avoid replacing non ending '/form' strings

* formUrl @Input

* @Input model

* identifier hidden

* Populate the model

* Add new project component

* Tests removed

* Show create message when creating new resource

* Change location to rails

* Add field_name helper for testing

* Add form field helper classes and fix tests

* Remove templated from copy for now

* Fix more tests

* Reset toolbar from dev

* Skip identifier test

* Set overflow to visible when expanded

* Fix more tests

* Rename data-field-name to data-qa-field-name

* Review feedback

* Uncollapse group fields when they contains errors

* Fix text spec

* Remove instantiate template service

* Remove unnecessary ProjectController#create

Co-authored-by: Aleix Suau <info@macrofonoestudio.es>
Co-authored-by: Benjamin Bädorf <b.baedorf@openproject.com>
pull/9217/head
Oliver Günther 4 years ago committed by GitHub
parent f309cb9b09
commit 71b330ea80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 55
      app/contracts/projects/instantiate_template_contract.rb
  2. 94
      app/controllers/projects_controller.rb
  3. 3
      app/services/projects/copy_service.rb
  4. 74
      app/services/projects/instantiate_template_service.rb
  5. 15
      app/views/projects/new.html.erb
  6. 4
      config/locales/js-en.yml
  7. 2
      config/routes.rb
  8. 4
      frontend/src/app/core/services/forms/forms.service.ts
  9. 17
      frontend/src/app/core/services/forms/typings.d.ts
  10. 35
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-field-group-wrapper/dynamic-field-group-wrapper.component.html
  11. 4
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-field-group-wrapper/dynamic-field-group-wrapper.component.ts
  12. 11
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-field-wrapper/dynamic-field-wrapper.component.html
  13. 0
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-field-wrapper/dynamic-field-wrapper.component.sass
  14. 11
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-field-wrapper/dynamic-field-wrapper.component.ts
  15. 36
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-form/dynamic-form.component.html
  16. 120
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-form/dynamic-form.component.ts
  17. 15
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-inputs/date-input/components/date-picker-adapter/date-picker-adapter.component.ts
  18. 8
      frontend/src/app/modules/common/dynamic-forms/dynamic-forms.module.ts
  19. 78
      frontend/src/app/modules/common/dynamic-forms/services/dynamic-fields/dynamic-fields.service.ts
  20. 18
      frontend/src/app/modules/common/dynamic-forms/services/dynamic-form/dynamic-form.service.ts
  21. 15
      frontend/src/app/modules/common/dynamic-forms/typings.d.ts
  22. 6
      frontend/src/app/modules/common/forms/form-field/form-field.component.ts
  23. 1
      frontend/src/app/modules/fields/edit/field-types/formattable-edit-field/formattable-edit-field.module.ts
  24. 27
      frontend/src/app/modules/projects/components/new-project/new-project.component.html
  25. 181
      frontend/src/app/modules/projects/components/new-project/new-project.component.ts
  26. 7
      frontend/src/app/modules/projects/components/projects/projects.component.html
  27. 10
      frontend/src/app/modules/projects/openproject-projects.module.ts
  28. 6
      frontend/src/app/modules/projects/projects-routes.ts
  29. 5
      frontend/src/app/modules/router/openproject.routes.ts
  30. 74
      spec/contracts/projects/instantiate_template_contract_spec.rb
  31. 309
      spec/controllers/projects_controller_spec.rb
  32. 11
      spec/features/global_roles/global_create_project_spec.rb
  33. 4
      spec/features/menu_items/quick_add_menu_spec.rb
  34. 2
      spec/features/projects/attribute_help_texts_spec.rb
  35. 36
      spec/features/projects/project_status_administration_spec.rb
  36. 51
      spec/features/projects/projects_custom_fields_spec.rb
  37. 108
      spec/features/projects/projects_spec.rb
  38. 46
      spec/features/projects/template_spec.rb
  39. 28
      spec/routing/project_routing_spec.rb
  40. 29
      spec/support/form_fields/form_field.rb
  41. 30
      spec/support/form_fields/input_form_field.rb
  42. 23
      spec/support/form_fields/select_form_field.rb

@ -1,55 +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.
#++
module Projects
class InstantiateTemplateContract < CreateContract
def self.visible_templates(user)
Project
.allowed_to(user, :copy_projects)
.where(templated: true)
end
validate :validate_user_allowed_to_instantiate_template
private
def validate_user_allowed_to_instantiate_template
errors.add(:base, :error_unauthorized) unless visible_template?
end
def visible_template?
return false if template_project_id.nil?
self.class.visible_templates(user).exists?(template_project_id)
end
def template_project_id
options[:template_project_id]
end
end
end

@ -32,14 +32,11 @@ class ProjectsController < ApplicationController
menu_item :overview
menu_item :roadmap, only: :roadmap
before_action :find_project, except: %i[index level_list new create]
before_action :authorize, only: %i[update modules types custom_fields]
before_action :authorize_global, only: %i[new create]
before_action :find_project, except: %i[index level_list new]
before_action :authorize, only: %i[modules types custom_fields]
before_action :authorize_global, only: %i[new]
before_action :require_admin, only: %i[archive unarchive destroy destroy_info]
before_action :assign_default_create_variables, only: %i[new]
before_action :new_project, only: %i[new]
include SortHelper
include PaginationHelper
include CustomFieldsHelper
@ -66,35 +63,9 @@ class ProjectsController < ApplicationController
end
def new
Projects::SetAttributesService
.new(user: current_user, model: @project, contract_class: EmptyContract)
.call(params.permit(:parent_id))
render layout: 'no_menu'
end
current_menu_item :new do
:new_project
end
def create
call_result =
if params[:from_template].present?
create_from_template
else
create_from_params
end
# In success case, nothing to do
call_result.on_failure do
@project = call_result.result
@errors = call_result.errors
assign_default_create_variables
render action: 'new', layout: 'no_menu'
end
end
def update
@altered_project = Project.find(@project.id)
@ -263,33 +234,6 @@ class ProjectsController < ApplicationController
protected
def create_from_params
call_result = Projects::CreateService
.new(user: current_user)
.call(permitted_params.project)
@project = call_result.result
call_result.on_success do
flash[:notice] = t(:notice_successful_create)
redirect_work_packages_or_overview
end
call_result
end
def create_from_template
call_result = Projects::InstantiateTemplateService
.new(user: current_user, template_id: params[:from_template])
.call(permitted_params.project)
call_result.on_success do
flash[:notice] = t('project.template.copying')
redirect_to job_status_path(call_result.result.job_id)
end
call_result
end
def load_projects(query)
query
.results
@ -309,36 +253,4 @@ class ProjectsController < ApplicationController
Setting.demo_projects_available = value
end
end
def assign_default_create_variables
@wp_custom_fields = WorkPackageCustomField.order("#{CustomField.table_name}.position")
@types = ::Type.all
end
def new_project
# If a template is passed, assign that as default
@template_project = template_project_from_param
@project =
if @template_project.nil?
Project.new
else
Projects::CopyService
.new(user: current_user, source: @template_project)
.call(target_project_params: {}, attributes_only: true)
.result
end
# Allow setting the name when selecting template
if params[:name]
@project.name = params[:name]
end
end
def template_project_from_param
if params[:template_project]
::Projects::InstantiateTemplateContract
.visible_templates(current_user)
.find_by(id: params[:template_project])
end
end
end

@ -66,6 +66,7 @@ module Projects
def initialize_copy(source, params)
target = Project.new
target.attributes = source.attributes.dup.except(*skipped_attributes)
# Clear enabled modules
target.enabled_modules = []
@ -126,7 +127,7 @@ module Projects
end
def skipped_attributes
%w[id created_at updated_at name identifier active lft rgt]
%w[id created_at updated_at name identifier active templated lft rgt]
end
end
end

@ -1,74 +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.
#++
module Projects
class InstantiateTemplateService < ::BaseServices::Create
attr_reader :template_id
def initialize(user:, template_id:)
@template_id = template_id
super user: user,
contract_class: Projects::InstantiateTemplateContract,
contract_options: { template_project_id: template_id }
end
def after_validate(params, _)
job = ::CopyProjectJob.perform_later(
user_id: user.id,
source_project_id: template_id,
target_project_params: project_params(params),
# Copy all associations
associations_to_copy: nil,
# Send mails for now until we send our own mails
send_mails: true
)
ServiceResult.new(success: true, result: job)
end
# Do not actually try to save the project here
# but simply pass the previous call
def persist(call)
call
end
private
##
# Modifies params to ensure we unset
# the templated option
def project_params(params)
params.to_h.merge(
templated: false
)
end
end
end

@ -28,16 +28,5 @@ See docs/COPYRIGHT.rdoc for more details.
++#%>
<% html_title t("label_project_new") %>
<%= toolbar title: t(:label_project_new) %>
<%= labelled_tabular_form_for @project do |f| %>
<%= render partial: 'form',
locals: {
f: f,
project: @project,
errors: @errors,
render_advanced: true,
render_types: false,
render_modules: false,
render_custom_fields: false
} %>
<%= styled_button_tag t(:button_create), class: '-highlight -with-icon icon-checkmark' %>
<% end %>
<openproject-base></openproject-base>

@ -573,6 +573,7 @@ en:
work_package_belongs_to: 'This work package belongs to project %{projectname}.'
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"
autocompleter:
label: 'Project autocompletion'
@ -1140,3 +1141,6 @@ en:
submit_success_message: 'The form was successfully submitted'
load_error_message: 'There was an error loading the form'
validation_error_message: 'Please fix the errors present in the form'
advanced_settings: 'Advanced settings'

@ -166,7 +166,7 @@ OpenProject::Application.routes.draw do
match '/unwatch' => 'watchers#unwatch', via: :delete
end
resources :projects, except: %i[show edit] do
resources :projects, except: %i[show edit create] do
member do
ProjectSettingsHelper.project_settings_tabs.each do |tab|
get "settings/#{tab[:name]}", controller: "project_settings/#{tab[:name]}", action: 'show', as: "settings_#{tab[:name]}"

@ -13,9 +13,9 @@ export class FormsService {
private _httpClient:HttpClient,
) { }
submit$(form:FormGroup, resourceEndpoint:string, resourceId?:string):Observable<any> {
submit$(form:FormGroup, resourceEndpoint:string, resourceId?:string, formHttpMethod?: 'post' | 'patch'):Observable<any> {
const modelToSubmit = this._formatModelToSubmit(form.value);
const httpMethod = resourceId ? 'patch' : 'post';
const httpMethod = resourceId ? 'patch' : (formHttpMethod || 'post');
const url = resourceId ? `${resourceEndpoint}/${resourceId}` : resourceEndpoint;
return this._httpClient

@ -1,10 +1,6 @@
interface IOPFormSettings {
interface IOPFormSettingsResource {
_type?: "Form";
_embedded: {
payload: IOPFormModel;
schema: IOPFormSchema;
validationErrors?: IOPValidationErrors;
};
_embedded: IOPFormSettings;
_links?: {
self: IOPApiCall;
validate: IOPApiCall;
@ -13,6 +9,13 @@ interface IOPFormSettings {
};
}
interface IOPFormSettings {
payload: IOPFormModel;
schema: IOPFormSchema;
validationErrors?: IOPValidationErrors;
[nonUsedSchemaKeys:string]:any,
}
interface IOPFormSchema {
_type?: "Schema";
_dependencies?: unknown[];
@ -40,6 +43,8 @@ interface IOPFieldSchema {
required?: boolean;
hasDefault: boolean;
name?: string;
minLength?: number,
maxLength?: number,
attributeGroup?: string;
location?: '_links' | string;
options: {

@ -1,7 +1,30 @@
<div class="op-field-group-wrapper">
<div class="op-field-group-wrapper--header">{{ to.label }}</div>
<fieldset class="form--fieldset -collapsible"
[ngClass]="{'collapsed': to.collapsibleFieldGroupsCollapsed}"
*ngIf="to?.collapsibleFieldGroups">
<legend class="form--fieldset-legend"
title="Show/hide"
(click)="to.collapsibleFieldGroupsCollapsed = !to.collapsibleFieldGroupsCollapsed">
<a href="#" class="op-form-group--label">
{{ to.label }}
</a>
</legend>
<div class="op-field-group-wrapper--body">
<ng-container #fieldComponent></ng-container>
</div>
</div>
<div class="op-form-group--content"
[ngStyle]="{
'height': to.collapsibleFieldGroupsCollapsed ? 0 : 'auto',
'visibility': to.collapsibleFieldGroupsCollapsed ? 'hidden' : 'visible',
'overflow': to.collapsibleFieldGroupsCollapsed ? 'hidden' : 'visible'
}">
<ng-container #fieldComponent></ng-container>
</div>
</fieldset>
<ng-container *ngIf="!to?.collapsibleFieldGroups">
<div class="op-form-group--label">
{{ to.label }}
</div>
<div class="op-form-group--content">
<ng-container #fieldComponent></ng-container>
</div>
</ng-container>

@ -1,4 +1,4 @@
import { Component, ViewChild, ViewContainerRef } from "@angular/core";
import { Component } from "@angular/core";
import { FieldWrapper } from "@ngx-formly/core";
@Component({
@ -7,6 +7,4 @@ import { FieldWrapper } from "@ngx-formly/core";
styleUrls: ["./dynamic-field-group-wrapper.component.scss"]
})
export class DynamicFieldGroupWrapperComponent extends FieldWrapper {
@ViewChild("fieldComponent", { read: ViewContainerRef })
fieldComponent: ViewContainerRef;
}

@ -0,0 +1,11 @@
<op-form-field [label]="to?.label"
[noWrapLabel]="to?.noWrapLabel"
[required]="to?.required"
[attr.data-qa-field-name]="to?.property">
<ng-container #fieldComponent slot=input></ng-container>
<formly-validation-message class="op-form-field--error"
[field]="field"
slot="errors">
</formly-validation-message>
</op-form-field>

@ -0,0 +1,11 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { FieldWrapper } from "@ngx-formly/core";
@Component({
selector: 'op-dynamic-field-wrapper',
templateUrl: './dynamic-field-wrapper.component.html',
styleUrls: ['./dynamic-field-wrapper.component.sass'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DynamicFieldWrapperComponent extends FieldWrapper {
}

@ -4,32 +4,16 @@
(submit)="submitForm(form)"
class="op-form">
<formly-form [form]="form"
[model]="model"
[model]="innerModel"
[fields]="fields"
(modelChange)="onModelChange($event)"
class="op-fieldset">
<ng-template formlyTemplate let-field>
<op-form-field *ngFor="let field of fields"
[label]="field.templateOptions?.label"
[noWrapLabel]="field.templateOptions?.noWrapLabel"
[required]="field.templateOptions?.required"
[attr.data-field-name]="field.property"
>
<formly-field [field]="field" slot=input></formly-field>
<formly-validation-message
class="op-form-field--error"
[field]="field"
slot="errors"
></formly-validation-message>
</op-form-field>
</ng-template>
</formly-form>
<div class="op-form--submit">
<div class="op-form--submit"
*ngIf="handleSubmit">
<button type="submit"
class="button -highlight"
*ngIf="handleSubmit"
[disabled]="inFlight">
{{text.save}}
</button>
@ -39,24 +23,14 @@
<!-- 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 don't find the injected FormGroupDirective. --->
the nested OpFormFieldComponent doesn't find the injected FormGroupDirective. --->
<div data-qa="op-form--container"
*ngIf="form && !isStandaloneForm">
<formly-form [form]="form"
[model]="model"
[model]="innerModel"
[fields]="fields"
(modelChange)="onModelChange($event)"
class="op-fieldset">
<ng-template formlyTemplate let-field>
<op-form-field *ngFor="let field of fields"
[label]="field.templateOptions?.label"
[noWrapLabel]="field.templateOptions?.noWrapLabel"
[required]="field.templateOptions?.required">
<formly-field [field]="field" slot=input></formly-field>
<formly-validation-message [field]="field" slot=errors></formly-validation-message>
</op-form-field>
</ng-template>
</formly-form>
</div>

@ -6,6 +6,7 @@ import {
ViewChild,
EventEmitter,
forwardRef,
SimpleChanges,
} from "@angular/core";
import { FormlyForm } from "@ngx-formly/core";
import { DynamicFormService } from "../../services/dynamic-form/dynamic-form.service";
@ -15,7 +16,7 @@ import {
} from "../../typings";
import { I18nService } from "core-app/modules/common/i18n/i18n.service";
import { PathHelperService } from "core-app/modules/common/path-helper/path-helper.service";
import { catchError, finalize, take } from "rxjs/operators";
import { catchError, finalize } from "rxjs/operators";
import { HalSource } from "core-app/modules/hal/resources/hal-resource";
import { NotificationsService } from "core-app/modules/common/notifications/notifications.service";
import { DynamicFieldsService } from "core-app/modules/common/dynamic-forms/services/dynamic-fields/dynamic-fields.service";
@ -83,33 +84,48 @@ import { FormsService } from "core-app/core/services/forms/forms.service";
]
})
export class DynamicFormComponent extends UntilDestroyedMixin implements ControlValueAccessor, OnChanges {
@Input() resourceId:string;
// 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;
@Input() settings:{
payload:IOPFormModel,
schema:IOPFormSchema,
[nonUsedSchemaKeys:string]:any,
};
// 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;
// Chance to modify the dynamicFormFields settings before the form is rendered
@Input() fieldsSettingsPipe: (dynamicFieldsSettings:IOPFormlyFieldSettings[]) => IOPFormlyFieldSettings[];
@Input() showNotifications = true;
@Input() showValidationErrorsOn: 'change' | 'blur' | 'submit' | 'never' = 'submit';
@Input() handleSubmit = true;
@Input() set model (payload:IOPFormModel) {
if (!this.innerModel && !payload) {
return;
}
const formattedModel = this._dynamicFieldsService.getFormattedFieldsModel(payload);
this.innerModel = formattedModel;
};
/** Initial payload to POST to the form */
@Input() initialPayload:Object = {};
@Output() modelChange = new EventEmitter<IOPFormModel>();
@Output() submitted = new EventEmitter<HalSource>();
@Output() errored = new EventEmitter<IOPFormErrorResponse>();
fields:IOPFormlyFieldSettings[];
model:IOPFormModel;
form: FormGroup;
resourceEndpoint:string | null;
formEndpoint:string | null;
inFlight:boolean;
text = {
save: this._I18n.t('js.button_save'),
validation_error_message: this._I18n.t('js.forms.validation_error_message'),
load_error_message: this._I18n.t('js.forms.load_error_message'),
submit_success_message: this._I18n.t('js.notice_successful_update'),
successful_update: this._I18n.t('js.notice_successful_update'),
successful_create: this._I18n.t('js.notice_successful_create'),
};
noSettingsSourceErrorMessage = `DynamicFormComponent needs a settings or resourcePath @Input
in order to fetch its setting. Please provide one.`;
@ -117,6 +133,11 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements Control
and validated. Please provide one.`;
onChange:Function;
onTouch:Function;
innerModel:IOPFormModel;
get model() {
return this.form.value;
}
get isFormControl():boolean {
return !!this.onChange && !!this.onTouch;
@ -132,6 +153,7 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements Control
constructor(
private _dynamicFormService: DynamicFormService,
private _dynamicFieldsService: DynamicFieldsService,
private _I18n:I18nService,
private _pathHelperService:PathHelperService,
private _notificationsService:NotificationsService,
@ -142,7 +164,7 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements Control
writeValue(value:{[key:string]:any}):void {
if (value) {
this.model = value;
this.innerModel = value;
}
}
@ -158,8 +180,14 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements Control
disabled ? this.form.disable() : this.form.enable();
}
ngOnChanges() {
this._initializeDynamicForm();
ngOnChanges(changes:SimpleChanges) {
this._initializeDynamicForm(
changes?.settings?.currentValue,
this.resourcePath,
this.resourceId,
this.formUrl,
this.innerModel || this.initialPayload
);
}
onModelChange(changes:any) {
@ -176,20 +204,20 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements Control
return;
}
if (!this.resourceEndpoint) {
if (!this.formEndpoint) {
throw new Error(this.noPathToSubmitToError);
}
this.inFlight = true;
this._dynamicFormService
.submit$(form, this.resourceEndpoint, this.resourceId)
.submit$(form, this.formEndpoint, this.resourceId, this.formHttpMethod)
.pipe(
finalize(() => this.inFlight = false)
)
.subscribe(
(formResource:HalSource) => {
this.submitted.emit(formResource);
this.showNotifications && this._notificationsService.addSuccess(this.text.submit_success_message);
this.showNotifications && this.showSuccessNotification();
},
(error:IOPFormErrorResponse) => {
this.errored.emit(error);
@ -198,33 +226,59 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements Control
);
}
private showSuccessNotification():void {
let submit_message = this.resourceId ? this.text.successful_update : this.text.successful_create;
this._notificationsService.addSuccess(submit_message);
}
validateForm() {
if (!this.resourceEndpoint) {
if (!this.formEndpoint) {
throw new Error(this.noPathToSubmitToError);
}
this._formsService.validateForm$(this.form, this.resourceEndpoint).subscribe();
this._formsService.validateForm$(this.form, this.formEndpoint).subscribe();
}
private _initializeDynamicForm() {
this.resourceEndpoint = this.resourcePath ?
`${this._pathHelperService.api.v3.apiV3Base}${this.resourcePath}` :
null;
if (this.settings) {
private _initializeDynamicForm(
settings?:IOPFormSettings,
resourcePath?:string,
resourceId?:string,
formUrl?:string,
payload?:Object,
) {
if (settings) {
this._setupDynamicFormFromSettings();
} else if (this.resourceEndpoint) {
this._setupDynamicFormFromBackend();
} else {
console.error(this.noSettingsSourceErrorMessage);
const newFormEndPoint = this._getFormEndPoint(formUrl, resourcePath);
if (newFormEndPoint && newFormEndPoint !== this.formEndpoint) {
this.formEndpoint = newFormEndPoint;
this._setupDynamicFormFromBackend(this.formEndpoint, resourceId, payload);
} else if (!newFormEndPoint) {
console.error(this.noSettingsSourceErrorMessage);
}
}
}
private _setupDynamicFormFromBackend() {
const url = `${this.resourceEndpoint}/${this.resourceId ? this.resourceId + '/' : ''}form`;
private _getFormEndPoint(formUrl?:string, resourcePath?:string): string | null {
let formEndpoint;
if (formUrl) {
formEndpoint = formUrl.endsWith(`/form`) ?
formUrl.replace(`/form`, ``) :
formUrl;
} else if (resourcePath) {
formEndpoint = `${this._pathHelperService.api.v3.apiV3Base}${resourcePath}`;
} else {
formEndpoint = null;
}
return formEndpoint;
}
private _setupDynamicFormFromBackend(formEndpoint?:string, resourceId?:string, payload?:Object) {
this._dynamicFormService
.getSettingsFromBackend$(url)
.getSettingsFromBackend$(formEndpoint, resourceId, payload)
.pipe(
catchError(error => {
this._notificationsService.addError(this.text.load_error_message);
@ -235,7 +289,7 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements Control
}
private _setupDynamicFormFromSettings() {
const formattedSettings:IOPFormSettings = {
const formattedSettings:IOPFormSettingsResource = {
_embedded: {
payload: this.settings.payload,
schema: this.settings.schema,
@ -249,10 +303,10 @@ export class DynamicFormComponent extends UntilDestroyedMixin implements Control
private _setupDynamicForm({fields, model, form}:IOPDynamicFormSettings) {
this.form = form;
this.fields = this.fieldsSettingsPipe ? this.fieldsSettingsPipe(fields) : fields;
this.model = model;
this.innerModel = model;
if (!this.isStandaloneForm) {
this.onChange(this.model);
this.onChange(this.innerModel);
}
}
}

@ -1,4 +1,4 @@
import { Component, forwardRef } from '@angular/core';
import { AfterViewInit, ChangeDetectorRef, Component, forwardRef, NgZone } from '@angular/core';
import { OpDatePickerComponent } from "core-app/modules/common/op-date-picker/op-date-picker.component";
import { TimezoneService } from "core-components/datetime/timezone.service";
import * as moment from "moment";
@ -15,12 +15,14 @@ import { NG_VALUE_ACCESSOR } from "@angular/forms";
}
]
})
export class DatePickerAdapterComponent extends OpDatePickerComponent {
export class DatePickerAdapterComponent extends OpDatePickerComponent implements AfterViewInit {
onControlChange = (_:any) => { }
onControlTouch = () => { }
constructor(
timezoneService:TimezoneService,
private _ngZone: NgZone,
private _changeDetectorRef:ChangeDetectorRef,
) {
super(timezoneService);
}
@ -41,6 +43,15 @@ export class DatePickerAdapterComponent extends OpDatePickerComponent {
this.disabled = disabled;
}
ngAfterViewInit():void {
this._ngZone.runOutsideAngular(() => {
setTimeout(() => {
this.initializeDatepicker();
this._changeDetectorRef.detectChanges();
});
});
}
onInputChange(_event:KeyboardEvent) {
if (this.isEmpty()) {
this.datePickerInstance.clear();

@ -20,6 +20,7 @@ import { FormattableControlComponent } from './components/dynamic-inputs/formatt
import { OpenprojectCommonModule } from "core-app/modules/common/openproject-common.module";
import { FormattableEditFieldModule } from "core-app/modules/fields/edit/field-types/formattable-edit-field/formattable-edit-field.module";
import { DatePickerModule } from "core-app/modules/common/op-date-picker/date-picker.module";
import { DynamicFieldWrapperComponent } from './components/dynamic-field-wrapper/dynamic-field-wrapper.component';
@NgModule({
imports: [
@ -36,9 +37,13 @@ import { DatePickerModule } from "core-app/modules/common/op-date-picker/date-pi
],
wrappers: [
{
name: "op-dynamic-field-group-wrapper",
name: 'op-dynamic-field-group-wrapper',
component: DynamicFieldGroupWrapperComponent,
},
{
name: 'op-dynamic-field-wrapper',
component: DynamicFieldWrapperComponent,
},
]
}),
HttpClientModule,
@ -63,6 +68,7 @@ import { DatePickerModule } from "core-app/modules/common/op-date-picker/date-pi
SelectInputComponent,
FormattableTextareaInputComponent,
FormattableControlComponent,
DynamicFieldWrapperComponent,
],
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: OpenProjectHeaderInterceptor, multi: true },

@ -98,7 +98,7 @@ export class DynamicFieldsService {
'Category', 'CustomOption', 'Project', 'ProjectStatus'
]
},
]
];
constructor(
private _httpClient:HttpClient,
@ -108,18 +108,25 @@ export class DynamicFieldsService {
const formFieldGroups = formSchema._attributeGroups;
const fieldSchemas = this._getFieldsSchemasWithKey(formSchema);
const formlyFields = fieldSchemas
.map(fieldSchema => this._getFormlyFieldConfig(fieldSchema))
.map(fieldSchema => this._getFormlyFieldConfig(fieldSchema, formPayload))
.filter(f => f !== null) as IOPFormlyFieldSettings[];
const formlyFormWithFieldGroups = this._getFormlyFormWithFieldGroups(formFieldGroups, formlyFields);
return formlyFormWithFieldGroups;
}
getModel(formSchema:IOPFormSchema, formPayload:IOPFormModel):IOPFormModel {
const fieldSchemas = this._getFieldsSchemasWithKey(formSchema);
const fieldsModel = this._getFieldsModel(fieldSchemas, formPayload);
getModel(formPayload:IOPFormModel):IOPFormModel {
return this.getFormattedFieldsModel(formPayload);
}
getFormattedFieldsModel(formModel:IOPFormModel = {}):IOPFormModel {
const {_links:resourcesModel, ...otherElementsModel} = formModel;
const model = {
...otherElementsModel,
_links: this._getFormattedResourcesModel(resourcesModel),
}
return fieldsModel;
return model;
}
private _getFieldsSchemasWithKey(formSchema:IOPFormSchema):IOPFieldSchemaWithKey[] {
@ -142,17 +149,7 @@ export class DynamicFieldsService {
}
private _isFieldSchema(schemaValue:IOPFieldSchemaWithKey | any):boolean {
return schemaValue?.type;
}
private _getFieldsModel(fieldSchemas:IOPFieldSchemaWithKey[], formModel:IOPFormModel = {}):IOPFormModel {
const {_links:resourcesModel, ...otherElementsModel} = formModel;
const model = {
...otherElementsModel,
_links: this._getFormattedResourcesModel(resourcesModel),
}
return model;
return !!schemaValue?.type;
}
private _getFormattedResourcesModel(resourcesModel:IOPFormModel['_links'] = {}): IOPFormModel['_links']{
@ -161,8 +158,8 @@ 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?.title }) :
resource?.href && { ...resource, name: resource?.title };
resource.map(resourceElement => resourceElement?.href && { ...resourceElement, name: resourceElement?.name || resourceElement?.title }) :
resource?.href && { ...resource, name: resource?.name || resource?.title };
result = {
...result,
@ -173,22 +170,29 @@ export class DynamicFieldsService {
}, {});
}
private _getFormlyFieldConfig(field:IOPFieldSchemaWithKey):IOPFormlyFieldSettings|null {
const { key, name:label, required } = field;
const fieldTypeConfigSearch = this._getFieldTypeConfig(field);
private _getFormlyFieldConfig(fieldSchema:IOPFieldSchemaWithKey, formPayload:IOPFormModel):IOPFormlyFieldSettings|null {
const {key, name:label, required, hasDefault, minLength, maxLength} = fieldSchema;
const fieldTypeConfigSearch = this._getFieldTypeConfig(fieldSchema);
if (!fieldTypeConfigSearch) {
return null;
}
const { templateOptions, ...fieldTypeConfig } = fieldTypeConfigSearch;
const fieldOptions = this._getFieldOptions(field);
const fieldOptions = this._getFieldOptions(fieldSchema);
const property = this.getFieldProperty(key);
const payloadValue = property && formPayload[property];
const formlyFieldConfig = {
...fieldTypeConfig,
key,
property: this.getFieldProperty(key),
className: `op-form--field ${fieldTypeConfig.className}`,
wrappers: [`op-dynamic-field-wrapper`],
templateOptions: {
property,
required,
label,
hasDefault,
...payloadValue != null && {payloadValue},
...minLength && {minLength},
...maxLength && {maxLength},
...templateOptions,
...fieldOptions && {options: fieldOptions},
},
@ -272,7 +276,7 @@ export class DynamicFieldsService {
private _getFormlyFormWithFieldGroups(fieldGroups:IOPAttributeGroup[] = [], formFields:IOPFormlyFieldSettings[] = []):IOPFormlyFieldSettings[] {
const fieldGroupKeys = fieldGroups.reduce((groupKeys, fieldGroup) => [...groupKeys, ...fieldGroup.attributes], []);
const fomFieldsWithoutGroup = formFields.filter(formField => {
const formFieldKey = formField.key && this.getFieldProperty(formField.key);
const formFieldKey = formField.key && this.getFieldProperty(formField.key);
return formFieldKey ?
!fieldGroupKeys.includes(formFieldKey) :
@ -281,9 +285,12 @@ export class DynamicFieldsService {
const formFieldGroups = fieldGroups.reduce((formWithFieldGroups: IOPFormlyFieldSettings[], fieldGroup) => {
const newFormFieldGroup = {
wrappers: ['op-dynamic-field-group-wrapper'],
fieldGroupClassName: 'op-form--field-group',
fieldGroupClassName: 'op-form-group',
templateOptions: {
label: fieldGroup.name,
isFieldGroup: true,
collapsibleFieldGroups: false,
collapsibleFieldGroupsCollapsed: true,
},
fieldGroup: formFields.filter(formField => {
const formFieldKey = formField.key && this.getFieldProperty(formField.key);
@ -292,6 +299,25 @@ export class DynamicFieldsService {
fieldGroup.attributes.includes(formFieldKey) :
false;
}),
expressionProperties: {
'templateOptions.collapsibleFieldGroupsCollapsed': (model:any, formState:any, field:FormlyFieldConfig) => {
// Uncollapse field groups when the form has errors and is 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
));
}
},
}
}
if (newFormFieldGroup.fieldGroup.length) {

@ -25,11 +25,15 @@ export class DynamicFormService {
this.dynamicForm = dynamicForm;
}
getSettingsFromBackend$(url:string):Observable<IOPDynamicFormSettings>{
getSettingsFromBackend$(formEndpoint?:string, resourceId?:string, payload:Object = {}):Observable<IOPDynamicFormSettings>{
const resourcePath = resourceId ? `/${resourceId}` : '';
const formPath = formEndpoint?.endsWith('/form') ? '' : '/form';
const url = `${formEndpoint}${resourcePath}${formPath}`;
return this._httpClient
.post<IOPFormSettings>(
.post<IOPFormSettingsResource>(
url,
{},
payload,
{
withCredentials: true,
responseType: 'json'
@ -40,19 +44,19 @@ export class DynamicFormService {
);
}
getSettings(formConfig:IOPFormSettings):IOPDynamicFormSettings {
getSettings(formConfig:IOPFormSettingsResource):IOPDynamicFormSettings {
const formSchema = formConfig._embedded?.schema;
const formPayload = formConfig._embedded?.payload;
const dynamicForm = {
fields: this._dynamicFieldsService.getConfig(formSchema, formPayload),
model: this._dynamicFieldsService.getModel(formSchema, formPayload),
model: this._dynamicFieldsService.getModel(formPayload),
form: new FormGroup({}),
};
return dynamicForm;
}
submit$(form:FormGroup, resourceEndpoint:string, resourceId?:string) {
return this._formsService.submit$(form, resourceEndpoint, resourceId);
submit$(form:FormGroup, resourceEndpoint:string, resourceId?:string, formHttpMethod?: 'post' | 'patch') {
return this._formsService.submit$(form, resourceEndpoint, resourceId, formHttpMethod);
}
}

@ -1,4 +1,4 @@
import { FormlyFieldConfig } from "@ngx-formly/core";
import { FormlyFieldConfig, FormlyTemplateOptions } from "@ngx-formly/core";
import { FormGroup } from "@angular/forms";
export interface IOPDynamicFormSettings {
@ -10,11 +10,20 @@ export interface IOPDynamicFormSettings {
export interface IOPFormlyFieldSettings extends FormlyFieldConfig {
key?:string;
type?:OPInputType;
property?:string;
templateOptions?:IOPFormlyTemplateOptions;
}
export interface IOPFormlyTemplateOptions extends FormlyTemplateOptions {
property?: string;
label?: string;
hasDefault?: boolean;
isFieldGroup?:boolean;
collapsibleFieldGroups?:boolean;
collapsibleFieldGroupsCollapsed?:boolean;
}
type OPInputType = 'formattableInput'|'selectInput'|'textInput'|'integerInput'|
'booleanInput'|'dateInput';
'booleanInput'| 'dateInput' | 'formly-group';
export interface IOPDynamicInputTypeSettings {
config:IOPFormlyFieldSettings,

@ -30,10 +30,9 @@ export class OpFormFieldComponent implements OnInit{
@Input() showValidationErrorOn: 'change' | 'blur' | 'submit' | 'never';
@ContentChild(NgControl) ngControl:NgControl;
@ContentChild(FormlyField) dynamicControl:FormlyField;
get formControl ():AbstractControl|undefined|null {
return this.ngControl?.control || this.dynamicControl?.field?.formControl;
return this.ngControl?.control || this._dynamicControl?.field?.formControl;
}
get showErrorMessage():boolean {
@ -55,10 +54,11 @@ export class OpFormFieldComponent implements OnInit{
}
get hidden () {
return this.dynamicControl?.field?.hide;
return this._dynamicControl?.field?.hide;
}
constructor(
@Optional() private _dynamicControl: FormlyField,
@Optional() private _formGroupDirective:FormGroupDirective,
@Optional() private _dynamicFormComponent:DynamicFormComponent,
) {}

@ -2,7 +2,6 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormattableEditFieldComponent } from "core-app/modules/fields/edit/field-types/formattable-edit-field/formattable-edit-field.component";
import { OpenprojectEditorModule } from "core-app/modules/editor/openproject-editor.module";
import { EditFieldControlsComponent } from "core-app/modules/fields/edit/field-controls/edit-field-controls.component";
import { EditFieldControlsModule } from "core-app/modules/fields/edit/field-controls/edit-field-controls.module";

@ -0,0 +1,27 @@
<form
class="op-form"
[formGroup]="templateForm"
>
<op-form-field
[label]="text.use_template"
data-qa-field-name="use_template"
>
<ng-select
[formControl]="templateControl"
[items]="templateOptions$ | async"
(ngModelChange)="onTemplateSelected($event)"
bindLabel="name"
slot="input"
>
</ng-select>
</op-form-field>
</form>
<op-dynamic-form
[formUrl]="formUrl"
[resourcePath]="resourcePath"
[initialPayload]="initialPayload"
[fieldsSettingsPipe]="dynamicFieldsSettingsPipe"
(submitted)="onSubmitted($event)"
>
</op-dynamic-form>

@ -0,0 +1,181 @@
import {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 {HalResource, HalSource} from "core-app/modules/hal/resources/hal-resource";
import {IOPFormlyFieldSettings} from "core-app/modules/common/dynamic-forms/typings";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {FormControl, FormGroup} from "@angular/forms";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {ApiV3FilterBuilder} from "core-components/api/api-v3/api-v3-filter-builder";
import {map} from "rxjs/operators";
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";
export interface ProjectTemplateOption {
href:string|null;
title:string;
}
@Component({
selector: 'op-new-project',
templateUrl: './new-project.component.html',
})
export class NewProjectComponent extends UntilDestroyedMixin implements OnInit {
resourcePath:string;
dynamicFieldsSettingsPipe = this.fieldSettingsPipe.bind(this);
initialPayload = {};
formUrl:string;
text = {
use_template: this.I18n.t('js.project.use_template'),
};
hiddenFields:string[] = [
'identifier',
'sendNotifications',
'active'
];
copyableTemplateFilter = new ApiV3FilterBuilder()
.add('user_action', '=', ["projects/copy"]) // no null values
.add('templated', '=', true);
templateOptions$:Observable<ProjectTemplateOption[]> =
this
.apiV3Service
.projects
.filtered(this.copyableTemplateFilter)
.get()
.pipe(
map(response =>
response.elements.map((el:HalResource) => ({ href: el.href, name: el.name }))),
);
templateForm = new FormGroup({
template: new FormControl(),
});
get templateControl() {
return this.templateForm.get('template');
}
constructor(
private apiV3Service:APIV3Service,
private uIRouterGlobals:UIRouterGlobals,
private pathHelperService:PathHelperService,
private modalService:OpModalService,
private $state:StateService,
private I18n:I18nService,
) {
super();
}
ngOnInit():void {
this.resourcePath = this.pathHelperService.projectsPath();
if (this.uIRouterGlobals.params.parent_id) {
this.setParentAsPayload(this.uIRouterGlobals.params.parent_id);
}
}
onSubmitted(response:HalSource) {
if (response._type === 'JobStatus') {
this.modalService.show(JobStatusModal, 'global', { jobId: response.jobId });
} else {
window.location.href = this.pathHelperService.projectPath(response.identifier as string);
}
}
onTemplateSelected(selected:{ href:string|null }) {
if (selected.href) {
this.formUrl = selected.href + '/copy';
} else {
this.resourcePath = this.pathHelperService.projectsPath();
}
}
private isHiddenField(key:string|undefined):boolean {
return !!key && (this.hiddenFields.includes(key) || this.isMeta(key));
}
private isMeta(key:string):boolean {
return key.startsWith('copy');
}
private setParentAsPayload(parentId:string) {
const href = this.apiV3Service.projects.id(parentId).path;
this.initialPayload = {
_links: {
parent: {
href: href
}
}
};
}
private fieldSettingsPipe(dynamicFieldsSettings:IOPFormlyFieldSettings[]):IOPFormlyFieldSettings[] {
const fieldsLayoutConfig = dynamicFieldsSettings
.reduce((result, field) => {
field = {
...field,
hide: this.isHiddenField(field.key),
}
if (
(field.templateOptions?.required &&
!field.templateOptions.hasDefault &&
field.templateOptions.payloadValue == null) ||
field.templateOptions?.property === 'name' ||
field.templateOptions?.property === 'parent'
) {
result.firstLevelFields = [...result.firstLevelFields, field];
} else {
result.advancedSettingsFields = [...result.advancedSettingsFields, field];
}
return result;
}, {
firstLevelFields: [],
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];
}
}

@ -1,5 +1,4 @@
<op-dynamic-form [resourcePath]="projectsPath"
[resourceId]="resourceId"
[fieldsSettingsPipe]="dynamicFieldsSettingsPipe"
(submitted)="onSubmitted($event)">
<op-dynamic-form [resourceId]="resourceId"
[resourcePath]="projectsPath"
[fieldsSettingsPipe]="dynamicFieldsSettingsPipe">
</op-dynamic-form>

@ -26,7 +26,6 @@
// See docs/COPYRIGHT.rdoc for more details.
//++
import { OpenprojectCommonModule } from 'core-app/modules/common/openproject-common.module';
import { OpenprojectFieldsModule } from 'core-app/modules/fields/openproject-fields.module';
import { NgModule } from '@angular/core';
import { OpenprojectHalModule } from "core-app/modules/hal/openproject-hal.module";
@ -34,12 +33,16 @@ import { UIRouterModule } from "@uirouter/angular";
import { PROJECTS_ROUTES, uiRouterProjectsConfiguration } from "core-app/modules/projects/projects-routes";
import { ProjectsComponent } from './components/projects/projects.component';
import { DynamicFormsModule } from "core-app/modules/common/dynamic-forms/dynamic-forms.module";
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";
@NgModule({
imports: [
// Commons
OpenprojectCommonModule,
ReactiveFormsModule,
OpenprojectHalModule,
OpenprojectFieldsModule,
@ -49,7 +52,10 @@ import { DynamicFormsModule } from "core-app/modules/common/dynamic-forms/dynami
}),
DynamicFormsModule,
],
declarations: [ProjectsComponent]
declarations: [
ProjectsComponent,
NewProjectComponent,
]
})
export class OpenprojectProjectsModule {
}

@ -1,5 +1,6 @@
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";
export const PROJECTS_ROUTES:Ng2StateDeclaration[] = [
{
@ -8,6 +9,11 @@ export const PROJECTS_ROUTES:Ng2StateDeclaration[] = [
parent: 'root',
component: ProjectsComponent,
},
{
name: 'new_project',
url: '/projects/new?parent_id',
component: NewProjectComponent,
},
];
export function uiRouterProjectsConfiguration(uiRouter:UIRouter) {

@ -36,6 +36,11 @@ import { appBaseSelector, ApplicationBaseComponent } from "core-app/modules/rout
import { BackRoutingService } from "core-app/modules/common/back-routing/back-routing.service";
export const OPENPROJECT_ROUTES:Ng2StateDeclaration[] = [
{
name: 'new_project.**',
url: '/projects/new',
loadChildren: () => import('../projects/openproject-projects.module').then(m => m.OpenprojectProjectsModule)
},
{
name: 'root',
url: '/{projects}/{projectPath}',

@ -1,74 +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'
require_relative './shared_contract_examples'
require 'contracts/shared/model_contract_shared_context'
describe Projects::InstantiateTemplateContract do
include_context 'ModelContract shared context'
let(:user) { FactoryBot.build_stubbed :user }
let(:project) { Project.new name: 'Foo Bar', identifier: 'foo' }
let(:template) { FactoryBot.build_stubbed :project }
let(:options) { { template_project_id: template.id } }
let(:contract) { described_class.new(project, user, options: options) }
before do
allow(user)
.to(receive(:allowed_to_globally?))
.with(:add_project)
.and_return(allowed_to_add)
allow(Project)
.to receive_message_chain(:allowed_to, :where, :exists?)
.and_return(allowed_to_copy)
end
context 'when user may copy template' do
let(:allowed_to_copy) { true }
let(:allowed_to_add) { true }
it_behaves_like 'contract is valid'
context 'but may not add projects' do
let(:allowed_to_copy) { true }
let(:allowed_to_add) { false }
it_behaves_like 'contract is invalid', base: :error_unauthorized
end
end
context 'when user may not copy template' do
let(:allowed_to_copy) { false }
let(:allowed_to_add) { true }
it_behaves_like 'contract is invalid', base: :error_unauthorized
end
end

@ -47,17 +47,6 @@ describe ProjectsController, type: :controller do
expect(response).to render_template 'new'
end
context 'with parent project' do
let!(:parent) { FactoryBot.create :project, name: 'Parent' }
it 'sets the parent of the project' do
get 'new', params: { parent_id: parent.id }
expect(response).to be_successful
expect(response).to render_template 'new'
expect(assigns(:project).parent).to eq parent
end
end
context 'by non-admin user with add_project permission' do
let(:non_member_user) { FactoryBot.create :user }
@ -72,304 +61,6 @@ describe ProjectsController, type: :controller do
expect(response).to render_template 'new'
end
end
context 'by non-admin user with add_subprojects permission' do
render_views
let(:parent) { FactoryBot.create :project }
let(:add_subproject_role) do
FactoryBot.create(:role, permissions: %i[add_subprojects view_project view_work_packages])
end
let(:member) do
FactoryBot.create :user,
member_in_project: parent,
member_through_role: add_subproject_role
end
before do
login_as member
end
it 'should accept get' do
get :new, params: { parent_id: parent.id }
expect(response).to be_successful
expect(response).to render_template 'new'
expect(response.body).to have_selector("option[selected]", text: parent.name, visible: :all)
end
end
context 'with template project' do
let!(:template) { FactoryBot.create :template_project }
render_views
it 'allows to select that template' do
get :new
expect(response).to be_successful
expect(response).to render_template :new
expect(response.body).to have_selector('option', text: template.name, visible: :all)
end
end
end
context 'with default modules',
with_settings: { default_projects_modules: %w(work_package_tracking repository) } do
it 'should create should preserve modules on validation failure' do
expect do
post :create,
params: {
project: {
name: 'blog',
identifier: '',
enabled_module_names: %w(work_package_tracking news)
}
}
end.not_to(change { Project.count })
expect(response).to be_successful
project = assigns(:project)
expect(project.enabled_module_names.sort).to eq %w(news work_package_tracking)
end
end
describe '#create' do
shared_let(:project_custom_field) { FactoryBot.create :list_project_custom_field }
let(:selected_custom_field_value) { project_custom_field.possible_values.find_by(value: 'A') }
shared_let(:wp_custom_field) { FactoryBot.create :string_wp_custom_field }
shared_let(:types) { FactoryBot.create_list :type, 2 }
shared_let(:parent) { FactoryBot.create :project }
context 'by admin user' do
it 'should create a new project' do
post :create,
params: {
project: {
name: 'blog',
description: 'weblog',
identifier: 'blog',
public: 1,
custom_field_values: { project_custom_field.id => selected_custom_field_value },
type_ids: types.map(&:id),
# an issue custom field that is not for all project
work_package_custom_field_ids: [wp_custom_field.id],
enabled_module_names: ['work_package_tracking', 'news', 'repository']
}
}
expect(response).to redirect_to '/projects/blog/work_packages'
project = Project.find_by(name: 'blog')
expect(project).to be_active
expect(project).to be_public
expect(project.description).to eq 'weblog'
expect(project.parent).to eq nil
expect(project.custom_value_for(project_custom_field.id).typed_value).to eq 'A'
expect(project.types.map(&:id).sort).to eq types.map(&:id).sort
expect(project.enabled_module_names.sort).to eq ['news', 'repository', 'work_package_tracking']
expect(project.work_package_custom_fields).to contain_exactly(wp_custom_field)
end
it 'should create a new subproject' do
post :create,
params: {
project: {
name: 'blog',
description: 'weblog',
identifier: 'blog',
public: 1,
parent_id: parent.id
}
}
expect(response).to redirect_to '/projects/blog/work_packages'
project = Project.find_by(name: 'blog')
expect(project.parent).to eq parent
end
end
context 'by non-admin user with add_project permission' do
let(:non_member_user) { FactoryBot.create :user }
# We need at least one givable role to make the user member
let!(:role) { FactoryBot.create :role, permissions: [:view_project] }
before do
non_member.update_attribute :permissions, %i[add_project view_work_packages]
login_as non_member_user
end
it 'should accept create a Project' do
post :create,
params: {
project: {
name: 'blog',
description: 'weblog',
identifier: 'blog',
public: 1,
custom_field_values: { project_custom_field.id => selected_custom_field_value },
type_ids: types.map(&:id),
enabled_module_names: ['work_package_tracking', 'news', 'repository']
}
}
expect(response).to redirect_to '/projects/blog'
project = Project.find_by(name: 'blog')
expect(project).to be_active
expect(project).to be_public
expect(project.description).to eq 'weblog'
expect(project.parent).to eq nil
expect(project.custom_value_for(project_custom_field.id).typed_value).to eq 'A'
expect(project.types.map(&:id).sort).to eq types.map(&:id).sort
expect(project.enabled_module_names.sort).to eq ['news', 'repository', 'work_package_tracking']
# User should be added as a project member
expect(non_member_user).to be_member_of(project)
expect(project.members.size).to eq 1
end
it 'should fail with parent_id' do
expect do
post :create,
params: {
project: {
name: 'blog',
description: 'weblog',
identifier: 'blog',
public: 1,
custom_field_values: { project_custom_field.id => selected_custom_field_value },
parent_id: parent.id
}
}
end.not_to change { Project.count }
project = assigns(:project)
errors = assigns(:errors)
expect(response).to be_successful
expect(project).to be_kind_of Project
expect(errors[:parent]).to be_present
end
end
context 'by non-admin user with add_subprojects permission' do
let(:add_subproject_role) do
FactoryBot.create(:role, permissions: %i[add_subprojects view_project view_work_packages])
end
let(:member) do
FactoryBot.create :user,
member_in_project: parent,
member_through_role: add_subproject_role
end
before do
login_as member
end
it 'should create a project with a parent_id' do
post :create,
params: {
project: {
name: 'blog',
description: 'weblog',
identifier: 'blog',
public: 1,
parent_id: parent.id
}
}
assert_redirected_to '/projects/blog/work_packages'
project = Project.find_by(name: 'blog')
expect(project.parent).to eq parent
end
it 'should fail without parent_id' do
expect do
post :create,
params: {
project: {
name: 'blog',
description: 'weblog',
identifier: 'blog',
public: 1
}
}
end.not_to(change { Project.count })
project = assigns(:project)
errors = assigns(:errors)
expect(response).to be_successful
expect(project).to be_kind_of Project
expect(errors.symbols_for(:base))
.to match_array [:error_unauthorized]
end
context 'with another parent' do
let(:parent2) { FactoryBot.create :project }
it 'should fail with unauthorized parent_id' do
expect(member).not_to be_member_of parent2
expect do
post :create,
params: {
project: {
name: 'blog',
description: 'weblog',
identifier: 'blog',
public: 1,
custom_field_values: { '3' => '5' },
parent_id: 6
}
}
end.not_to change { Project.count }
project = assigns(:project)
errors = assigns(:errors)
expect(response).to be_successful
expect(project).to be_kind_of Project
expect(errors.symbols_for(:base))
.to match_array [:error_unauthorized]
end
end
end
describe 'with template project' do
let!(:template) { FactoryBot.create :template_project, identifier: 'template' }
let(:service_result) { double('Job', job_id: 'uuid of the job') }
let(:service_double) { double('Projects::InstantiateTemplateService') }
let(:project_params) do
{
name: 'blog',
description: 'weblog',
identifier: 'blog',
public: '1',
custom_field_values: { project_custom_field.id.to_s => selected_custom_field_value.id.to_s },
type_ids: types.map { |type| type.id.to_s },
# an issue custom field that is not for all project
work_package_custom_field_ids: [wp_custom_field.id.to_s],
enabled_module_names: %w[work_package_tracking news repository]
}
end
it 'calls the instantiation service' do
expect(Projects::InstantiateTemplateService)
.to receive(:new)
.with(user: admin, template_id: template.id.to_s)
.and_return service_double
expect(service_double)
.to receive(:call) do |params|
expect(params.to_h).to eq(project_params.stringify_keys)
ServiceResult.new success: true, result: service_result
end
post :create,
params: {
from_template: template.id,
project: project_params
}
expect(response).to redirect_to job_status_path('uuid of the job')
end
end
end
describe 'index.html' do

@ -65,6 +65,7 @@ describe 'Global role: Global Create project', type: :feature, js: true do
describe 'Create Project displayed to user' do
let!(:global_role) { FactoryBot.create(:global_role, name: 'Global', permissions: %i[add_project]) }
let!(:member_role) { FactoryBot.create(:role, name: 'Member', permissions: %i[view_project]) }
let(:user) { FactoryBot.create :user }
let!(:global_member) do
@ -73,6 +74,8 @@ describe 'Global role: Global Create project', type: :feature, js: true do
roles: [global_role])
end
let(:name_field) { ::FormFields::InputFormField.new :name }
it 'does show the global permission' do
visit projects_path
expect(page).to have_selector('.button.-alt-highlight', text: 'Project')
@ -80,11 +83,11 @@ describe 'Global role: Global Create project', type: :feature, js: true do
# Can add new project
visit new_project_path
fill_in 'project_name', with: 'New project name'
click_on 'Create'
name_field.set_value 'New project name'
page.find('button:not([disabled])', text: 'Save').click
expect(page).to have_text 'Successful creation.'
expect(current_path).to match /projects\/new-project-name/
expect(page).to have_current_path '/projects/new-project-name/'
end
end

@ -55,6 +55,8 @@ feature 'Quick-add menu', js: true, selenium: true do
member_with_permissions: %i[add_project view_project add_subprojects]
end
let(:field) { ::FormFields::SelectFormField.new :parent }
it 'moves to a form with parent_id set' do
visit project_path(project)
@ -65,7 +67,7 @@ feature 'Quick-add menu', js: true, selenium: true do
quick_add.click_link 'Project'
expect(page).to have_current_path new_project_path(parent_id: project.id)
expect(page).to have_select('project_parent_id', selected: project.name, visible: true)
field.expect_selected project.name
end
end
end

@ -81,6 +81,8 @@ describe 'Project attribute help texts', type: :feature, js: true do
it_behaves_like 'allows to view help texts'
it 'shows the help text on the project create form' do
skip 'Attribute help texts are not working yet on dynamic forms'
visit new_project_path
page.find('.form--fieldset-legend', text: 'ADVANCED SETTINGS').click

@ -47,8 +47,10 @@ describe 'Projects status administration', type: :feature, js: true do
.and_return(r.id.to_s)
end
end
let(:edit_status_description) { Components::WysiwygEditor.new('[data-field-name="statusExplanation"]') }
let(:create_status_description) { Components::WysiwygEditor.new('.form--field:nth-of-type(4)') }
let(:status_description) { Components::WysiwygEditor.new('[data-qa-field-name="statusExplanation"]') }
let(:name_field) { ::FormFields::InputFormField.new :name }
let(:status_field) { ::FormFields::SelectFormField.new :status }
before do
login_as current_user
@ -60,32 +62,28 @@ describe 'Projects status administration', type: :feature, js: true do
# Create the project with status
click_link 'Advanced settings'
fill_in 'Name', with: 'New project'
select 'On track', from: 'Status'
name_field.set_value 'New project'
status_field.select_option 'On track'
create_status_description.set_markdown 'Everything is fine at the start'
create_status_description.expect_supports_no_macros
status_description.set_markdown 'Everything is fine at the start'
status_description.expect_supports_no_macros
click_button 'Create'
click_button 'Save'
expect(page)
.to have_content('Successful creation.')
expect(page).to have_current_path /projects\/new-project\/?/
# Check that the status has been set correctly
visit settings_generic_project_path(Project.last)
visit settings_generic_project_path(id: 'new-project')
expect(page).to have_selector('[data-field-name="status"] .ng-value', text: 'On track')
status_field.expect_selected 'On track'
status_description.expect_value 'Everything is fine at the start'
edit_status_description.expect_value 'Everything is fine at the start'
select_autocomplete page.find('[data-field-name="status"]'), query: 'Off track'
edit_status_description.set_markdown 'Oh no'
status_field.select_option 'Off track'
status_description.set_markdown 'Oh no'
click_button 'Save'
expect(page).to have_selector('[data-field-name="status"] .ng-value', text: 'Off track')
edit_status_description.expect_value 'Oh no'
status_field.expect_selected 'Off track'
status_description.expect_value 'Oh no'
end
end

@ -31,7 +31,8 @@ require 'spec_helper'
describe 'Projects custom fields', type: :feature, js: true do
shared_let(:current_user) { FactoryBot.create(:admin) }
shared_let(:project) { FactoryBot.create(:project, name: 'Foo project', identifier: 'foo-project') }
let(:identifier) { "[data-field-name='customField#{custom_field.id}'] input[type=checkbox]" }
let(:name_field) { ::FormFields::InputFormField.new :name }
let(:identifier) { "[data-qa-field-name='customField#{custom_field.id}'] input[type=checkbox]" }
before do
login_as current_user
@ -41,17 +42,20 @@ describe 'Projects custom fields', type: :feature, js: true do
let!(:custom_field) do
FactoryBot.create(:version_project_custom_field)
end
let(:identifier) { "project_custom_field_values_#{custom_field.id}" }
let(:cf_field) { ::FormFields::SelectFormField.new custom_field }
scenario 'allows creating a new project (regression #29099)' do
visit new_project_path
fill_in 'project_name', with: 'My project name'
name_field.set_value 'My project name'
find('.form--fieldset-legend a', text: 'ADVANCED SETTINGS').click
expect(page).to have_selector "##{identifier}"
click_on 'Create'
expect(page).to have_text I18n.t(:notice_successful_create)
cf_field.expect_visible
click_button 'Save'
expect(page).to have_current_path /\/projects\/my-project-name\/?/
end
end
@ -66,36 +70,33 @@ describe 'Projects custom fields', type: :feature, js: true do
FactoryBot.create(:string_project_custom_field)
end
let(:name_field) { ::FormFields::InputFormField.new :name }
let(:default_int_field) { ::FormFields::InputFormField.new default_int_custom_field }
let(:default_string_field) { ::FormFields::InputFormField.new default_string_custom_field }
let(:no_default_string_field) { ::FormFields::InputFormField.new no_default_string_custom_field }
scenario 'sets the default values on custom fields and allows overwriting them' do
visit new_project_path
fill_in 'project_name', with: 'My project name'
name_field.set_value 'My project name'
find('.form--fieldset-legend a', text: 'ADVANCED SETTINGS').click
expect(page)
.to have_field default_int_custom_field.name, with: default_int_custom_field.default_value.to_s
expect(page)
.to have_field default_string_custom_field.name, with: default_string_custom_field.default_value.to_s
expect(page)
.to have_field no_default_string_custom_field.name, with: nil
default_int_field.expect_value default_int_custom_field.default_value.to_s
default_string_field.expect_value default_string_custom_field.default_value.to_s
no_default_string_field.expect_value ''
fill_in default_string_custom_field.name, with: 'Overwritten'
default_string_field.set_value 'Overwritten'
click_on 'Create'
expect(page).to have_selector('.flash.notice', text: I18n.t(:notice_successful_create))
click_button 'Save'
expect(page).to have_current_path /\/projects\/my-project-name\/?/
created_project = Project.last
visit settings_project_path(created_project)
int_field = page.find "[data-field-name='customField#{default_int_custom_field.id}'] input"
expect(int_field.value).to eq(default_int_custom_field.default_value.to_s)
string_field = page.find "[data-field-name='customField#{default_string_custom_field.id}'] input"
expect(string_field.value).to eq('Overwritten')
string_field = page.find "[data-field-name='customField#{no_default_string_custom_field.id}'] input"
expect(string_field.value).to eq('')
default_int_field.expect_value default_int_custom_field.default_value.to_s
default_string_field.expect_value 'Overwritten'
no_default_string_field.expect_value ''
end
end
@ -103,7 +104,7 @@ describe 'Projects custom fields', type: :feature, js: true do
let!(:custom_field) do
FactoryBot.create(:text_project_custom_field)
end
let(:editor) { ::Components::WysiwygEditor.new "[data-field-name='customField#{custom_field.id}']" }
let(:editor) { ::Components::WysiwygEditor.new "[data-qa-field-name='customField#{custom_field.id}']" }
scenario 'allows settings the project boolean CF (regression #26313)' do
visit settings_generic_project_path(project.id)

@ -30,6 +30,8 @@ require 'spec_helper'
describe 'Projects', type: :feature, js: true do
let(:current_user) { FactoryBot.create(:admin) }
let(:name_field) { ::FormFields::InputFormField.new :name }
let(:parent_field) { ::FormFields::SelectFormField.new :parent }
before do
allow(User).to receive(:current).and_return current_user
@ -45,26 +47,11 @@ describe 'Projects', type: :feature, js: true do
it 'can create a project' do
click_on 'New project'
fill_in 'Name', with: 'Foo bar'
click_on 'Create'
name_field.set_value 'Foo bar'
click_button 'Save'
expect(page).to have_content 'Successful creation.'
expect(page).to have_content 'Foo bar'
expect(current_path).to eq '/projects/foo-bar/work_packages'
end
context 'work_packages module disabled',
with_settings: { default_projects_modules: 'wiki' } do
it 'creates a project and redirects to settings' do
click_on 'New project'
fill_in 'Name', with: 'Foo bar'
click_on 'Create'
expect(page).to have_content 'Successful creation.'
expect(page).to have_content 'Foo bar'
expect(current_path).to eq '/projects/foo-bar/'
end
expect(page).to have_current_path /\/projects\/foo-bar\/?/
end
it 'can create a subproject' do
@ -74,47 +61,47 @@ describe 'Projects', type: :feature, js: true do
SeleniumHubWaiter.wait
click_on 'New subproject'
fill_in 'Name', with: 'Foo child'
name_field.set_value 'Foo child'
parent_field.expect_selected project.name
# This will also check that the "Advanced settings" are opened
expect(page)
.to have_field('Subproject of', with: project.id)
click_button 'Save'
click_on 'Create'
expect(page).to have_current_path /\/projects\/foo-child\/?/
expect(page).to have_content 'Successful creation.'
expect(current_path).to eq '/projects/foo-child/work_packages'
child = Project.last
expect(child.identifier).to eq 'foo-child'
expect(child.parent).to eq project
end
it 'does not create a project with an already existing identifier' do
skip "TODO identifier is not yet rendered on error in dynamic form"
click_on 'New project'
fill_in 'project[name]', with: 'Foo project'
click_on 'Create'
name_field.set_value 'Foo project'
click_on 'Save'
expect(page).to have_content 'Identifier has already been taken'
expect(current_path).to eq '/projects'
expect(page).to have_current_path /\/projects\/new\/?/
end
context 'with a multi-select custom field' do
let!(:list_custom_field) { FactoryBot.create(:list_project_custom_field, name: 'List CF', multi_value: true) }
let(:list_field) { ::FormFields::SelectFormField.new list_custom_field }
it 'can create a project' do
click_on 'New project'
fill_in 'project[name]', with: 'Foo bar'
click_on 'Advanced settings'
name_field.set_value 'Foo bar'
select 'A', from: 'List CF'
select 'B', from: 'List CF'
find('.form--fieldset-legend a', text: 'ADVANCED SETTINGS').click
sleep 1
list_field.select_option 'A', 'B'
click_on 'Create'
click_button 'Save'
expect(page).to have_content 'Successful creation.'
expect(page).to have_current_path /\/projects\/foo-bar\/?/
expect(page).to have_content 'Foo bar'
expect(current_path).to eq '/projects/foo-bar/work_packages'
project = Project.last
expect(project.name).to eq 'Foo bar'
@ -212,8 +199,10 @@ describe 'Projects', type: :feature, js: true do
it 'hides the active field and the identifier' do
visit new_project_path
expect(page).not_to have_field 'Active'
expect(page).not_to have_field 'Identifier'
find('.form--fieldset-legend a', text: 'ADVANCED SETTINGS').click
expect(page).to have_no_content 'Active'
expect(page).to have_no_content 'Identifier'
end
end
@ -223,8 +212,8 @@ describe 'Projects', type: :feature, js: true do
visit settings_generic_project_path(project.id)
expect(page).not_to have_field 'Active'
expect(page).not_to have_field 'Identifier'
expect(page).to have_no_text :all, 'Active'
expect(page).to have_no_text :all, 'Identifier'
end
end
@ -248,9 +237,9 @@ describe 'Projects', type: :feature, js: true do
click_on 'Advanced settings'
within('#advanced-project-settings') do
expect(page).to have_content 'Optional Foo'
expect(page).not_to have_content 'Required Foo'
within('.form--fieldset') do
expect(page).to have_text 'Optional Foo'
expect(page).to have_no_text 'Required Foo'
end
end
@ -260,8 +249,8 @@ describe 'Projects', type: :feature, js: true do
visit settings_generic_project_path(project.id)
expect(page).to have_content 'Required Foo'
expect(page).to have_content 'Optional Foo'
expect(page).to have_text 'Optional Foo'
expect(page).to have_text 'Required Foo'
end
end
@ -275,16 +264,22 @@ describe 'Projects', type: :feature, js: true do
max_length: 2,
is_for_all: true)
end
let(:foo_field) { ::FormFields::InputFormField.new required_custom_field }
it 'shows the errors of that field when saving (Regression #33766)' do
visit settings_generic_project_path(project.id)
expect(page).to have_content 'Foo'
# Enter something too long
fill_in 'Foo', with: '1234'
foo_field.set_value '1234'
# It should cut of that remaining value
foo_field.expect_value '12'
click_button 'Save'
click_on 'Save'
expect(page).to have_selector('.op-form-field--errors', text: 'Foo is too long (maximum is 2 characters)')
expect(page).to have_text 'Successful update.'
end
end
end
@ -294,29 +289,18 @@ describe 'Projects', type: :feature, js: true do
let(:project) { FactoryBot.create(:project, name: 'Foo project', identifier: 'foo-project') }
let!(:list_custom_field) { FactoryBot.create(:list_project_custom_field, name: 'List CF', multi_value: true) }
let(:form_field) { ::FormFields::SelectFormField.new list_custom_field }
it 'can create a project' do
visit settings_generic_project_path(project.id)
select = page.find("[data-field-name='customField#{list_custom_field.id}']")
select.find('.ng-select-container').click
select.find('.ng-option', text: 'A').click
sleep 1
select.find('.ng-select-container').click
select.find('.ng-option', text: 'B').click
sleep 1
form_field.select_option 'A', 'B'
click_on 'Save'
expect(page).to have_content 'Successful update.'
select = page.find("[data-field-name='customField#{list_custom_field.id}']")
expect(select).to have_selector('.ng-value', text: 'A')
expect(select).to have_selector('.ng-value', text: 'B')
form_field.expect_selected 'A', 'B'
cvs = project.reload.custom_value_for(list_custom_field)
expect(cvs.count).to eq 2

@ -72,6 +72,11 @@ describe 'Project templates', type: :feature, js: true do
let(:status_field_selector) { 'ckeditor-augmented-textarea[textarea-selector="#project_status_explanation"]' }
let(:status_description) { ::Components::WysiwygEditor.new status_field_selector }
let(:name_field) { ::FormFields::InputFormField.new :name }
let(:template_field) { ::FormFields::SelectFormField.new :use_template }
let(:status_field) { ::FormFields::SelectFormField.new :status }
let(:parent_field) { ::FormFields::SelectFormField.new :parent }
before do
login_as current_user
end
@ -79,43 +84,28 @@ describe 'Project templates', type: :feature, js: true do
it 'can instantiate the project with the copy permission' do
visit new_project_path
fill_in 'project[name]', with: 'Foo bar'
# Choosing template reloads the page and sets advanced settings
select 'My template', from: 'project-select-template'
# It reloads the page without any warning dialog and keeps the name
expect(page).to have_field 'Name', with: 'Foo bar'
expect(page).to have_select 'project-select-template', selected: 'My template'
name_field.set_value 'Foo bar'
# Updates the identifier in advanced settings
page.find('#advanced-project-settings').click
expect(page).to have_select 'project_status_code', selected: 'On track'
# Changing the template now causes a dialog
select '(none)', from: 'project-select-template'
page.driver.browser.switch_to.alert.accept
template_field.select_option 'My template'
# Choosing template reloads the page and sets advanced settings
select 'My template', from: 'project-select-template'
sleep 1
# It reloads the page without any warning dialog and keeps the name
expect(page).to have_field 'Name', with: 'Foo bar'
expect(page).to have_select 'project-select-template', selected: 'My template'
# It keeps the name
name_field.expect_value 'Foo bar'
template_field.expect_selected 'My template'
# Expend advanced settings
page.find('#advanced-project-settings').click
expect(page).to have_select 'project_status_code', selected: 'On track'
# Updates the identifier in advanced settings
page.find('.form--fieldset-legend', text: 'ADVANCED SETTINGS').click
status_field.expect_selected 'On track'
# Update status to off track
select 'Off track', from: 'project_status_code'
select other_project.name, from: 'project_parent_id'
status_field.select_option 'Off track'
parent_field.select_option other_project.name
click_on 'Create'
page.find('button:not([disabled])', text: 'Save').click
expect(page).to have_content I18n.t('project.template.copying')
expect(page).to have_content I18n.t(:label_copy_project)
expect(page).to have_content I18n.t('js.job_status.generic_messages.in_queue')
expect(page).to have_current_path /\/job_statuses\/[\w-]+/
# Email notification should be sent
perform_enqueued_jobs

@ -57,34 +57,6 @@ describe ProjectsController, type: :routing do
end
end
describe 'create' do
it do
expect(post('/projects')).to route_to(
controller: 'projects', action: 'create'
)
end
it do
expect(post('/projects.xml')).to route_to(
controller: 'projects', action: 'create', format: 'xml'
)
end
end
describe 'update' do
it do
expect(put('/projects/123')).to route_to(
controller: 'projects', action: 'update', id: '123'
)
end
it do
expect(put('/projects/123.xml')).to route_to(
controller: 'projects', action: 'update', id: '123', format: 'xml'
)
end
end
describe 'destroy_info' do
it do
expect(get('/projects/123/destroy_info')).to route_to(

@ -0,0 +1,29 @@
module FormFields
class FormField
include Capybara::DSL
include RSpec::Matchers
attr_reader :property, :selector
def initialize(property, selector: nil)
@property = property
@selector = selector || "[data-qa-field-name='#{property_name}']"
end
def expect_visible
raise NotImplementedError
end
def field_container
page.find(selector)
end
def property_name
if property.is_a? CustomField
"customField#{property.id}"
else
property.to_s
end
end
end
end

@ -0,0 +1,30 @@
require_relative './form_field'
module FormFields
class InputFormField < FormField
def expect_value(value)
expect(field_container).to have_selector('input') { |el| el.value == value }
end
def expect_visible
expect(field_container).to have_selector('input')
end
##
# Set or select the given value.
# For fields of type select, will check for an option with that value.
def set_value(content)
scroll_to_element(input_element)
# A normal fill_in would cause the focus loss on the input for empty strings.
# Thus the form would be submitted.
# https://github.com/erikras/redux-form/issues/686
input_element.fill_in with: content, fill_options: { clear: :backspace }
end
def input_element
field_container.find 'input'
end
end
end

@ -0,0 +1,23 @@
require_relative './form_field'
module FormFields
class SelectFormField < FormField
def expect_selected(*values)
values.each do |val|
expect(field_container).to have_selector('.ng-value', text: val)
end
end
def expect_visible
expect(field_container).to have_selector('ng-select')
end
def select_option(*values)
values.each do |val|
field_container.find('.ng-select-container').click
page.find('.ng-option', text: val, visible: :all).click
sleep 1
end
end
end
end
Loading…
Cancel
Save