Merge remote-tracking branch 'origin/release/10.4' into dev

pull/8142/head
Oliver Günther 5 years ago
commit 37e3c12683
No known key found for this signature in database
GPG Key ID: A3A8BDAD7C0C552C
  1. 8
      app/assets/javascripts/custom-fields.js
  2. 3
      app/helpers/browser_helper.rb
  3. 138
      app/models/project.rb
  4. 1
      app/models/type/attribute_groups.rb
  5. 1
      app/services/base_type_service.rb
  6. 2
      app/views/copy_projects/copy_settings/_copy_associations.html.erb
  7. 22
      app/views/custom_fields/_form.html.erb
  8. 1
      app/views/types/form/_form_configuration.html.erb
  9. 8
      docs/release-notes/9-0-4/README.md
  10. 59
      frontend/src/app/components/user/user-link/user-link.component.spec.ts
  11. 32
      frontend/src/app/components/user/user-link/user-link.component.ts
  12. 8
      frontend/src/app/components/wp-activity/user/user-activity.component.html
  13. 8
      frontend/src/app/components/wp-activity/user/user-activity.component.ts
  14. 15
      frontend/src/app/modules/admin/types/type-form-configuration.component.ts
  15. 2
      frontend/src/app/modules/hal/resources/user-resource.ts
  16. 20
      frontend/src/app/modules/work_packages/routing/wp-full-view/wp-full-view.component.ts
  17. 2
      lib/api/v3/users/user_representer.rb
  18. 66
      spec/features/custom_fields/create_long_text_spec.rb
  19. 19
      spec/lib/api/v3/users/user_representer_spec.rb
  20. 6
      spec/support/pages/custom_fields.rb

@ -44,6 +44,7 @@
possibleValues = $('#custom_field_possible_values_attributes'),
defaultValueFields = $('#custom_field_default_value_attributes'),
spanDefaultText = $('#default_value_text'),
spanDefaultLongText = $('#default_value_long_text'),
spanDefaultBool = $('#default_value_bool'),
textOrientationField = $('#custom_field_text_orientation');
@ -66,10 +67,10 @@
unsearchable = function() { searchable.attr('checked', false).hide(); };
// defaults (reset these fields before doing anything else)
$.each([spanDefaultBool, spanDefaultText, multiSelect, textOrientationField], function(idx, element) {
$.each([spanDefaultBool, spanDefaultLongText, spanDefaultText, multiSelect, textOrientationField], function(idx, element) {
deactivate(element);
});
show(defaultValueFields);
activate(defaultValueFields);
activate(spanDefaultText);
switch (format.val()) {
@ -109,6 +110,8 @@
unsearchable();
break;
case 'text':
activate(spanDefaultLongText);
deactivate(spanDefaultText);
show(lengthField, regexpField, searchable, textOrientationField);
deactivate(possibleValues);
activate(textOrientationField);
@ -122,6 +125,7 @@
// assign the switch format function to the select field
format.on('change', toggleFormat).trigger('change');
toggleFormat();
});
$(function() {

@ -20,6 +20,9 @@ module BrowserHelper
# Older version of safari
return true if browser.safari? && version < 12
# Older version of EDGE
return true if browser.edge? && version < 18
false
end
end

@ -407,80 +407,94 @@ class Project < ActiveRecord::Base
parents | descendants # Set union
end
# Returns an auto-generated project identifier based on the last identifier used
def self.next_identifier
p = Project.newest.first
p.nil? ? nil : p.identifier.to_s.succ
end
class << self
# Returns an auto-generated project identifier based on the last identifier used
def next_identifier
p = Project.newest.first
p.nil? ? nil : p.identifier.to_s.succ
end
# builds up a project hierarchy helper structure for use with #project_tree_from_hierarchy
#
# it expects a simple list of projects with a #lft column (awesome_nested_set)
# and returns a hierarchy based on #lft
#
# the result is a nested list of root level projects that contain their child projects
# but, each entry is actually a ruby hash wrapping the project and child projects
# the keys are :project and :children where :children is in the same format again
#
# result = [ root_level_project_info_1, root_level_project_info_2, ... ]
#
# where each entry has the form
#
# project_info = { project: the_project, children: [ child_info_1, child_info_2, ... ] }
#
# if a project has no children the :children array is just empty
#
def self.build_projects_hierarchy(projects)
ancestors = []
result = []
projects.sort_by(&:lft).each do |project|
while ancestors.any? && !project.is_descendant_of?(ancestors.last[:project])
# before we pop back one level, we sort the child projects by name
ancestors.last[:children] = ancestors.last[:children].sort_by { |h| h[:project].name.downcase if h[:project].name }
ancestors.pop
end
# builds up a project hierarchy helper structure for use with #project_tree_from_hierarchy
#
# it expects a simple list of projects with a #lft column (awesome_nested_set)
# and returns a hierarchy based on #lft
#
# the result is a nested list of root level projects that contain their child projects
# but, each entry is actually a ruby hash wrapping the project and child projects
# the keys are :project and :children where :children is in the same format again
#
# result = [ root_level_project_info_1, root_level_project_info_2, ... ]
#
# where each entry has the form
#
# project_info = { project: the_project, children: [ child_info_1, child_info_2, ... ] }
#
# if a project has no children the :children array is just empty
#
def build_projects_hierarchy(projects)
ancestors = []
result = []
projects.sort_by(&:lft).each do |project|
while ancestors.any? && !project.is_descendant_of?(ancestors.last[:project])
# before we pop back one level, we sort the child projects by name
ancestors.last[:children] = sort_by_name(ancestors.last[:children])
ancestors.pop
end
current_hierarchy = { project: project, children: [] }
current_tree = ancestors.any? ? ancestors.last[:children] : result
current_hierarchy = { project: project, children: [] }
current_tree = ancestors.any? ? ancestors.last[:children] : result
current_tree << current_hierarchy
ancestors << current_hierarchy
current_tree << current_hierarchy
ancestors << current_hierarchy
end
# When the last project is deeply nested, we need to sort
# all layers we are in.
ancestors.each do |level|
level[:children] = sort_by_name(level[:children])
end
# we need one extra element to ensure sorting at the end
# at the end the root level must be sorted as well
sort_by_name(result)
end
# at the end the root level must be sorted as well
result.sort_by { |h| h[:project].name&.downcase }
end
def project_tree_from_hierarchy(projects_hierarchy, level, &block)
projects_hierarchy.each do |hierarchy|
project = hierarchy[:project]
children = hierarchy[:children]
yield project, level
# recursively show children
project_tree_from_hierarchy(children, level + 1, &block) if children.any?
end
end
def self.project_tree_from_hierarchy(projects_hierarchy, level, &block)
projects_hierarchy.each do |hierarchy|
project = hierarchy[:project]
children = hierarchy[:children]
yield project, level
# recursively show children
project_tree_from_hierarchy(children, level + 1, &block) if children.any?
# Yields the given block for each project with its level in the tree
def project_tree(projects, &block)
projects_hierarchy = build_projects_hierarchy(projects)
project_tree_from_hierarchy(projects_hierarchy, 0, &block)
end
end
# Yields the given block for each project with its level in the tree
def self.project_tree(projects, &block)
projects_hierarchy = build_projects_hierarchy(projects)
project_tree_from_hierarchy(projects_hierarchy, 0, &block)
end
def project_level_list(projects)
list = []
project_tree(projects) do |project, level|
element = {
project: project,
level: level
}
element.merge!(yield(project)) if block_given?
def self.project_level_list(projects)
list = []
project_tree(projects) do |project, level|
element = {
project: project,
level: level
}
list << element
end
list
end
element.merge!(yield(project)) if block_given?
private
list << element
def sort_by_name(project_hashes)
project_hashes.sort_by { |h| h[:project].name&.downcase }
end
list
end
def allowed_permissions

@ -136,7 +136,6 @@ module Type::AttributeGroups
self.attribute_groups_objects = nil
end
private
def write_attribute_groups_objects

@ -104,7 +104,6 @@ class BaseTypeService
def transform_attribute_groups(groups)
groups.map do |group|
if group['type'] == 'query'
transform_query_group(group)
else

@ -34,7 +34,7 @@ See docs/COPYRIGHT.rdoc for more details.
locals: { name: "queries", checked: true, label: l(:label_query_plural),
count: project.queries.count } %>
<%= render partial: "copy_projects/copy_settings/block_checkbox",
locals: { name: "boards", checked: false, label: l(:label_forum_plural),
locals: { name: "forums", checked: false, label: l(:label_forum_plural),
count: project.forums.count } %>
<%= render partial: "copy_projects/copy_settings/block_checkbox",
locals: { name: "members", checked: true, label: l(:label_member_plural),

@ -87,15 +87,31 @@ See docs/COPYRIGHT.rdoc for more details.
<% end %>
<div id="custom_field_default_value_attributes">
<div class="form--field" id="default_value_text">
<% if @custom_field.new_record? || @custom_field.field_format != 'bool' %>
<%= f.text_field(:default_value, container_class: '-wide') %>
<% if @custom_field.new_record? || !%w[text bool].include?(@custom_field.field_format) %>
<%= f.text_field :default_value,
id: 'custom_fields_default_value_text',
for: 'custom_fields_default_value_text',
container_class: '-wide' %>
<% end %>
</div>
<div class="form--field" style="display:none" id="default_value_bool">
<% if @custom_field.new_record? || @custom_field.field_format == 'bool' %>
<%= f.check_box(:default_value) %>
<%= f.check_box :default_value,
id: 'custom_fields_default_value_bool',
for: 'custom_fields_default_value_bool' %>
<% end %>
</div>
<div class="form--field" style="display:none" id="default_value_long_text">
<% if @custom_field.new_record? || @custom_field.field_format == 'text' %>
<%= f.text_area :default_value,
id: 'custom_fields_default_value_longtext',
for: 'custom_fields_default_value_longtext',
cols: 100,
rows: 20,
class: 'wiki-edit',
with_text_formatting: true %>
<% end %>
</div>
</div>
<%= call_hook(:view_custom_fields_form_upper_box, custom_field: @custom_field, form: f) %>
</section>

@ -89,6 +89,7 @@ See docs/COPYRIGHT.rdoc for more details.
<div class="grid-block">
<div class="generic-table--action-buttons">
<%= styled_button_tag t(@type.new_record? ? :button_create : :button_save),
data: { disable_with: t(@type.new_record? ? :button_create : :button_save) },
class: 'form-configuration--save -highlight -with-icon icon-checkmark' %>
</div>
</div>

@ -1,8 +1,8 @@
---
title: OpenProject 9.0.3
title: OpenProject 9.0.4
sidebar_navigation:
title: 9.0.3
release_version: 9.0.3
title: 9.0.4
release_version: 9.0.4
release_date: 2019-07-23
---
@ -20,4 +20,4 @@ Thanks to David Haintz from the SEC Consult Vulnerability Lab (https://www.sec-c
#### Contributions
Thanks to David Haintz from [SEC Consult Vulnerability Lab](https://www.sec-consult.com/) for identifying and responsibly disclosing the identified issues.
Thanks to David Haintz from [SEC Consult Vulnerability Lab](https://www.sec-consult.com/) for identifying and responsibly disclosing the identified issues.

@ -42,6 +42,11 @@ describe('UserLinkComponent component test', () => {
t: (key:string, args:any) => `Author: ${args.user}`
};
let app:UserLinkComponent;
let fixture:ComponentFixture<UserLinkComponent>;
let element:HTMLElement;
let user:UserResource;
beforeEach(async(() => {
// noinspection JSIgnoredPromiseFromCall
@ -54,32 +59,50 @@ describe('UserLinkComponent component test', () => {
{ provide: PathHelperService, useValue: PathHelperStub },
]
}).compileComponents();
fixture = TestBed.createComponent(UserLinkComponent);
app = fixture.debugElement.componentInstance;
element = fixture.elementRef.nativeElement;
}));
describe('inner element', function() {
let app:UserLinkComponent;
let fixture:ComponentFixture<UserLinkComponent>
let element:HTMLElement;
describe('with the uer having the showUserPath attribute', function() {
beforeEach(async(() => {
user = {
name: 'First Last',
showUserPath: '/users/1'
} as UserResource;
app.user = user;
fixture.detectChanges();
}));
let user = {
name: 'First Last',
href: '/api/v3/users/1',
idFromLink: '1',
} as UserResource;
it('should render an inner link with specified classes', function () {
const link = element.querySelector('a')!;
expect(link.textContent).toEqual('First Last');
expect(link.getAttribute('title')).toEqual('Author: First Last');
expect(link.getAttribute('href')).toEqual('/users/1');
});
});
it('should render an inner link with specified classes', function() {
fixture = TestBed.createComponent(UserLinkComponent);
app = fixture.debugElement.componentInstance;
element = fixture.elementRef.nativeElement;
describe('with the user not having the showUserPath attribute', function() {
beforeEach(async(() => {
user = {
name: 'First Last',
showUserPath: null
} as UserResource;
app.user = user;
fixture.detectChanges();
app.user = user;
fixture.detectChanges();
}));
const link = element.querySelector('a')!;
it('renders only the name', function () {
const link = element.querySelector('a');
expect(link.textContent).toEqual('First Last');
expect(link.getAttribute('title')).toEqual('Author: First Last');
expect(link.getAttribute('href')).toEqual('/users/1');
expect(link).toBeNull();
expect(element.textContent).toEqual(' First Last ');
});
});
});
});

@ -26,7 +26,7 @@
// See docs/COPYRIGHT.rdoc for more details.
//++
import {Component, Inject, Input} from '@angular/core';
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
import {UserResource} from 'core-app/modules/hal/resources/user-resource';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.service';
@ -34,26 +34,32 @@ import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper
@Component({
selector: 'user-link',
template: `
<a [attr.href]="href"
<a *ngIf="href"
[attr.href]="href"
[attr.title]="label"
[textContent]="user.name">
[textContent]="name">
</a>
`
<ng-container *ngIf="!href">
{{ name }}
<ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserLinkComponent {
@Input() user:UserResource;
public href:string;
public label:string;
public name:string;
constructor(readonly I18n:I18nService) {
}
public get href() {
return this.user && this.user.showUserPath;
}
constructor(readonly pathHelper:PathHelperService,
readonly I18n:I18nService) {
public get name() {
return this.user && this.user.name;
}
ngOnInit() {
this.href = this.pathHelper.userPath(this.user.idFromLink);
this.name = this.user.name;
this.label = this.I18n.t('js.label_author', { user: this.name });
public get label() {
return this.I18n.t('js.label_author', { user: this.name });
}
}

@ -8,13 +8,9 @@
data-class-list="avatar">
</user-avatar>
<span class="user" *ngIf="userActive">
<a [attr.href]="userPath"
[attr.aria-label]="userLabel"
[textContent]="userName">
</a>
<span class="user">
<user-link [user]="user"></user-link>
</span>
<span class="user" *ngIf="!userActive">{{ userName }}</span>
<span class="date">
{{ isInitial ? text.label_created_on : text.label_updated_on }}
<op-date-time [dateTimeValue]="activity.createdAt"></op-date-time>

@ -64,10 +64,8 @@ export class UserActivityComponent extends WorkPackageCommentFieldHandler implem
public userCanQuote = false;
public userId:string | number;
public user:UserResource;
public userName:string;
public userActive:boolean;
public userPath:string | null;
public userLabel:string;
public userAvatar:string;
public details:any[] = [];
public isComment:boolean;
@ -121,12 +119,10 @@ export class UserActivityComponent extends WorkPackageCommentFieldHandler implem
this.userCacheService
.require(this.activity.user.idFromLink)
.then((user:UserResource) => {
this.user = user;
this.userId = user.id!;
this.userName = user.name;
this.userActive = user.isActive;
this.userAvatar = user.avatar;
this.userPath = user.showUser.href;
this.userLabel = this.I18n.t('js.label_author', {user: this.userName});
this.cdRef.detectChanges();
});
}

@ -80,9 +80,22 @@ export class TypeFormConfigurationComponent extends UntilDestroyedMixin implemen
this.form = jQuery(this.element).closest('form');
this.submit = this.form.find('.form-configuration--save');
// In the following we are triggering the form submit ourselves to work around
// a firefox shortcoming. But to avoid double submits which are sometimes not canceled fast
// enough, we need to memoize whether we have already submitted.
let submitted = false;
this.form.on('submit', (event) => {
submitted = true;
});
// Capture mousedown on button because firefox breaks blur on click
this.submit.on('mousedown', (event) => {
setTimeout(() => this.form.trigger('submit'), 50);
setTimeout(() => {
if (!submitted) {
this.form.trigger('submit');
}
}, 50);
return true;
});

@ -55,7 +55,7 @@ export class UserResource extends HalResource {
}
public get showUserPath() {
return this.showUser.$link.href;
return this.showUser ? this.showUser.$link.href : null;
}
public get isActive() {

@ -26,10 +26,8 @@
// See docs/COPYRIGHT.rdoc for more details.
// ++
import {UserResource} from 'core-app/modules/hal/resources/user-resource';
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {StateService} from '@uirouter/core';
import {TypeResource} from 'core-app/modules/hal/resources/type-resource';
import {Component, Injector, OnInit} from '@angular/core';
import {WorkPackageViewSelectionService} from 'core-app/modules/work_packages/routing/wp-view-base/view-services/wp-view-selection.service';
import {WorkPackageSingleViewBase} from "core-app/modules/work_packages/routing/wp-view-base/work-package-single-view.base";
@ -48,13 +46,6 @@ export class WorkPackagesFullViewComponent extends WorkPackageSingleViewBase imp
public displayWatchButton:boolean;
public watchers:any;
// Properties
public type:TypeResource;
public author:UserResource;
public authorPath:string;
public authorActive:boolean;
public attachments:any;
// More menu
public permittedActions:any;
public actionsAvailable:any;
@ -102,16 +93,5 @@ export class WorkPackagesFullViewComponent extends WorkPackageSingleViewBase imp
if (wp.watchers) {
this.watchers = (wp.watchers as any).elements;
}
// Type
this.type = wp.type;
// Author
this.author = wp.author;
this.authorPath = this.author.showUserPath as string;
this.authorActive = this.author.isActive;
// Attachments
this.attachments = wp.attachments.elements;
}
}

@ -48,6 +48,8 @@ module API
self_link
link :showUser do
next if represented.locked?
{
href: api_v3_paths.show_user(represented.id),
type: 'text/html'

@ -0,0 +1,66 @@
require 'spec_helper'
require 'support/pages/custom_fields'
describe 'custom fields', js: true do
let(:user) { FactoryBot.create :admin }
let(:cf_page) { Pages::CustomFields.new }
let(:editor) { ::Components::WysiwygEditor.new '#default_value_long_text' }
let(:type) { FactoryBot.create :type_task }
let(:project) { FactoryBot.create :project, enabled_module_names: %i[work_package_tracking], types: [type] }
let(:wp_page) { Pages::FullWorkPackageCreate.new project: project }
let(:default_text) do
<<~MARKDOWN
# This is an exemplary test
**Foo bar**
MARKDOWN
end
before do
login_as(user)
end
describe "creating a new long text custom field" do
before do
cf_page.visit!
click_on "Create a new custom field"
end
it "creates a new bool custom field" do
cf_page.set_name "New Field"
cf_page.select_format "Long text"
sleep 1
editor.set_markdown default_text
cf_page.set_all_projects true
click_on "Save"
expect(page).to have_text("Successful creation")
expect(page).to have_text("New Field")
cf = CustomField.last
expect(cf.field_format).to eq 'text'
# textareas get carriage returns entered
expect(cf.default_value.gsub("\r\n", "\n").strip).to eq default_text.strip
type.custom_fields << cf
type.save!
wp_page.visit!
wp_editor = TextEditorField.new(page, 'description', selector: ".inline-edit--container.customField#{cf.id}")
wp_editor.expect_active!
wp_editor.ckeditor.in_editor do |container, _|
expect(container).to have_selector('h1', text: 'This is an exemplary test')
expect(container).to have_selector('strong', text: 'Foo bar')
end
end
end
end

@ -29,7 +29,8 @@
require 'spec_helper'
describe ::API::V3::Users::UserRepresenter do
let(:user) { FactoryBot.build_stubbed(:user, status: 1) }
let(:status) { Principal::STATUSES[:active] }
let(:user) { FactoryBot.build_stubbed(:user, status: status) }
let(:current_user) { FactoryBot.build_stubbed(:user) }
let(:representer) { described_class.new(user, current_user: current_user) }
@ -150,9 +151,19 @@ describe ::API::V3::Users::UserRepresenter do
expect(subject).to have_json_path('_links/self/href')
end
it_behaves_like 'has an untitled link' do
let(:link) { 'showUser' }
let(:href) { "/users/#{user.id}" }
context 'showUser' do
it_behaves_like 'has an untitled link' do
let(:link) { 'showUser' }
let(:href) { "/users/#{user.id}" }
end
context 'with a locked user' do
let(:status) { Principal::STATUSES[:locked] }
it_behaves_like 'has no link' do
let(:link) { 'showUser' }
end
end
end
context 'when regular current_user' do

@ -43,7 +43,11 @@ module Pages
end
def set_default_value(value)
find("#custom_field_default_value").set value
fill_in 'custom_field[default_value]', with: value
end
def set_all_projects(value)
find('#custom_field_is_for_all').set value
end
def has_form_element?(name)

Loading…
Cancel
Save