Merge pull request #7541 from opf/feature/members_widget_on_dashboard

add members widget to dashboard

[ci skip]
pull/7544/head
Oliver Günther 5 years ago committed by GitHub
commit b66e4ae676
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      app/assets/stylesheets/content/_attributes_key_value.sass
  2. 1
      app/models/queries/projects.rb
  3. 35
      app/models/queries/projects/filters/id_filter.rb
  4. 1
      config/locales/en.yml
  5. 1
      docs/api/apiv3/endpoints/projects.apib
  6. 9
      frontend/src/app/modules/common/path-helper/apiv3/apiv3-paths.ts
  7. 41
      frontend/src/app/modules/common/path-helper/apiv3/groups/apiv3-groups-path.ts
  8. 39
      frontend/src/app/modules/common/path-helper/apiv3/memberships/apiv3-memberships-paths.ts
  9. 4
      frontend/src/app/modules/common/path-helper/path-helper.service.ts
  10. 11
      frontend/src/app/modules/grids/openproject-grids.module.ts
  11. 64
      frontend/src/app/modules/grids/widgets/members/members.component.html
  12. 9
      frontend/src/app/modules/grids/widgets/members/members.component.sass
  13. 139
      frontend/src/app/modules/grids/widgets/members/members.component.ts
  14. 1
      frontend/src/app/modules/grids/widgets/news/news.component.ts
  15. 22
      frontend/src/app/modules/hal/dm-services/abstract-dm.service.ts
  16. 53
      frontend/src/app/modules/hal/dm-services/membership-dm.service.ts
  17. 2
      frontend/src/app/modules/hal/openproject-hal.module.ts
  18. 49
      frontend/src/app/modules/hal/resources/membership-resource.ts
  19. 32
      frontend/src/app/modules/hal/resources/role-resource.ts
  20. 8
      frontend/src/app/modules/hal/services/hal-resource.config.ts
  21. 2
      lib/api/v3/memberships/available_projects_api.rb
  22. 7
      modules/dashboards/lib/dashboards/grid_registration.rb
  23. 148
      modules/dashboards/spec/features/members_spec.rb
  24. 6
      modules/grids/config/locales/js-en.yml

@ -44,6 +44,10 @@
@include text-shortener @include text-shortener
flex: 0 1 auto flex: 0 1 auto
&.-minimal
@include text-shortener
flex: 0 1 auto
.attributes-key-value--value-container .attributes-key-value--value-container
display: flex display: flex
flex: 1 0 65% flex: 1 0 65%

@ -43,6 +43,7 @@ module Queries::Projects
register.filter query, filters::LatestActivityAtFilter register.filter query, filters::LatestActivityAtFilter
register.filter query, filters::PrincipalFilter register.filter query, filters::PrincipalFilter
register.filter query, filters::ParentFilter register.filter query, filters::ParentFilter
register.filter query, filters::IdFilter
register.order query, orders::DefaultOrder register.order query, orders::DefaultOrder
register.order query, orders::LatestActivityAtOrder register.order query, orders::LatestActivityAtOrder

@ -0,0 +1,35 @@
#-- encoding: UTF-8
#-- 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.
#++
class Queries::Projects::Filters::IdFilter < Queries::Projects::Filters::ProjectFilter
def type
:integer
end
end

@ -1404,6 +1404,7 @@ en:
label_member_new: "New member" label_member_new: "New member"
label_member_all_admin: "(All roles due to admin status)" label_member_all_admin: "(All roles due to admin status)"
label_member_plural: "Members" label_member_plural: "Members"
# TODO: remove once overview page is replaced
label_view_all_members: "View all members" label_view_all_members: "View all members"
label_menu_item_name: "Name of menu item" label_menu_item_name: "Name of menu item"
label_message: "Message" label_message: "Message"

@ -225,6 +225,7 @@ Returns a collection of projects. The collection can be filtered via query param
+ parent_id: filters projects by their parent. + parent_id: filters projects by their parent.
+ principal: based on members of the project. + principal: based on members of the project.
+ type_id: based on the types active in a project. + type_id: based on the types active in a project.
+ id: based on projects' id.
There might also be additional filters based on the custom fields that have been configured. There might also be additional filters based on the custom fields that have been configured.

@ -41,6 +41,8 @@ import {Apiv3GridsPaths} from "core-app/modules/common/path-helper/apiv3/grids/a
import {Apiv3NewsesPaths} from "core-app/modules/common/path-helper/apiv3/news/apiv3-newses-paths"; import {Apiv3NewsesPaths} from "core-app/modules/common/path-helper/apiv3/news/apiv3-newses-paths";
import {Apiv3TimeEntriesPaths} from "core-app/modules/common/path-helper/apiv3/time-entries/apiv3-time-entries-paths"; import {Apiv3TimeEntriesPaths} from "core-app/modules/common/path-helper/apiv3/time-entries/apiv3-time-entries-paths";
import {Apiv3VersionPaths} from "core-app/modules/common/path-helper/apiv3/versions/apiv3-version-paths"; import {Apiv3VersionPaths} from "core-app/modules/common/path-helper/apiv3/versions/apiv3-version-paths";
import {Apiv3MembershipsPaths} from "core-app/modules/common/path-helper/apiv3/memberships/apiv3-memberships-paths";
import {Apiv3GroupsPaths} from "core-app/modules/common/path-helper/apiv3/groups/apiv3-groups-path";
export class ApiV3Paths { export class ApiV3Paths {
// Base path // Base path
@ -67,6 +69,9 @@ export class ApiV3Paths {
// /api/v3/time_entries // /api/v3/time_entries
public readonly time_entries = new Apiv3TimeEntriesPaths(this.apiV3Base); public readonly time_entries = new Apiv3TimeEntriesPaths(this.apiV3Base);
// /api/v3/memberships
public readonly memberships = new Apiv3MembershipsPaths(this.apiV3Base);
// /api/v3/news // /api/v3/news
public readonly news = new Apiv3NewsesPaths(this.apiV3Base); public readonly news = new Apiv3NewsesPaths(this.apiV3Base);
@ -94,6 +99,10 @@ export class ApiV3Paths {
// /api/v3/grids // /api/v3/grids
public readonly grids = new Apiv3GridsPaths(this.apiV3Base); public readonly grids = new Apiv3GridsPaths(this.apiV3Base);
// /api/v3/groups
public readonly groups = new Apiv3GroupsPaths(this.apiV3Base);
constructor(readonly appBasePath:string) { constructor(readonly appBasePath:string) {
} }

@ -0,0 +1,41 @@
// -- 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 {
SimpleResourceCollection
} from 'core-app/modules/common/path-helper/apiv3/path-resources';
export class Apiv3GroupsPaths extends SimpleResourceCollection {
constructor(basePath:string) {
super(basePath, 'groups');
}
public toString():string {
throw 'Backend does not yet offer this end point';
}
}

@ -0,0 +1,39 @@
// -- 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 {SimpleResourceCollection} from 'core-app/modules/common/path-helper/apiv3/path-resources';
export class Apiv3MembershipsPaths extends SimpleResourceCollection {
constructor(basePath:string) {
super(basePath, 'memberships');
}
// /api/v3/memberships/available_projects
public readonly available_projects = this.path + '/available_projects';
}

@ -102,6 +102,10 @@ export class PathHelperService {
return this.projectPath(projectId) + '/work_packages/calendar'; return this.projectPath(projectId) + '/work_packages/calendar';
} }
public projectMembershipsPath(projectId:string) {
return this.projectPath(projectId) + '/members';
}
public projectNewsPath(projectId:string) { public projectNewsPath(projectId:string) {
return this.projectPath(projectId) + '/news'; return this.projectPath(projectId) + '/news';
} }

@ -65,6 +65,7 @@ import {WidgetProjectDetailsComponent} from "core-app/modules/grids/widgets/proj
import {WidgetTimeEntriesProjectComponent} from "core-app/modules/grids/widgets/time-entries-current-user/project/time-entries-project.component"; import {WidgetTimeEntriesProjectComponent} from "core-app/modules/grids/widgets/time-entries-current-user/project/time-entries-project.component";
import {WidgetSubprojectsComponent} from "core-app/modules/grids/widgets/subprojects/subprojects.component"; import {WidgetSubprojectsComponent} from "core-app/modules/grids/widgets/subprojects/subprojects.component";
import {OpenprojectAttachmentsModule} from "core-app/modules/attachments/openproject-attachments.module"; import {OpenprojectAttachmentsModule} from "core-app/modules/attachments/openproject-attachments.module";
import {WidgetMembersComponent} from "core-app/modules/grids/widgets/members/members.component";
export const GRID_ROUTES:Ng2StateDeclaration[] = [ export const GRID_ROUTES:Ng2StateDeclaration[] = [
{ {
@ -95,6 +96,7 @@ export const GRID_ROUTES:Ng2StateDeclaration[] = [
DynamicModule.withComponents([ DynamicModule.withComponents([
WidgetCustomTextComponent, WidgetCustomTextComponent,
WidgetDocumentsComponent, WidgetDocumentsComponent,
WidgetMembersComponent,
WidgetNewsComponent, WidgetNewsComponent,
WidgetWpTableQuerySpaceComponent, WidgetWpTableQuerySpaceComponent,
WidgetWpGraphComponent, WidgetWpGraphComponent,
@ -128,6 +130,7 @@ export const GRID_ROUTES:Ng2StateDeclaration[] = [
// Widgets // Widgets
WidgetCustomTextComponent, WidgetCustomTextComponent,
WidgetDocumentsComponent, WidgetDocumentsComponent,
WidgetMembersComponent,
WidgetNewsComponent, WidgetNewsComponent,
WidgetWpCalendarComponent, WidgetWpCalendarComponent,
WidgetWpOverviewComponent, WidgetWpOverviewComponent,
@ -298,6 +301,14 @@ export function registerWidgets(injector:Injector) {
name: i18n.t('js.grid.widgets.documents.title') name: i18n.t('js.grid.widgets.documents.title')
} }
}, },
{
identifier: 'members',
component: WidgetMembersComponent,
title: i18n.t(`js.grid.widgets.members.title`),
properties: {
name: i18n.t('js.grid.widgets.members.title')
}
},
{ {
identifier: 'news', identifier: 'news',
component: WidgetNewsComponent, component: WidgetNewsComponent,

@ -0,0 +1,64 @@
<widget-header
[name]="widgetName"
[icon]="'group'"
(onRenamed)="renameWidget($event)">
<widget-menu
[resource]="resource">
</widget-menu>
</widget-header>
<div class="grid--widget-content">
<no-results *ngIf="noMembers"
[title]="text.noResults">
</no-results>
<div class="attributes-key-value" *ngFor="let usersByRole of usersByRole">
<div class="attributes-key-value--key -minimal">
{{usersByRole.role.name}}:
</div>
<div class="attributes-key-value--value-container">
<div class="attributes-key-value--value">
<span *ngFor="let principal of usersByRole.users; let last = last">
<ng-container *ngIf="isGroup(principal)">
{{userName(principal)}}
</ng-container>
<ng-container *ngIf="!isGroup(principal)">
<user-avatar [user]="principal"
data-class-list="avatar avatar-mini -spaced">
</user-avatar>
<a [href]="userPath(principal)"
[textContent]="userName(principal)">
</a>
</ng-container>
<ng-container *ngIf="!last">, </ng-container>
</span>
</div>
</div>
</div>
<div *ngIf="moreMembers"
class="members-widget--notification">
{{moreMembersText}}
</div>
</div>
<div class="grid--widget-footer">
<a *ngIf="membersAddable"
[href]="projectMembershipsPath + '?show_add_members=true'"
class="button -alt-highlight">
<i class="button--icon icon-add" aria-hidden="true"></i>
<span class="button--text"
[textContent]="text.add">
</span>
</a>
<a *ngIf="!noMembers"
[href]="projectMembershipsPath"
class="button -highlight-inverted">
<i class="button--icon icon-group" aria-hidden="true"></i>
<span class="button--text"
[textContent]="text.viewAll">
</span>
</a>
</div>

@ -0,0 +1,9 @@
.members-widget--notification
font-style: italic
margin-top: 0.5rem
.grid--widget-footer
margin-top: 0.5rem
.button
margin-bottom: 0

@ -0,0 +1,139 @@
import {AbstractWidgetComponent} from "core-app/modules/grids/widgets/abstract-widget.component";
import {Component, OnInit, ChangeDetectorRef, Injector, ChangeDetectionStrategy} from '@angular/core';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {UserResource} from "core-app/modules/hal/resources/user-resource";
import {CurrentProjectService} from "core-components/projects/current-project.service";
import {DmListParameter} from "core-app/modules/hal/dm-services/dm.service.interface";
import {MembershipResource} from "core-app/modules/hal/resources/membership-resource";
import {MembershipDmService} from "core-app/modules/hal/dm-services/membership-dm.service";
import {RoleResource} from "core-app/modules/hal/resources/role-resource";
const DISPLAYED_MEMBERS_LIMIT = 100;
@Component({
templateUrl: './members.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./members.component.sass']
})
export class WidgetMembersComponent extends AbstractWidgetComponent implements OnInit {
public text = {
add: this.i18n.t('js.grid.widgets.members.add'),
noResults: this.i18n.t('js.grid.widgets.members.no_results'),
viewAll: this.i18n.t('js.grid.widgets.members.view_all_members'),
};
public totalMembers:number;
public entriesByRoles:{[roleId:string]:{role:RoleResource, users:UserResource[]}} = {};
private entriesLoaded = false;
public membersAddable:boolean = false;
constructor(readonly pathHelper:PathHelperService,
readonly i18n:I18nService,
protected readonly injector:Injector,
readonly membershipDm:MembershipDmService,
readonly currentProject:CurrentProjectService,
readonly cdr:ChangeDetectorRef) {
super(i18n, injector);
}
ngOnInit() {
this.membershipDm
.list(this.listMembersParams)
.then(collection => {
this.partitionEntriesByRole(collection.elements);
this.sortUsersByName();
this.totalMembers = collection.total;
this.entriesLoaded = true;
this.cdr.detectChanges();
});
this.membershipDm
.listAvailableProjects(this.listAvailableProjectsParams)
.then(collection => {
this.membersAddable = collection.total > 0;
})
.catch(() => {
// nothing bad, the user is just not allowed to add members to the project
});
}
public userPath(user:UserResource) {
return this.pathHelper.userPath(user.id!);
}
public userName(user:UserResource) {
return user.name;
}
public get noMembers() {
return this.entriesLoaded && !Object.keys(this.entriesByRoles).length;
}
public get moreMembers() {
return this.entriesLoaded && this.totalMembers > DISPLAYED_MEMBERS_LIMIT;
}
public get moreMembersText() {
return I18n.t(
'js.grid.widgets.members.too_many',
{ count: DISPLAYED_MEMBERS_LIMIT, total: this.totalMembers }
);
}
public get projectMembershipsPath() {
return this.pathHelper.projectMembershipsPath(this.currentProject.identifier!);
}
public get usersByRole() {
return Object.values(this.entriesByRoles);
}
public isGroup(principal:UserResource) {
return this.pathHelper.api.v3.groups.id(principal.id!).toString() === principal.href;
}
private partitionEntriesByRole(memberships:MembershipResource[]) {
memberships.forEach(membership => {
membership.roles.forEach((role) => {
if (!this.entriesByRoles[role.id!]) {
this.entriesByRoles[role.id!] = { role: role, users: [] };
}
this.entriesByRoles[role.id!].users.push(membership.principal);
});
});
}
private sortUsersByName() {
Object.values(this.entriesByRoles).forEach(entry => {
entry.users.sort((a, b) => {
return this.userName(a).localeCompare(this.userName(b));
});
});
}
private get listMembersParams() {
let params:DmListParameter = { sortBy: [['created_on', 'desc']], pageSize: DISPLAYED_MEMBERS_LIMIT };
if (this.currentProject.id) {
params['filters'] = [['project_id', '=', [this.currentProject.id]]];
}
return params;
}
private get listAvailableProjectsParams() {
// It would make sense to set the pageSize but the backend for projects
// returns an upaginated list which does not support that.
let params:DmListParameter = {};
if (this.currentProject.id) {
params['filters'] = [['id', '=', [this.currentProject.id]]];
}
return params;
}
}

@ -9,7 +9,6 @@ import {UserCacheService} from "core-components/user/user-cache.service";
import {UserResource} from "core-app/modules/hal/resources/user-resource"; import {UserResource} from "core-app/modules/hal/resources/user-resource";
import {NewsDmService} from "core-app/modules/hal/dm-services/news-dm.service"; import {NewsDmService} from "core-app/modules/hal/dm-services/news-dm.service";
import {CurrentProjectService} from "core-components/projects/current-project.service"; import {CurrentProjectService} from "core-components/projects/current-project.service";
import {FilterOperator} from "core-components/api/api-v3/api-v3-filter-builder";
import {DmListParameter} from "core-app/modules/hal/dm-services/dm.service.interface"; import {DmListParameter} from "core-app/modules/hal/dm-services/dm.service.interface";
@Component({ @Component({

@ -42,13 +42,27 @@ export abstract class AbstractDmService<T extends HalResource> implements DmServ
} }
public list(params:DmListParameter|null):Promise<CollectionResource<T>> { public list(params:DmListParameter|null):Promise<CollectionResource<T>> {
return this.listRequest(this.listUrl(), params) as Promise<CollectionResource<T>>;
}
public one(id:number):Promise<T> {
return this.halResourceService.get<T>(this.oneUrl(id).toString()).toPromise();
}
protected listRequest(url:string, params:DmListParameter|null) {
return this.halResourceService.get(url + this.listParamsString(params)).toPromise();
}
protected listParamsString(params:DmListParameter|null):string {
let queryProps = []; let queryProps = [];
if (params && params.sortBy) { if (params && params.sortBy) {
queryProps.push(`sortBy=${JSON.stringify(params.sortBy)}`); queryProps.push(`sortBy=${JSON.stringify(params.sortBy)}`);
} }
if (params && params.pageSize) { // 0 should not be treated as false
if (params && params.pageSize !== undefined) {
queryProps.push(`pageSize=${params.pageSize}`); queryProps.push(`pageSize=${params.pageSize}`);
} }
@ -68,11 +82,7 @@ export abstract class AbstractDmService<T extends HalResource> implements DmServ
queryPropsString = `?${queryProps.join('&')}`; queryPropsString = `?${queryProps.join('&')}`;
} }
return this.halResourceService.get<CollectionResource<T>>(this.listUrl() + queryPropsString).toPromise(); return queryPropsString;
}
public one(id:number):Promise<T> {
return this.halResourceService.get<T>(this.oneUrl(id).toString()).toPromise();
} }

@ -0,0 +1,53 @@
//-- 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 {Injectable} from '@angular/core';
import {AbstractDmService} from "core-app/modules/hal/dm-services/abstract-dm.service";
import {MembershipResource} from "core-app/modules/hal/resources/membership-resource";
import {DmListParameter} from "core-app/modules/hal/dm-services/dm.service.interface";
import {CollectionResource} from "core-app/modules/hal/resources/collection-resource";
import {ProjectResource} from "core-app/modules/hal/resources/project-resource";
@Injectable()
export class MembershipDmService extends AbstractDmService<MembershipResource> {
public listAvailableProjects(params:DmListParameter|null) {
return this.listRequest(this.availableProjectsUrl(), params) as Promise<CollectionResource<ProjectResource>>;
}
protected listUrl() {
return this.pathHelper.api.v3.memberships.toString();
}
protected oneUrl(id:number|string) {
return this.pathHelper.api.v3.memberships.id(id).toString();
}
protected availableProjectsUrl() {
return this.pathHelper.api.v3.memberships.available_projects.toString();
}
}

@ -52,6 +52,7 @@ import {NewsDmService} from './dm-services/news-dm.service';
import {StatusDmService} from "core-app/modules/hal/dm-services/status-dm.service"; import {StatusDmService} from "core-app/modules/hal/dm-services/status-dm.service";
import {VersionDmService} from "core-app/modules/hal/dm-services/version-dm.service"; import {VersionDmService} from "core-app/modules/hal/dm-services/version-dm.service";
import {QueryOrderDmService} from "core-app/modules/hal/dm-services/query-order-dm.service"; import {QueryOrderDmService} from "core-app/modules/hal/dm-services/query-order-dm.service";
import {MembershipDmService} from "core-app/modules/hal/dm-services/membership-dm.service";
@NgModule({ @NgModule({
imports: [ imports: [
@ -67,6 +68,7 @@ import {QueryOrderDmService} from "core-app/modules/hal/dm-services/query-order-
ConfigurationDmService, ConfigurationDmService,
GridDmService, GridDmService,
HelpTextDmService, HelpTextDmService,
MembershipDmService,
NewsDmService, NewsDmService,
PayloadDmService, PayloadDmService,
ProjectDmService, ProjectDmService,

@ -0,0 +1,49 @@
//-- 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 {HalResource} from 'core-app/modules/hal/resources/hal-resource';
import {UserResource} from "core-app/modules/hal/resources/user-resource";
import {RoleResource} from "core-app/modules/hal/resources/role-resource";
import {ProjectResource} from "core-app/modules/hal/resources/project-resource";
export interface MembershipResourceLinks {
update(payload:unknown):Promise<unknown>;
updateImmediately(payload:unknown):Promise<unknown>;
delete():Promise<unknown>;
}
export interface MembershipResourceEmbedded {
principal:UserResource;
roles:RoleResource[];
project:ProjectResource;
}
export class MembershipResource extends HalResource {
}
export interface MembershipResource extends MembershipResourceLinks, MembershipResourceEmbedded {}

@ -0,0 +1,32 @@
//-- 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 {HalResource} from 'core-app/modules/hal/resources/hal-resource';
export class RoleResource extends HalResource {
}

@ -59,6 +59,8 @@ import {GridResource} from "core-app/modules/hal/resources/grid-resource";
import {TimeEntryResource} from "core-app/modules/hal/resources/time-entry-resource"; import {TimeEntryResource} from "core-app/modules/hal/resources/time-entry-resource";
import {NewsResource} from "core-app/modules/hal/resources/news-resource"; import {NewsResource} from "core-app/modules/hal/resources/news-resource";
import {VersionResource} from "core-app/modules/hal/resources/version-resource"; import {VersionResource} from "core-app/modules/hal/resources/version-resource";
import {MembershipResource} from "core-app/modules/hal/resources/membership-resource";
import {RoleResource} from "core-app/modules/hal/resources/role-resource";
const halResourceDefaultConfig:{ [typeName:string]:HalResourceFactoryConfigInterface } = { const halResourceDefaultConfig:{ [typeName:string]:HalResourceFactoryConfigInterface } = {
WorkPackage: { WorkPackage: {
@ -166,6 +168,9 @@ const halResourceDefaultConfig:{ [typeName:string]:HalResourceFactoryConfigInter
Post: { Post: {
cls: PostResource cls: PostResource
}, },
Role: {
cls: RoleResource
},
Grid: { Grid: {
cls: GridResource, cls: GridResource,
}, },
@ -175,6 +180,9 @@ const halResourceDefaultConfig:{ [typeName:string]:HalResourceFactoryConfigInter
TimeEntry: { TimeEntry: {
cls: TimeEntryResource cls: TimeEntryResource
}, },
Membership: {
cls: MembershipResource
},
News: { News: {
cls: NewsResource cls: NewsResource
}, },

@ -30,7 +30,7 @@ module API
module V3 module V3
module Memberships module Memberships
class AvailableProjectsAPI < ::API::OpenProjectAPI class AvailableProjectsAPI < ::API::OpenProjectAPI
before do after_validation do
authorize :manage_members, global: true authorize :manage_members, global: true
end end

@ -11,6 +11,7 @@ module Dashboards
'work_packages_calendar', 'work_packages_calendar',
'work_packages_overview', 'work_packages_overview',
'time_entries_project', 'time_entries_project',
'members',
'news', 'news',
'documents', 'documents',
'custom_text' 'custom_text'
@ -56,6 +57,12 @@ module Dashboards
allowed view_work_packages_lambda allowed view_work_packages_lambda
end end
widget_strategy 'members' do
allowed ->(user, project) {
user.allowed_to?(:view_members, project)
}
end
widget_strategy 'news' do widget_strategy 'news' do
allowed ->(user, project) { allowed ->(user, project) {
user.allowed_to?(:view_news, project) user.allowed_to?(:view_news, project)

@ -0,0 +1,148 @@
#-- 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 'Members widget on dashboard', type: :feature, js: true do
let!(:project) { FactoryBot.create :project }
let!(:other_project) { FactoryBot.create :project }
let!(:manager_user) do
FactoryBot.create :user, lastname: "Manager", member_in_project: project, member_through_role: role
end
let!(:no_edit_member_user) do
FactoryBot.create :user, lastname: "No_Edit", member_in_project: project, member_through_role: no_edit_member_role
end
let!(:no_view_member_user) do
FactoryBot.create :user, lastname: "No_View", member_in_project: project, member_through_role: no_view_member_role
end
let!(:invisible_user) do
FactoryBot.create :user, lastname: "Invisible", member_in_project: other_project, member_through_role: role
end
let(:no_view_member_role) do
FactoryBot.create(:role,
permissions: %i[manage_dashboards
view_dashboards])
end
let(:no_edit_member_role) do
FactoryBot.create(:role,
permissions: %i[manage_dashboards
view_dashboards
view_members])
end
let(:role) do
FactoryBot.create(:role,
permissions: %i[manage_dashboards
view_dashboards
manage_members
view_members])
end
let(:dashboard) do
Pages::Dashboard.new(project)
end
before do
login_as manager_user
dashboard.visit!
end
def expect_all_members_visible(area)
within area do
expect(page)
.to have_content role.name
expect(page)
.to have_content manager_user.name
expect(page)
.to have_content no_edit_member_role
expect(page)
.to have_content no_edit_member_user.name
expect(page)
.to have_content no_view_member_role
expect(page)
.to have_content no_view_member_user.name
end
end
it 'can add the widget and see the members if the permissions suffice' do
# within top-right area, add an additional widget
dashboard.add_widget(1, 3, 'Members')
members_area = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(1)')
members_area.expect_to_span(1, 3, 4, 5)
members_area.resize_to(7, 4)
expect_all_members_visible(members_area.area)
expect(page)
.not_to have_content invisible_user.name
within members_area.area do
expect(page)
.to have_link('Member')
end
# A user without edit permission will see the members but cannot add one
login_as no_edit_member_user
visit root_path
dashboard.visit!
expect_all_members_visible(members_area.area)
within members_area.area do
expect(page)
.to have_no_link('Member')
end
# A user without view permission will not see any members
login_as no_view_member_user
visit root_path
dashboard.visit!
within members_area.area do
expect(page)
.to have_no_content manager_user.name
expect(page)
.to have_content('No visible members')
expect(page)
.to have_no_link('Member')
expect(page)
.to have_no_link('View all members')
end
end
end

@ -10,6 +10,12 @@ en:
documents: documents:
title: 'Documents' title: 'Documents'
no_results: 'No documents yet.' no_results: 'No documents yet.'
members:
title: 'Members'
no_results: 'No visible members.'
view_all_members: 'View all members'
add: 'Member'
too_many: 'Displaying %{count} of %{total} members.'
news: news:
title: 'News' title: 'News'
at: 'at' at: 'at'

Loading…
Cancel
Save