add project details widget

pull/7505/head
ulferts 5 years ago
parent cc41884048
commit 3f677298ad
No known key found for this signature in database
GPG Key ID: A205708DE1284017
  1. 2
      config/locales/js-en.yml
  2. 2
      frontend/src/app/components/wp-edit-form/display-field-renderer.ts
  3. 5
      frontend/src/app/modules/common/path-helper/apiv3/projects/apiv3-projects-paths.ts
  4. 11
      frontend/src/app/modules/grids/openproject-grids.module.ts
  5. 13
      frontend/src/app/modules/grids/widgets/project-details/project-details.component.html
  6. 170
      frontend/src/app/modules/grids/widgets/project-details/project-details.component.ts
  7. 14
      frontend/src/app/modules/hal/dm-services/project-dm.service.ts
  8. 1
      modules/dashboards/lib/dashboards/grid_registration.rb
  9. 118
      modules/dashboards/spec/features/project_details_spec.rb

@ -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.'

@ -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',

@ -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<Apiv3ProjectPaths> {
@ -38,4 +38,7 @@ export class Apiv3ProjectsPaths extends SimpleResourceCollection<Apiv3ProjectPat
public id(projectIdentifier:string|number):Apiv3ProjectPaths {
return new Apiv3ProjectPaths(this.path, projectIdentifier);
}
// /api/v3/projects/schema
public readonly schema = this.path + '/schema';
}

@ -61,6 +61,7 @@ import {WidgetHeaderComponent} from "core-app/modules/grids/widgets/header/heade
import {WidgetWpOverviewComponent} from "core-app/modules/grids/widgets/wp-overview/wp-overview.component";
import {WidgetCustomTextComponent} from "core-app/modules/grids/widgets/custom-text/custom-text.component";
import {OpenprojectFieldsModule} from "core-app/modules/fields/openproject-fields.module";
import {WidgetProjectDetailsComponent} from "core-app/modules/grids/widgets/project-details/project-details.component";
export const GRID_ROUTES:Ng2StateDeclaration[] = [
{
@ -95,6 +96,7 @@ export const GRID_ROUTES:Ng2StateDeclaration[] = [
WidgetWpCalendarComponent,
WidgetWpOverviewComponent,
WidgetProjectDescriptionComponent,
WidgetProjectDetailsComponent,
WidgetTimeEntriesCurrentUserComponent]),
// Support for inline editig fields
@ -126,6 +128,7 @@ export const GRID_ROUTES:Ng2StateDeclaration[] = [
WidgetWpTableQuerySpaceComponent,
WidgetWpGraphComponent,
WidgetProjectDescriptionComponent,
WidgetProjectDetailsComponent,
WidgetTimeEntriesCurrentUserComponent,
// Widget menus
@ -304,6 +307,14 @@ export function registerWidgets(injector:Injector) {
raw: ''
}
}
},
{
identifier: 'project_details',
component: WidgetProjectDetailsComponent,
title: i18n.t(`js.grid.widgets.project_details.title`),
properties: {
name: i18n.t('js.grid.widgets.project_details.title')
}
}
];
});

@ -0,0 +1,13 @@
<widget-header
[name]="widgetName"
[icon]="'flag'"
(onRenamed)="renameWidget($event)">
<widget-menu
[resource]="resource">
</widget-menu>
</widget-header>
<div class="grid--widget-content"
#contentContainer>
</div>

@ -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<DisplayField> = [];
@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<DisplayField> = [];
// 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<DisplayField>) {
fields.sort((a, b) => { return a.label.localeCompare(b.label); });
}
private renderFields(fields:Array<DisplayField>) {
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();
}
}

@ -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<ProjectResource> {
return this.halResourceService
.get<ProjectResource>(this.pathHelper.api.v3.projects.id(id).toString())
.get<ProjectResource>(this.projectsPath.id(id).toString())
.toPromise();
}
public schema():Promise<SchemaResource> {
return this.halResourceService
.get<SchemaResource>(this.projectsPath.schema)
.toPromise();
}
private get projectsPath():Apiv3ProjectsPaths {
return this.pathHelper.api.v3.projects;
}
}

@ -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'

@ -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
Loading…
Cancel
Save