From 3f677298ad2d74c9b7db891a5219455cc643efa0 Mon Sep 17 00:00:00 2001 From: ulferts Date: Mon, 5 Aug 2019 10:36:41 +0200 Subject: [PATCH] add project details widget --- config/locales/js-en.yml | 2 + .../wp-edit-form/display-field-renderer.ts | 2 +- .../apiv3/projects/apiv3-projects-paths.ts | 5 +- .../modules/grids/openproject-grids.module.ts | 11 ++ .../project-details.component.html | 13 ++ .../project-details.component.ts | 170 ++++++++++++++++++ .../hal/dm-services/project-dm.service.ts | 14 +- .../lib/dashboards/grid_registration.rb | 1 + .../spec/features/project_details_spec.rb | 118 ++++++++++++ 9 files changed, 333 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/modules/grids/widgets/project-details/project-details.component.html create mode 100644 frontend/src/app/modules/grids/widgets/project-details/project-details.component.ts create mode 100644 modules/dashboards/spec/features/project_details_spec.rb diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index a4d0eb6a4e..9ffdb293d3 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -226,6 +226,8 @@ en: no_results: 'Nothing new to report.' project_description: title: 'Project description' + project_details: + title: 'Project details' time_entries_current_user: title: 'Spent time (last 7 days)' no_results: 'No time entries for the last 7 days.' diff --git a/frontend/src/app/components/wp-edit-form/display-field-renderer.ts b/frontend/src/app/components/wp-edit-form/display-field-renderer.ts index 4196398d0a..30750e3501 100644 --- a/frontend/src/app/components/wp-edit-form/display-field-renderer.ts +++ b/frontend/src/app/components/wp-edit-form/display-field-renderer.ts @@ -24,7 +24,7 @@ export class DisplayFieldRenderer { readonly I18n:I18nService = this.injector.get(I18nService); /** We cache the previously used fields to avoid reinitialization */ - private fieldCache:{ [key:string]: DisplayField } = {}; + private fieldCache:{ [key:string]:DisplayField } = {}; constructor(public readonly injector:Injector, public readonly container:'table' | 'single-view' | 'timeline', diff --git a/frontend/src/app/modules/common/path-helper/apiv3/projects/apiv3-projects-paths.ts b/frontend/src/app/modules/common/path-helper/apiv3/projects/apiv3-projects-paths.ts index bca39b77b0..b879cd9369 100644 --- a/frontend/src/app/modules/common/path-helper/apiv3/projects/apiv3-projects-paths.ts +++ b/frontend/src/app/modules/common/path-helper/apiv3/projects/apiv3-projects-paths.ts @@ -26,7 +26,7 @@ // See doc/COPYRIGHT.rdoc for more details. // ++ -import {SimpleResourceCollection} from 'core-app/modules/common/path-helper/apiv3/path-resources'; +import {SimpleResource, SimpleResourceCollection} from 'core-app/modules/common/path-helper/apiv3/path-resources'; import {Apiv3ProjectPaths} from 'core-app/modules/common/path-helper/apiv3/projects/apiv3-project-paths'; export class Apiv3ProjectsPaths extends SimpleResourceCollection { @@ -38,4 +38,7 @@ export class Apiv3ProjectsPaths extends SimpleResourceCollection + + + + + +
+
diff --git a/frontend/src/app/modules/grids/widgets/project-details/project-details.component.ts b/frontend/src/app/modules/grids/widgets/project-details/project-details.component.ts new file mode 100644 index 0000000000..a50fc6dd85 --- /dev/null +++ b/frontend/src/app/modules/grids/widgets/project-details/project-details.component.ts @@ -0,0 +1,170 @@ +// -- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// 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 doc/COPYRIGHT.rdoc for more details. +// ++ + +import {Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef, Injector, ViewChild, ElementRef} from '@angular/core'; +import {AbstractWidgetComponent} from "app/modules/grids/widgets/abstract-widget.component"; +import {I18nService} from "core-app/modules/common/i18n/i18n.service"; +import {ProjectDmService} from "core-app/modules/hal/dm-services/project-dm.service"; +import {CurrentProjectService} from "core-components/projects/current-project.service"; +import {SchemaResource} from "core-app/modules/hal/resources/schema-resource"; +import {DisplayFieldContext, DisplayFieldService} from "core-app/modules/fields/display/display-field.service"; +import {ProjectResource} from "core-app/modules/hal/resources/project-resource"; +import {PortalCleanupService} from 'core-app/modules/fields/display/display-portal/portal-cleanup.service'; +import {DisplayField} from "core-app/modules/fields/display/display-field.module"; +import {WorkPackageTableHighlightingService} from "core-components/wp-fast-table/state/wp-table-highlighting.service"; +import {IsolatedQuerySpace} from "core-app/modules/work_packages/query-space/isolated-query-space"; + +export const emptyPlaceholder = '-'; + +@Component({ + templateUrl: './project-details.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + // required by the displayField service to render the fields + PortalCleanupService, + WorkPackageTableHighlightingService, + IsolatedQuerySpace + ] +}) +export class WidgetProjectDetailsComponent extends AbstractWidgetComponent implements OnInit { + public customFieldsMap:Array = []; + + @ViewChild('contentContainer', { static: true }) readonly contentContainer:ElementRef; + + constructor(protected readonly i18n:I18nService, + protected readonly injector:Injector, + protected readonly projectDm:ProjectDmService, + protected readonly currentProject:CurrentProjectService, + protected readonly displayField:DisplayFieldService, + protected readonly cdr:ChangeDetectorRef) { + super(i18n, injector); + } + + ngOnInit() { + this.loadAndRender(); + } + + private loadAndRender() { + Promise.all( + [this.loadCurrentProject(), + this.loadProjectSchema()] + ) + .then(([project, schema]) => { + this.renderCFs(project, schema as SchemaResource); + + this.redraw(); + }); + } + + private loadCurrentProject() { + return this.projectDm.load(this.currentProject.id as string); + } + + private loadProjectSchema() { + return this.projectDm.schema(); + } + + private renderCFs(project:ProjectResource, schema:SchemaResource) { + const cfFields = this.collectFieldsForCfs(project, schema); + + this.sortFieldsLexicographically(cfFields); + this.renderFields(cfFields); + } + + private collectFieldsForCfs(project:ProjectResource, schema:SchemaResource) { + let displayFields:Array = []; + // passing an arbitrary context to displayField.getField only to satisfy the interface + let context:DisplayFieldContext = {injector: this.injector, container: 'table', options: []}; + + Object.entries(schema).forEach(([key, keySchema]) => { + if (key.match(/customField\d+/)) { + let field = this.displayField.getField(project, key, keySchema, context); + + displayFields.push(field); + } + }); + + return displayFields; + } + + private sortFieldsLexicographically(fields:Array) { + fields.sort((a, b) => { return a.label.localeCompare(b.label); }); + } + + private renderFields(fields:Array) { + this.contentContainer.nativeElement.innerHTML = ''; + + fields.forEach(field => { + this.contentContainer.nativeElement.appendChild(this.displayKeyValue(field)); + }); + } + + private displayKeyValue(field:DisplayField) { + const container = this.containerElement(); + + container.appendChild(this.labelElement(field)); + container.appendChild(this.valueElement(field)); + + return container; + } + + private containerElement() { + const container = document.createElement('div'); + container.classList.add('attributes-key-value'); + + return container; + } + + private labelElement(field:DisplayField) { + const label = document.createElement('div'); + label.classList.add('attributes-key-value--key'); + label.innerText = field.label; + + return label; + } + + private valueElement(field:DisplayField) { + const value = document.createElement('div'); + value.classList.add('attributes-key-value--value-container'); + field.render(value, this.getText(field)); + + return value; + } + + private getText(field:DisplayField):string { + if (field.isEmpty()) { + return emptyPlaceholder; + } else { + return field.valueString; + } + } + + private redraw() { + this.cdr.detectChanges(); + } +} diff --git a/frontend/src/app/modules/hal/dm-services/project-dm.service.ts b/frontend/src/app/modules/hal/dm-services/project-dm.service.ts index cacdd5ecce..085e53bde4 100644 --- a/frontend/src/app/modules/hal/dm-services/project-dm.service.ts +++ b/frontend/src/app/modules/hal/dm-services/project-dm.service.ts @@ -30,6 +30,8 @@ import {HalResourceService} from 'core-app/modules/hal/services/hal-resource.ser import {Inject, Injectable} from '@angular/core'; import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.service'; import {ProjectResource} from 'core-app/modules/hal/resources/project-resource'; +import {SchemaResource} from "core-app/modules/hal/resources/schema-resource"; +import {Apiv3ProjectsPaths} from "core-app/modules/common/path-helper/apiv3/projects/apiv3-projects-paths"; @Injectable() export class ProjectDmService { @@ -39,7 +41,17 @@ export class ProjectDmService { public load(id:string|number):Promise { return this.halResourceService - .get(this.pathHelper.api.v3.projects.id(id).toString()) + .get(this.projectsPath.id(id).toString()) .toPromise(); } + + public schema():Promise { + return this.halResourceService + .get(this.projectsPath.schema) + .toPromise(); + } + + private get projectsPath():Apiv3ProjectsPaths { + return this.pathHelper.api.v3.projects; + } } diff --git a/modules/dashboards/lib/dashboards/grid_registration.rb b/modules/dashboards/lib/dashboards/grid_registration.rb index 4b2c742ecb..25b217d8f4 100644 --- a/modules/dashboards/lib/dashboards/grid_registration.rb +++ b/modules/dashboards/lib/dashboards/grid_registration.rb @@ -6,6 +6,7 @@ module Dashboards widgets 'work_packages_table', 'work_packages_graph', 'project_description', + 'project_details', 'work_packages_calendar', 'work_packages_overview', 'custom_text' diff --git a/modules/dashboards/spec/features/project_details_spec.rb b/modules/dashboards/spec/features/project_details_spec.rb new file mode 100644 index 0000000000..6d2d67f983 --- /dev/null +++ b/modules/dashboards/spec/features/project_details_spec.rb @@ -0,0 +1,118 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# 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' + +require_relative '../support/pages/dashboard' + +describe 'Project details widget on dashboard', type: :feature, js: true do + let!(:version_cf) { FactoryBot.create(:version_project_custom_field) } + let!(:bool_cf) { FactoryBot.create(:bool_project_custom_field) } + let!(:user_cf) { FactoryBot.create(:user_project_custom_field) } + let!(:int_cf) { FactoryBot.create(:int_project_custom_field) } + let!(:float_cf) { FactoryBot.create(:float_project_custom_field) } + let!(:text_cf) { FactoryBot.create(:text_project_custom_field) } + let!(:string_cf) { FactoryBot.create(:string_project_custom_field) } + let!(:date_cf) { FactoryBot.create(:date_project_custom_field) } + + let(:system_version) { FactoryBot.create(:version, sharing: 'system') } + + let!(:project) do + FactoryBot.create(:project).tap do |p| + p.add_member(other_user, [role]) + + p.send(:"custom_field_#{int_cf.id}=", 5) + p.send(:"custom_field_#{bool_cf.id}=", true) + p.send(:"custom_field_#{version_cf.id}=", system_version) + p.send(:"custom_field_#{float_cf.id}=", 4.5) + p.send(:"custom_field_#{text_cf.id}=", 'Some **long** text') + p.send(:"custom_field_#{string_cf.id}=", 'Some small text') + p.send(:"custom_field_#{date_cf.id}=", Date.today) + p.send(:"custom_field_#{user_cf.id}=", other_user) + + p.save!(validate: false) + end + end + + let(:permissions) do + %i[view_dashboards + manage_dashboards] + end + + let(:role) do + FactoryBot.create(:role, permissions: permissions) + end + + let(:user) do + FactoryBot.create(:user, member_in_project: project, member_through_role: role) + end + let(:other_user) do + FactoryBot.create(:user) + end + let(:dashboard_page) do + Pages::Dashboard.new(project) + end + + before do + login_as user + + dashboard_page.visit! + end + + it 'can add the widget and see the description in it' do + dashboard_page.add_column(3, before_or_after: :before) + + dashboard_page.add_widget(2, 3, "Project details") + + sleep(0.1) + + # As the user lacks the manage_public_queries and save_queries permission, no other widget is present + details_widget = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(1)') + details_widget.resize_to(6, 5) + details_widget.expect_to_span(2, 3, 7, 6) + + within(details_widget.area) do + expect(page) + .to have_content("#{int_cf.name}\n5") + expect(page) + .to have_content("#{bool_cf.name}\nyes") + expect(page) + .to have_content("#{version_cf.name}\n#{system_version.name}") + expect(page) + .to have_content("#{float_cf.name}\n4.5") + expect(page) + .to have_content("#{text_cf.name}\nSome long text") + expect(page) + .to have_content("#{string_cf.name}\nSome small text") + expect(page) + .to have_content("#{date_cf.name}\n#{Date.today.strftime('%m/%d/%Y')}") + expect(page) + .to have_content("#{user_cf.name}\n#{user.name.split.map(&:first).join}#{user.name}") + end + end +end