Generate URL params for gantt view and insert projects

pull/8610/head
Oliver Günther 4 years ago
parent e8dde985aa
commit 4aad0c8b72
No known key found for this signature in database
GPG Key ID: 88872239EB414F99
  1. 5
      app/helpers/projects_helper.rb
  2. 79
      app/services/projects/gantt_query_generator_service.rb
  3. 9
      app/views/projects/index.html.erb
  4. 5
      app/views/settings/_projects.html.erb
  5. 1
      config/locales/en.yml
  6. 1
      config/settings.yml
  7. 6
      frontend/src/app/angular4-modules.ts
  8. 2
      frontend/src/app/components/wp-query/url-params-helper.ts
  9. 7
      frontend/src/app/components/wp-table/embedded/wp-embedded-base.component.ts
  10. 25
      frontend/src/app/components/wp-table/external-configuration/external-query-configuration.component.ts
  11. 1
      frontend/src/app/components/wp-table/external-configuration/external-query-configuration.constants.ts
  12. 28
      frontend/src/app/components/wp-table/external-configuration/external-query-configuration.service.ts
  13. 2
      frontend/src/app/components/wp-table/external-configuration/external-query-configuration.template.html
  14. 31
      frontend/src/app/modules/admin/editable-query-props/editable-query-props.component.ts
  15. 8
      frontend/src/app/modules/admin/types/type-form-configuration.component.ts
  16. 4
      frontend/src/vendor/ckeditor/ckeditor.js
  17. 2
      frontend/src/vendor/ckeditor/ckeditor.js.map
  18. 13
      spec/features/projects/projects_index_spec.rb
  19. 138
      spec/features/projects/projects_portfolio_spec.rb
  20. 70
      spec/services/projects/gantt_query_generator_service_spec.rb
  21. 8
      spec/support/components/work_packages/columns.rb
  22. 11
      spec/support/components/work_packages/filters.rb

@ -220,6 +220,11 @@ module ProjectsHelper
.assignable_parents
end
def gantt_portfolio_query_link(filtered_project_ids)
generator = ::Projects::GanttQueryGeneratorService.new(filtered_project_ids)
work_packages_path query_props: generator.call
end
def short_project_description(project, length = 255)
unless project.description.present?
return ''

@ -0,0 +1,79 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 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 GanttQueryGeneratorService
DEFAULT_GANTT_QUERY ||=
'{"c":["type","id","subject","status","project"],"tv":true,"hi":false,"g":"project"}'.freeze
attr_reader :selected_project_ids
def initialize(selected_project_ids)
@selected_project_ids = selected_project_ids
end
def call
# Read the raw query_props from the settings (filters and columns still serialized)
params = params_from_settings.dup
# Delete the parent filter
params['f'] =
if params['f']
params['f'].reject { |filter| filter['n'] == 'project' }
else
[]
end
# Ensure grouped by project
params['g'] = 'project'
params['hi'] = false
# Ensure timeline visible
params['tv'] = true
# Add the parent filter
params['f'] << { 'n' => 'project', 'o' => '=', 'v' => selected_project_ids }
params.to_json
end
private
def params_from_settings
setting = Setting.project_gantt_query.presence || DEFAULT_GANTT_QUERY
JSON.parse(setting)
rescue JSON::JSONError => e
Rails.logger.error "Failed to read project gantt view, resetting to default. Error was: #{e.message}"
Setting.project_gantt_query = DEFAULT_GANTT_QUERY
JSON.parse(DEFAULT_GANTT_QUERY)
end
end
end

@ -52,6 +52,15 @@ See docs/COPYRIGHT.rdoc for more details.
<li class="toolbar-item">
<%= link_to t(:label_overall_activity), activities_path, class: 'button' %>
</li>
<li class="toolbar-item">
<%= link_to gantt_portfolio_query_link(@projects.select(:id).uniq.pluck(:id)),
class: 'button',
target: '_blank' do %>
<%= op_icon("button--icon icon-view-timeline") %>
<%= t('projects.index.open_as_gantt') %>
<%= op_icon("button--icon icon-external-link") %>
<% end %>
</li>
<% end %>
<%= render partial: 'projects/filters/form', locals: { query: @query } %>

@ -64,7 +64,8 @@ See docs/COPYRIGHT.rdoc for more details.
data: {
name: 'settings[project_gantt_query]',
id: 'settings_project_gantt_query',
query: Setting.project_gantt_query
query: Setting.project_gantt_query,
'url-params': 'true'
}
%>
</span>
@ -75,4 +76,6 @@ See docs/COPYRIGHT.rdoc for more details.
</div>
</fieldset>
</section>
<%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %>
<% end %>

@ -229,6 +229,7 @@ en:
completed: "Deletion of project %{name} completed"
completed_text: "The request to delete project '%{name}' has been completed."
index:
open_as_gantt: 'Open as Gantt view'
no_results_title_text: There are currently no projects
no_results_content_text: Create a new project
settings:

@ -288,7 +288,6 @@ default_projects_modules:
enabled_projects_columns:
serialized: true
default:
- name
- project_status
- public
- created_at

@ -198,8 +198,6 @@ export class OpenProjectModule {
export function initializeServices(injector:Injector) {
return () => {
const ExternalQueryConfiguration = injector.get(ExternalQueryConfigurationService);
const ExternalRelationQueryConfiguration = injector.get(ExternalRelationQueryConfigurationService);
const PreviewTrigger = injector.get(PreviewTriggerService);
const mainMenuNavigationService = injector.get(MainMenuNavigationService);
const keyboardShortcuts = injector.get(KeyboardShortcutService);
@ -211,9 +209,5 @@ export function initializeServices(injector:Injector) {
PreviewTrigger.setupListener();
keyboardShortcuts.register();
// Setup query configuration listener
ExternalQueryConfiguration.setupListener();
ExternalRelationQueryConfiguration.setupListener();
};
}

@ -164,7 +164,7 @@ export class UrlParamsHelperService {
public buildV3GetQueryFromJsonParams(updateJson:string|null) {
var queryData:any = {
pageSize: this.paginationService.getPerPage()
}
};
if (!updateJson) {
return queryData;

@ -71,6 +71,13 @@ export abstract class WorkPackageEmbeddedBaseComponent extends WorkPackagesViewB
return this.urlParamsHelper.buildV3GetQueryFromQueryResource(query);
}
public buildUrlParams() {
const query = this.querySpace.query.value!;
this.wpStatesInitialization.applyToQuery(query);
return this.urlParamsHelper.encodeQueryJsonParams(query);
}
protected setLoaded() {
this.renderTable = this.configuration.tableVisible;
this.cdRef.detectChanges();

@ -1,13 +1,15 @@
import {AfterViewInit, ChangeDetectorRef, Component, Inject, ViewChild} from '@angular/core';
import {AfterViewInit, ChangeDetectorRef, Component, Inject, OnInit, ViewChild} from '@angular/core';
import {WorkPackageEmbeddedTableComponent} from 'core-components/wp-table/embedded/wp-embedded-table.component';
import {WpTableConfigurationService} from 'core-components/wp-table/configuration-modal/wp-table-configuration.service';
import {RestrictedWpTableConfigurationService} from 'core-components/wp-table/external-configuration/restricted-wp-table-configuration.service';
import {OpQueryConfigurationLocalsToken} from "core-components/wp-table/external-configuration/external-query-configuration.constants";
import {UrlParamsHelperService} from "core-components/wp-query/url-params-helper";
export interface QueryConfigurationLocals {
service:any;
currentQuery:any;
disabledTabs:{ [key:string]:string };
urlParams?:boolean;
disabledTabs?:{ [key:string]:string };
callback:(newQuery:any) => void;
}
@ -15,21 +17,36 @@ export interface QueryConfigurationLocals {
templateUrl: './external-query-configuration.template.html',
providers: [[{ provide: WpTableConfigurationService, useClass: RestrictedWpTableConfigurationService }]]
})
export class ExternalQueryConfigurationComponent implements AfterViewInit {
export class ExternalQueryConfigurationComponent implements OnInit, AfterViewInit {
@ViewChild('embeddedTableForConfiguration', { static: true }) private embeddedTable:WorkPackageEmbeddedTableComponent;
queryProps:string;
constructor(@Inject(OpQueryConfigurationLocalsToken) readonly locals:QueryConfigurationLocals,
readonly urlParamsHelper:UrlParamsHelperService,
readonly cdRef:ChangeDetectorRef) {
}
ngOnInit() {
if (this.locals.urlParams) {
this.queryProps = this.urlParamsHelper.buildV3GetQueryFromJsonParams(this.locals.currentQuery);
} else {
this.queryProps = this.locals.currentQuery;
}
}
ngAfterViewInit() {
// Open the configuration modal in an asynchronous step
// to avoid nesting components in the view initialization.
setTimeout(() => {
this.embeddedTable.openConfigurationModal(() => {
this.service.detach();
this.locals.callback(this.embeddedTable.buildQueryProps());
if (this.locals.urlParams) {
this.locals.callback(this.embeddedTable.buildUrlParams());
} else {
this.locals.callback(this.embeddedTable.buildQueryProps());
}
});
this.cdRef.detectChanges();
});

@ -1,4 +1,3 @@
import {InjectionToken} from "@angular/core";
export const OpQueryConfigurationLocalsToken = new InjectionToken<any>('OpQueryConfigurationLocalsToken');
export const OpQueryConfigurationTriggerEvent = 'op:queryconfiguration:trigger';

@ -2,11 +2,11 @@ import {ApplicationRef, ComponentFactoryResolver, Injectable, Injector} from '@a
import {ComponentPortal, DomPortalOutlet, PortalInjector} from '@angular/cdk/portal';
import {TransitionService} from '@uirouter/core';
import {FocusHelperService} from 'core-app/modules/common/focus/focus-helper';
import {ExternalQueryConfigurationComponent} from "core-components/wp-table/external-configuration/external-query-configuration.component";
import {
OpQueryConfigurationLocalsToken,
OpQueryConfigurationTriggerEvent
} from "core-components/wp-table/external-configuration/external-query-configuration.constants";
ExternalQueryConfigurationComponent,
QueryConfigurationLocals
} from "core-components/wp-table/external-configuration/external-query-configuration.component";
import {OpQueryConfigurationLocalsToken} from "core-components/wp-table/external-configuration/external-query-configuration.constants";
export type Class = { new(...args:any[]):any; };
@ -25,16 +25,6 @@ export class ExternalQueryConfigurationService {
private injector:Injector) {
}
public setupListener() {
// Listen to keyups on window to close context menus
jQuery(window)
.on(OpQueryConfigurationTriggerEvent,
(event:JQuery.TriggeredEvent, originator:JQuery, currentQuery:any) => {
this.show(originator, currentQuery);
return false;
});
}
/**
* Create a portal host element to contain the table configuration components.
*/
@ -58,20 +48,14 @@ export class ExternalQueryConfigurationService {
/**
* Open a Modal reference and append it to the portal
*/
public show(currentQuery:any,
callback:(newQuery:any) => void,
disabledTabs:{[key:string]:string} = {}) {
public show(data:Partial<QueryConfigurationLocals>) {
this.detach();
// Create a portal for the given component class and render it
const portal = new ComponentPortal(
this.externalQueryConfigurationComponent(),
null,
this.injectorFor({
callback: callback,
currentQuery: currentQuery,
disabledTabs: disabledTabs
})
this.injectorFor(data)
);
this.bodyPortalHost.attach(portal);
this._portalHostElement.style.display = 'block';

@ -1,6 +1,6 @@
<ng-container wp-isolated-query-space>
<wp-embedded-table #embeddedTableForConfiguration
[queryProps]="locals.currentQuery || {}"
[queryProps]="queryProps"
[configuration]="{ tableVisible: false }">
</wp-embedded-table>
</ng-container>

@ -1,6 +1,7 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, OnInit} from "@angular/core";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {ExternalQueryConfigurationService} from "core-components/wp-table/external-configuration/external-query-configuration.service";
import {UrlParamsHelperService} from "core-components/wp-query/url-params-helper";
export const editableQueryPropsSelector = 'editable-query-props';
@ -12,6 +13,7 @@ export const editableQueryPropsSelector = 'editable-query-props';
export class EditableQueryPropsComponent implements OnInit {
id:string|null;
name:string|null;
urlParams:boolean = false;
queryProps:string;
@ -22,6 +24,7 @@ export class EditableQueryPropsComponent implements OnInit {
constructor(private elementRef:ElementRef,
private I18n:I18nService,
private cdRef:ChangeDetectorRef,
private urlParamsHelper:UrlParamsHelperService,
private externalQuery:ExternalQueryConfigurationService) {
}
@ -29,26 +32,30 @@ export class EditableQueryPropsComponent implements OnInit {
const element = this.elementRef.nativeElement;
this.id = element.dataset.id;
this.name = element.dataset.name;
this.urlParams = element.dataset.urlParams === 'true';
this.queryProps = element.dataset.query;
}
public editQuery() {
let json;
try {
json = JSON.parse(this.queryProps);
} catch (e) {
console.error(`Failed to parse query props from ${this.queryProps}: ${e}`);
json = {};
let queryProps:any = this.queryProps;
if (!this.urlParams) {
try {
queryProps = JSON.parse(this.queryProps);
} catch (e) {
console.error(`Failed to parse query props from ${this.queryProps}: ${e}`);
queryProps = {};
}
}
this.externalQuery.show(
json,
(queryProps:any) => {
this.queryProps = JSON.stringify(queryProps);
this.externalQuery.show({
currentQuery: queryProps,
urlParams: this.urlParams,
callback: (queryProps:any) => {
this.queryProps = this.urlParams ? queryProps : JSON.stringify(queryProps);
this.cdRef.detectChanges();
}
);
});
}
}

@ -170,11 +170,11 @@ export class TypeFormConfigurationComponent extends UntilDestroyedMixin implemen
'timelines': I18n.t('js.work_packages.table_configuration.embedded_tab_disabled')
};
this.externalRelationQuery.show(
JSON.parse(group.query),
(queryProps:any) => group.query = JSON.stringify(queryProps),
this.externalRelationQuery.show({
currentQuery: JSON.parse(group.query),
callback: (queryProps:any) => group.query = JSON.stringify(queryProps),
disabledTabs
);
});
}
public deleteGroup(group:TypeGroup) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -202,7 +202,16 @@ describe 'Projects index page',
allow_enterprise_edition
end
scenario 'CF columns and filters are visible' do
scenario 'CF columns and filters are not visible by default' do
load_and_open_filters admin
# CF's columns are not shown due to setting
expect(page).to_not have_text(custom_field.name.upcase)
end
scenario 'CF columns and filters are visible when added to settings' do
Setting.enabled_projects_columns << "cf_#{custom_field.id}"
Setting.enabled_projects_columns << "cf_#{invisible_custom_field.id}"
load_and_open_filters admin
# CF's column is present:
@ -847,6 +856,8 @@ describe 'Projects index page',
end
scenario 'allows to alter the order in which projects are displayed' do
Setting.enabled_projects_columns << "cf_#{integer_custom_field.id}"
# initially, ordered by name asc on each hierarchical level
expect_projects_in_order(development_project,
project,

@ -0,0 +1,138 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe 'Projects index page',
type: :feature,
with_ee: %i[custom_fields_in_projects_list],
js: true,
with_settings: { login_required?: false } do
using_shared_fixtures :admin
let!(:string_cf) { FactoryBot.create(:string_project_custom_field, name: 'Foobar') }
let(:cv_a) { FactoryBot.build :custom_value, custom_field: string_cf, value: 'A' }
let(:cv_b) { FactoryBot.build :custom_value, custom_field: string_cf, value: 'B' }
let!(:project_a) { FactoryBot.create :project, name: 'A', types: [type_milestone], custom_values: [cv_a] }
let!(:project_b) { FactoryBot.create :project, name: 'B', types: [type_milestone], custom_values: [cv_b] }
let!(:type_milestone) { FactoryBot.create :type, name: 'Milestone', is_milestone: true }
let!(:work_package_a) { FactoryBot.create :work_package, subject: 'WP A', type: type_milestone, project: project_a }
let!(:work_package_b) { FactoryBot.create :work_package, subject: 'WP B', type: type_milestone, project: project_b }
let(:modal) { ::Components::WorkPackages::TableConfigurationModal.new }
let(:model_filters) { ::Components::WorkPackages::TableConfiguration::Filters.new }
let(:columns) { ::Components::WorkPackages::Columns.new }
let(:filters) { ::Components::WorkPackages::Filters.new }
let(:wp_table) { ::Pages::WorkPackagesTable.new }
before do
login_as admin
end
it 'can manage and browse the project portfolio Gantt' do
visit projects_settings_path
# It has checked all selected settings
Setting.enabled_projects_columns.each do |name|
expect(page).to have_selector("#enabled_projects_columns_#{name}:checked")
end
# Uncheck all selected columns
page.all('.form--matrix input[type="checkbox"]').each do |el|
el.uncheck if el.checked?
end
# Check the status and custom field only
find("#enabled_projects_columns_project_status").check
find("#enabled_projects_columns_cf_#{string_cf.id}").check
expect(page).to have_selector("#enabled_projects_columns_project_status:checked")
expect(page).to have_selector("#enabled_projects_columns_cf_#{string_cf.id}:checked")
# Edit the project gantt query
scroll_to_and_click(find('a', text: 'Edit query'))
columns.assume_opened
columns.uncheck_all save_changes: false
columns.add 'ID', save_changes: false
columns.add 'Subject', save_changes: false
columns.add 'Project', save_changes: false
modal.switch_to 'Filters'
model_filters.expect_filter_count 2
# Add a project filter that gets overridden
model_filters.add_filter_by('Project', 'is', project_a.name)
model_filters.add_filter_by('Type', 'is', type_milestone.name)
model_filters.save
# Save the page
scroll_to_and_click(find('.button', text: 'Save'))
expect(page).to have_selector('.flash.notice', text: 'Successful update.')
RequestStore.clear!
query = JSON.parse Setting.project_gantt_query
expect(query['f']).to include({ 'n' => 'type', 'o' => '=', 'v' => [type_milestone.id.to_s] })
# Go to project page
visit projects_path
# Click the gantt button
new_window = window_opened_by { click_on 'Open as Gantt view' }
switch_to_window new_window
wp_table.expect_work_package_listed work_package_a, work_package_b
# Expect grouped and filtered for both projects
expect(page).to have_selector '.group--value', text: 'A'
expect(page).to have_selector '.group--value', text: 'B'
# Expect status, type and project filters
filters.expect_filter_count 3
filters.open
filters.expect_filter_by('Type', 'is', [type_milestone.name])
filters.expect_filter_by('Project', 'is', [project_a.name, project_b.name])
# Expect columns
columns.open_modal
columns.expect_checked 'ID'
columns.expect_checked 'Subject'
columns.expect_checked 'Project'
columns.expect_unchecked 'Assignee'
columns.expect_unchecked 'Type'
columns.expect_unchecked 'Priority'
end
end

@ -0,0 +1,70 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2020 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-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe Projects::GanttQueryGeneratorService, type: :model do
let(:selected) { %w[1 2 3] }
let(:instance) { described_class.new selected }
let(:subject) { instance.call }
let(:json) { JSON.parse(subject) }
let(:default_json) { JSON.parse(Projects::GanttQueryGeneratorService::DEFAULT_GANTT_QUERY) }
def build_project_filter(ids)
{ 'n' => 'project', 'o' => '=', 'v' => ids }
end
context 'with empty setting' do
it 'uses the default' do
Setting.project_gantt_query = ''
expected = default_json.merge('f' => [build_project_filter(selected)])
expect(json).to eq(expected)
end
end
context 'with existing filter' do
it 'overrides the filter' do
Setting.project_gantt_query = default_json.merge('f' => [build_project_filter(%w[other values])]).to_json
expected = default_json.merge('f' => [build_project_filter(selected)])
expect(json).to eq(expected)
end
end
context 'with invalid json' do
it 'returns the default' do
Setting.project_gantt_query = 'invalid!1234'
expected = default_json.merge('f' => [build_project_filter(selected)])
expect(json).to eq(expected)
end
end
end

@ -94,6 +94,12 @@ module Components
end
end
def expect_unchecked(name)
within_modal do
expect(page).to have_no_selector('.draggable-autocomplete--item', text: name)
end
end
def uncheck_all(save_changes: true)
modal_open? or open_modal
@ -101,7 +107,7 @@ module Components
expect(page).to have_selector('.draggable-autocomplete--item', minimum: 1)
page.all('.draggable-autocomplete--remove-item').each do |el|
el.click
sleep 1
sleep 0.2
end
end

@ -98,6 +98,8 @@ module Components
set_operator(name, operator, selector)
set_value(id, value) unless value.nil?
close_autocompleter(id)
end
def expect_filter_by(name, operator, value, selector = nil)
@ -193,6 +195,15 @@ module Components
yield page.has_selector?('.ng-select-container')
end
end
def close_autocompleter(id)
input = page.all("#filter_#{id} .advanced-filters--filter-value .ng-input input").first
if input
input.click
input.send_keys :escape
end
end
end
end
end

Loading…
Cancel
Save