Merge pull request #9581 from opf/feature/38520-Sidebar-in-Notification-Center-with-project-filter
[38520] Sidebar in Notification Center with project filterpull/9714/head
@ -0,0 +1,40 @@ |
||||
<%#-- copyright |
||||
OpenProject is an open source project management software. |
||||
Copyright (C) 2012-2021 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-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 docs/COPYRIGHT.rdoc for more details. |
||||
|
||||
++#%> |
||||
|
||||
<%= content_for :header_tags do %> |
||||
<%= nonced_javascript_tag do %> |
||||
<%= include_gon(need_tag: false) -%> |
||||
<% end %> |
||||
<% end -%> |
||||
|
||||
<%= content_for :content_body do %> |
||||
<openproject-base></openproject-base> |
||||
<% end -%> |
||||
|
||||
<%= render template: "layouts/base", locals: local_assigns.merge({ menu_name: :notifications_menu }) %> |
@ -0,0 +1 @@ |
||||
<op-ian-menu></op-ian-menu> |
@ -0,0 +1,19 @@ |
||||
export interface HalResourceLink { |
||||
href:string; |
||||
title:string; |
||||
} |
||||
|
||||
/* |
||||
* TODO: This typing is not perfect, since overriding with more specific keys that are optional |
||||
* e.g. `project?:HalResourceLink;` is not possible. This would result in a possible undefined type. |
||||
* I'm not sure how to fix this, but it works for now. |
||||
*/ |
||||
export type HalResourceLinks = Record<string, HalResourceLink|HalResourceLink[]>; |
||||
|
||||
export type FormattableFormat = 'markdown'|'custom'; |
||||
|
||||
export interface Formattable { |
||||
format:FormattableFormat; |
||||
raw:string; |
||||
html:string; |
||||
} |
@ -0,0 +1,32 @@ |
||||
import { ID } from '@datorama/akita'; |
||||
import { |
||||
HalResourceLink, |
||||
HalResourceLinks, |
||||
Formattable, |
||||
} from 'core-app/core/state/hal-resource'; |
||||
|
||||
export const PROJECTS_MAX_SIZE = 100; |
||||
|
||||
export interface ProjectHalResourceLinks extends HalResourceLinks { |
||||
categories:HalResourceLink; |
||||
delete:HalResourceLink; |
||||
parent:HalResourceLink; |
||||
self:HalResourceLink; |
||||
status:HalResourceLink; |
||||
schema:HalResourceLink; |
||||
} |
||||
|
||||
export interface Project { |
||||
id:ID; |
||||
identifier:string; |
||||
name:string; |
||||
public:boolean; |
||||
active:boolean; |
||||
statusExplanation:Formattable; |
||||
description:Formattable; |
||||
|
||||
createdAt:string; |
||||
updatedAt:string; |
||||
|
||||
_links:ProjectHalResourceLinks; |
||||
} |
@ -0,0 +1,5 @@ |
||||
import { QueryEntity } from '@datorama/akita'; |
||||
import { ProjectsState } from './projects.store'; |
||||
|
||||
export class ProjectsQuery extends QueryEntity<ProjectsState> { |
||||
} |
@ -0,0 +1,88 @@ |
||||
import { Injectable } from '@angular/core'; |
||||
import { |
||||
catchError, |
||||
tap, |
||||
} from 'rxjs/operators'; |
||||
import { Observable } from 'rxjs'; |
||||
import { |
||||
applyTransaction, |
||||
ID, |
||||
} from '@datorama/akita'; |
||||
import { HttpClient } from '@angular/common/http'; |
||||
import { APIV3Service } from 'core-app/core/apiv3/api-v3.service'; |
||||
import { NotificationsService } from 'core-app/shared/components/notifications/notifications.service'; |
||||
import { IHALCollection } from 'core-app/core/apiv3/types/hal-collection.type'; |
||||
import { ProjectsQuery } from 'core-app/core/state/projects/projects.query'; |
||||
import { Apiv3ListParameters } from 'core-app/core/apiv3/paths/apiv3-list-resource.interface'; |
||||
import { collectionKey } from 'core-app/core/state/collection-store'; |
||||
import { ProjectsStore } from './projects.store'; |
||||
import { Project } from './project.model'; |
||||
|
||||
@Injectable() |
||||
export class ProjectsResourceService { |
||||
protected store = new ProjectsStore(); |
||||
|
||||
readonly query = new ProjectsQuery(this.store); |
||||
|
||||
private get projectsPath():string { |
||||
return this |
||||
.apiV3Service |
||||
.projects |
||||
.path; |
||||
} |
||||
|
||||
constructor( |
||||
private http:HttpClient, |
||||
private apiV3Service:APIV3Service, |
||||
private notifications:NotificationsService, |
||||
) { |
||||
} |
||||
|
||||
fetchProjects(params:Apiv3ListParameters):Observable<IHALCollection<Project>> { |
||||
const collectionURL = collectionKey(params); |
||||
|
||||
return this |
||||
.http |
||||
.get<IHALCollection<Project>>(this.projectsPath + collectionURL) |
||||
.pipe( |
||||
tap((events) => { |
||||
applyTransaction(() => { |
||||
this.store.add(events._embedded.elements); |
||||
this.store.update(({ collections }) => ( |
||||
{ |
||||
collections: { |
||||
...collections, |
||||
[collectionURL]: { |
||||
ids: events._embedded.elements.map((el) => el.id), |
||||
}, |
||||
}, |
||||
} |
||||
)); |
||||
}); |
||||
}), |
||||
catchError((error) => { |
||||
this.notifications.addError(error); |
||||
throw error; |
||||
}), |
||||
); |
||||
} |
||||
|
||||
update(id:ID, project:Partial<Project>):void { |
||||
this.store.update(id, project); |
||||
} |
||||
|
||||
modifyCollection(params:Apiv3ListParameters, callback:(collection:ID[]) => ID[]):void { |
||||
const key = collectionKey(params); |
||||
this.store.update(({ collections }) => ( |
||||
{ |
||||
collections: { |
||||
...collections, |
||||
[key]: { |
||||
...collections[key], |
||||
ids: [...callback(collections[key]?.ids || [])], |
||||
}, |
||||
}, |
||||
} |
||||
)); |
||||
} |
||||
} |
@ -0,0 +1,13 @@ |
||||
import { EntityStore, StoreConfig } from '@datorama/akita'; |
||||
import { CollectionState, createInitialCollectionState } from 'core-app/core/state/collection-store'; |
||||
import { Project } from './project.model'; |
||||
|
||||
export interface ProjectsState extends CollectionState<Project> { |
||||
} |
||||
|
||||
@StoreConfig({ name: 'projects' }) |
||||
export class ProjectsStore extends EntityStore<ProjectsState> { |
||||
constructor() { |
||||
super(createInitialCollectionState()); |
||||
} |
||||
} |
@ -0,0 +1,4 @@ |
||||
<op-sidemenu |
||||
[items]="menuItems$ | async" |
||||
data-qa-selector="op-sidemenu" |
||||
></op-sidemenu> |
@ -0,0 +1,21 @@ |
||||
.op-ian-center |
||||
display: grid |
||||
grid-template-rows: 1fr auto |
||||
height: 100% |
||||
|
||||
&--content |
||||
height: 100% |
||||
|
||||
&--viewport |
||||
height: 100% |
||||
|
||||
&--max-warning |
||||
margin-bottom: 0 |
||||
text-align: center |
||||
font-style: italic |
||||
|
||||
:host |
||||
.-browser-safari & |
||||
// Because of Safari's viewport bug, the address bar overlaps content with height: 100vh |
||||
// Check #38082 before changing it |
||||
height: 100% |
@ -0,0 +1,129 @@ |
||||
import { |
||||
ChangeDetectionStrategy, |
||||
ChangeDetectorRef, |
||||
Component, |
||||
OnInit, |
||||
} from '@angular/core'; |
||||
import { combineLatest } from 'rxjs'; |
||||
import { map } from 'rxjs/operators'; |
||||
import { StateService } from '@uirouter/core'; |
||||
import idFromLink from 'core-app/features/hal/helpers/id-from-link'; |
||||
import { I18nService } from 'core-app/core/i18n/i18n.service'; |
||||
import { INotificationPageQueryParameters } from '../../in-app-notifications.routes'; |
||||
import { IanMenuService } from './state/ian-menu.service'; |
||||
|
||||
export const ianMenuSelector = 'op-ian-menu'; |
||||
|
||||
@Component({ |
||||
selector: ianMenuSelector, |
||||
templateUrl: './menu.component.html', |
||||
styleUrls: ['./menu.component.sass'], |
||||
providers: [IanMenuService], |
||||
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
}) |
||||
export class IanMenuComponent implements OnInit { |
||||
baseMenuItems = [ |
||||
{ |
||||
key: 'inbox', |
||||
title: this.I18n.t('js.notifications.menu.inbox'), |
||||
icon: 'inbox', |
||||
href: this.getHrefForFilters({}), |
||||
}, |
||||
]; |
||||
|
||||
reasonMenuItems = [ |
||||
{ |
||||
key: 'mentioned', |
||||
title: this.I18n.t('js.notifications.menu.mentioned'), |
||||
icon: 'mention', |
||||
href: this.getHrefForFilters({ filter: 'reason', name: 'mentioned' }), |
||||
}, |
||||
{ |
||||
key: 'assigned', |
||||
title: this.I18n.t('js.notifications.menu.assigned'), |
||||
icon: 'assigned', |
||||
href: this.getHrefForFilters({ filter: 'reason', name: 'assigned' }), |
||||
}, |
||||
{ |
||||
key: 'responsible', |
||||
title: this.I18n.t('js.notifications.menu.accountable'), |
||||
icon: 'accountable', |
||||
href: this.getHrefForFilters({ filter: 'reason', name: 'responsible' }), |
||||
}, |
||||
{ |
||||
key: 'watched', |
||||
title: this.I18n.t('js.notifications.menu.watching'), |
||||
icon: 'watching', |
||||
href: this.getHrefForFilters({ filter: 'reason', name: 'watched' }), |
||||
}, |
||||
]; |
||||
|
||||
notificationsByProject$ = this.ianMenuService.query.notificationsByProject$.pipe( |
||||
map((items) => items |
||||
.map((item) => ({ |
||||
...item, |
||||
title: (item.projectHasParent ? '... ' : '') + item.value, |
||||
href: this.getHrefForFilters({ filter: 'project', name: idFromLink(item._links.valueLink[0].href) }), |
||||
})) |
||||
.sort((a, b) => { |
||||
if (b.projectHasParent && !a.projectHasParent) { |
||||
return -1; |
||||
} |
||||
|
||||
return a.value.toLowerCase().localeCompare(b.value.toLowerCase()); |
||||
})), |
||||
); |
||||
|
||||
notificationsByReason$ = this.ianMenuService.query.notificationsByReason$.pipe( |
||||
map((items) => this.reasonMenuItems.map((reason) => ({ |
||||
...items.find((item) => item.value === reason.key), |
||||
...reason, |
||||
}))), |
||||
); |
||||
|
||||
menuItems$ = combineLatest([ |
||||
this.notificationsByProject$, |
||||
this.notificationsByReason$, |
||||
]).pipe( |
||||
map(([byProject, byReason]) => [ |
||||
...this.baseMenuItems.map((baseMenuItem) => ({ |
||||
...baseMenuItem, |
||||
count: byReason.reduce((a, b) => a + (b.count || 0), 0), |
||||
})), |
||||
{ |
||||
title: this.I18n.t('js.notifications.menu.by_reason'), |
||||
collapsible: true, |
||||
children: byReason, |
||||
}, |
||||
{ |
||||
title: this.I18n.t('js.notifications.menu.by_project'), |
||||
collapsible: true, |
||||
children: byProject, |
||||
}, |
||||
]), |
||||
); |
||||
|
||||
text = { |
||||
title: this.I18n.t('js.notifications.title'), |
||||
button_close: this.I18n.t('js.button_close'), |
||||
no_results: { |
||||
unread: this.I18n.t('js.notifications.no_unread'), |
||||
all: this.I18n.t('js.notice_no_results_to_display'), |
||||
}, |
||||
}; |
||||
|
||||
constructor( |
||||
readonly cdRef:ChangeDetectorRef, |
||||
readonly I18n:I18nService, |
||||
readonly ianMenuService:IanMenuService, |
||||
readonly state:StateService, |
||||
) { } |
||||
|
||||
ngOnInit():void { |
||||
this.ianMenuService.reload(); |
||||
} |
||||
|
||||
private getHrefForFilters(filters:INotificationPageQueryParameters = {}) { |
||||
return this.state.href('notifications', filters); |
||||
} |
||||
} |
@ -0,0 +1,54 @@ |
||||
import { Query } from '@datorama/akita'; |
||||
import { combineLatest } from 'rxjs'; |
||||
import { map } from 'rxjs/operators'; |
||||
import idFromLink from 'core-app/features/hal/helpers/id-from-link'; |
||||
import { InAppNotificationsResourceService } from 'core-app/core/state/in-app-notifications/in-app-notifications.service'; |
||||
import { ProjectsResourceService } from 'core-app/core/state/projects/projects.service'; |
||||
import { Project } from 'core-app/core/state/projects/project.model'; |
||||
import { collectionKey } from 'core-app/core/state/collection-store'; |
||||
import { |
||||
IanMenuState, |
||||
IanMenuStore, |
||||
} from './ian-menu.store'; |
||||
|
||||
export class IanMenuQuery extends Query<IanMenuState> { |
||||
projectsFilter$ = this.select('projectsFilter'); |
||||
|
||||
projectsForNotifications$ = combineLatest([ |
||||
this.projectsFilter$, |
||||
this.projectsResourceService.query.select(), |
||||
]).pipe( |
||||
map(([filterParams, collectionData]) => { |
||||
const key = collectionKey(filterParams); |
||||
const collection = collectionData.collections[key]; |
||||
const ids = collection?.ids || []; |
||||
|
||||
return ids |
||||
.map((id:string) => this.projectsResourceService.query.getEntity(id)) |
||||
.filter((item:Project|undefined) => !!item) as Project[]; |
||||
}), |
||||
); |
||||
|
||||
notificationsByProject$ = combineLatest([ |
||||
this.select('notificationsByProject'), |
||||
this.projectsForNotifications$, |
||||
]).pipe( |
||||
map(([notifications, projects]) => notifications.map((notification) => { |
||||
const project = projects.find((p) => p.id.toString() === idFromLink(notification._links.valueLink[0].href)); |
||||
return { |
||||
...notification, |
||||
projectHasParent: !!project?._links.parent.href, |
||||
}; |
||||
})), |
||||
); |
||||
|
||||
notificationsByReason$ = this.select('notificationsByReason'); |
||||
|
||||
constructor( |
||||
protected store:IanMenuStore, |
||||
protected resourceService:InAppNotificationsResourceService, |
||||
protected projectsResourceService:ProjectsResourceService, |
||||
) { |
||||
super(store); |
||||
} |
||||
} |
@ -0,0 +1,75 @@ |
||||
import { |
||||
Injectable, |
||||
Injector, |
||||
} from '@angular/core'; |
||||
import { notificationsMarkedRead } from 'core-app/core/state/in-app-notifications/in-app-notifications.actions'; |
||||
import { |
||||
EffectCallback, |
||||
EffectHandler, |
||||
} from 'core-app/core/state/effects/effect-handler.decorator'; |
||||
import { ActionsService } from 'core-app/core/state/actions/actions.service'; |
||||
import { APIV3Service } from 'core-app/core/apiv3/api-v3.service'; |
||||
import { Apiv3ListParameters } from 'core-app/core/apiv3/paths/apiv3-list-resource.interface'; |
||||
import idFromLink from 'core-app/features/hal/helpers/id-from-link'; |
||||
import { InAppNotificationsResourceService } from 'core-app/core/state/in-app-notifications/in-app-notifications.service'; |
||||
import { ProjectsResourceService } from 'core-app/core/state/projects/projects.service'; |
||||
import { IanMenuQuery } from './ian-menu.query'; |
||||
import { |
||||
IanMenuStore, |
||||
IAN_MENU_PROJECT_FILTERS, |
||||
IAN_MENU_REASON_FILTERS, |
||||
} from './ian-menu.store'; |
||||
|
||||
@Injectable() |
||||
@EffectHandler |
||||
export class IanMenuService { |
||||
readonly id = 'ian-center'; |
||||
|
||||
readonly store = new IanMenuStore(); |
||||
|
||||
readonly query = new IanMenuQuery(this.store, this.ianResourceService, this.projectsResourceService); |
||||
|
||||
constructor( |
||||
readonly injector:Injector, |
||||
readonly ianResourceService:InAppNotificationsResourceService, |
||||
readonly projectsResourceService:ProjectsResourceService, |
||||
readonly actions$:ActionsService, |
||||
readonly apiV3Service:APIV3Service, |
||||
) { |
||||
} |
||||
|
||||
/** |
||||
* Reload after notifications were successfully marked as read |
||||
*/ |
||||
@EffectCallback(notificationsMarkedRead) |
||||
private reloadOnNotificationRead() { |
||||
return this.reload(); |
||||
} |
||||
|
||||
public reload():void { |
||||
this.ianResourceService.fetchNotifications(IAN_MENU_PROJECT_FILTERS) |
||||
.subscribe((data) => { |
||||
const projectsFilter:Apiv3ListParameters = { |
||||
pageSize: 100, |
||||
filters: [], |
||||
}; |
||||
|
||||
if (data.groups) { |
||||
projectsFilter.filters = [['id', '=', data.groups.map((group) => idFromLink(group._links.valueLink[0].href))]]; |
||||
} |
||||
|
||||
this.store.update({ |
||||
notificationsByProject: data.groups, |
||||
projectsFilter, |
||||
}); |
||||
|
||||
// Only request if there are any groups
|
||||
if (data.groups && data.groups.length > 0) { |
||||
this.projectsResourceService.fetchProjects(projectsFilter).subscribe(); |
||||
} |
||||
}); |
||||
this.ianResourceService.fetchNotifications(IAN_MENU_REASON_FILTERS) |
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
.subscribe((data) => this.store.update({ notificationsByReason: data.groups })); |
||||
} |
||||
} |
@ -0,0 +1,51 @@ |
||||
import { Store, StoreConfig } from '@datorama/akita'; |
||||
import { Apiv3ListParameters } from 'core-app/core/apiv3/paths/apiv3-list-resource.interface'; |
||||
|
||||
export interface IanMenuGroupingData { |
||||
value:string; |
||||
count:number; |
||||
projectHasParent?:boolean; |
||||
_links:{ |
||||
valueLink:{ |
||||
href:string; |
||||
}[]; |
||||
}; |
||||
} |
||||
|
||||
export interface BaseMenuItemData { |
||||
value:string; |
||||
count:number; |
||||
} |
||||
|
||||
export interface IanMenuState { |
||||
notificationsByProject:IanMenuGroupingData[], |
||||
notificationsByReason:IanMenuGroupingData[], |
||||
projectsFilter:Apiv3ListParameters, |
||||
} |
||||
|
||||
export const IAN_MENU_PROJECT_FILTERS:Apiv3ListParameters = { |
||||
pageSize: 100, |
||||
groupBy: 'project', |
||||
filters: [['read_ian', '=', false]], |
||||
}; |
||||
|
||||
export const IAN_MENU_REASON_FILTERS:Apiv3ListParameters = { |
||||
pageSize: 100, |
||||
groupBy: 'reason', |
||||
filters: [['read_ian', '=', false]], |
||||
}; |
||||
|
||||
export function createInitialState():IanMenuState { |
||||
return { |
||||
notificationsByProject: [], |
||||
notificationsByReason: [], |
||||
projectsFilter: {}, |
||||
}; |
||||
} |
||||
|
||||
@StoreConfig({ name: 'ian-menu' }) |
||||
export class IanMenuStore extends Store<IanMenuState> { |
||||
constructor() { |
||||
super(createInitialState()); |
||||
} |
||||
} |
@ -0,0 +1,52 @@ |
||||
<button |
||||
class="op-sidemenu--title" |
||||
type="button" |
||||
(click)="toggleCollapsed()" |
||||
*ngIf="title && collapsible" |
||||
> |
||||
<span class="icon3 icon-small" |
||||
[ngClass]="collapsed ? 'icon-arrow-down1' : 'icon-arrow-up1'" |
||||
aria-hidden="true"> |
||||
</span> |
||||
{{ title }} |
||||
</button> |
||||
|
||||
<div |
||||
class="op-sidemenu--title" |
||||
*ngIf="title && !collapsible" |
||||
> |
||||
{{ title }} |
||||
</div> |
||||
|
||||
<ul |
||||
class="op-sidemenu--items" |
||||
[ngClass]="{'op-sidemenu--items_collapsed' : collapsed}" |
||||
> |
||||
<li |
||||
class="op-sidemenu--item" |
||||
data-qa-selector="op-sidemenu--item" |
||||
*ngFor="let item of items" |
||||
> |
||||
<a |
||||
class="op-sidemenu--item-action" |
||||
[attr.data-qa-selector]="'op-sidemenu--item-action--' + item.title.split(' ').join('')" |
||||
[href]="item.href" |
||||
*ngIf="!item.children" |
||||
> |
||||
<span |
||||
*ngIf="item.icon" |
||||
class="op-sidemenu--item-icon" |
||||
[class]="'icon-' + item.icon" |
||||
></span> |
||||
<span class="op-sidemenu--item-title">{{ item.title }}</span> |
||||
<span class="op-bubble op-bubble_alt_highlighting" *ngIf="item.count">{{ item.count }}</span> |
||||
</a> |
||||
|
||||
<op-sidemenu |
||||
*ngIf="item.children" |
||||
[title]="item.title" |
||||
[items]="item.children" |
||||
[collapsible]="item.collapsible" |
||||
></op-sidemenu> |
||||
</li> |
||||
</ul> |
@ -0,0 +1,47 @@ |
||||
.op-sidemenu |
||||
display: flex |
||||
flex-direction: column |
||||
font-size: 14px |
||||
|
||||
&--title |
||||
display: flex |
||||
background: transparent |
||||
color: var(--main-menu-fieldset-header-color) |
||||
border: 0 |
||||
text-transform: uppercase |
||||
padding: 8px 12px |
||||
margin-top: 12px |
||||
font-size: 12px |
||||
cursor: pointer |
||||
|
||||
&:hover |
||||
background: var(--main-menu-bg-hover-background) |
||||
color: var(--main-menu-hover-font-color) |
||||
|
||||
&--items |
||||
list-style: none |
||||
|
||||
&_collapsed |
||||
display: none |
||||
|
||||
&--item-action |
||||
display: flex |
||||
align-items: center |
||||
color: var(--main-menu-font-color) |
||||
padding: 8px 12px |
||||
|
||||
&:hover |
||||
background: var(--main-menu-bg-hover-background) |
||||
color: var(--main-menu-hover-font-color) |
||||
|
||||
&--item-title |
||||
margin-right: auto |
||||
overflow: hidden |
||||
text-overflow: ellipsis |
||||
white-space: nowrap |
||||
line-height: 30px |
||||
text-decoration: none |
||||
|
||||
&--item-icon |
||||
font-size: 24px |
||||
margin-right: 8px |
@ -0,0 +1,44 @@ |
||||
import { |
||||
ChangeDetectionStrategy, |
||||
ChangeDetectorRef, |
||||
Component, |
||||
Input, |
||||
HostBinding, |
||||
} from '@angular/core'; |
||||
import { I18nService } from 'core-app/core/i18n/i18n.service'; |
||||
|
||||
export interface IOpSidemenuItem { |
||||
title:string; |
||||
icon?:string; |
||||
count?:number; |
||||
href:string; |
||||
children?:IOpSidemenuItem[]; |
||||
collapsible?:boolean; |
||||
} |
||||
|
||||
@Component({ |
||||
selector: 'op-sidemenu', |
||||
templateUrl: './sidemenu.component.html', |
||||
styleUrls: ['./sidemenu.component.sass'], |
||||
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
}) |
||||
export class OpSidemenuComponent { |
||||
@HostBinding('class.op-sidemenu') className = true; |
||||
|
||||
@HostBinding('class.op-sidemenu_collapsed') collapsed = false; |
||||
|
||||
@Input() items:IOpSidemenuItem[] = []; |
||||
|
||||
@Input() title:string; |
||||
|
||||
@Input() collapsible = true; |
||||
|
||||
constructor( |
||||
readonly cdRef:ChangeDetectorRef, |
||||
readonly I18n:I18nService, |
||||
) { } |
||||
|
||||
toggleCollapsed():void { |
||||
this.collapsed = !this.collapsed; |
||||
} |
||||
} |
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 166 KiB |
@ -0,0 +1,171 @@ |
||||
require 'spec_helper' |
||||
|
||||
describe "Notification center sidemenu", type: :feature, js: true do |
||||
shared_let(:project) { FactoryBot.create :project } |
||||
shared_let(:project2) { FactoryBot.create :project } |
||||
shared_let(:project3) { FactoryBot.create :project, parent: project2 } |
||||
|
||||
shared_let(:recipient) do |
||||
FactoryBot.create :user, |
||||
member_in_projects: [project, project2, project3], |
||||
member_with_permissions: %i[view_work_packages] |
||||
end |
||||
shared_let(:other_user) { FactoryBot.create(:user) } |
||||
|
||||
shared_let(:work_package) { FactoryBot.create :work_package, project: project, author: other_user } |
||||
shared_let(:work_package2) { FactoryBot.create :work_package, project: project2, author: other_user } |
||||
shared_let(:work_package3) { FactoryBot.create :work_package, project: project3, author: other_user } |
||||
shared_let(:work_package4) { FactoryBot.create :work_package, project: project3, author: other_user } |
||||
|
||||
let(:notification) do |
||||
FactoryBot.create :notification, |
||||
recipient: recipient, |
||||
project: project, |
||||
resource: work_package, |
||||
reason: :watched |
||||
end |
||||
|
||||
let(:notification2) do |
||||
FactoryBot.create :notification, |
||||
recipient: recipient, |
||||
project: project2, |
||||
resource: work_package2, |
||||
reason: :assigned |
||||
end |
||||
|
||||
let(:notification3) do |
||||
FactoryBot.create :notification, |
||||
recipient: recipient, |
||||
project: project3, |
||||
resource: work_package3, |
||||
reason: :responsible |
||||
end |
||||
|
||||
let(:notification4) do |
||||
FactoryBot.create :notification, |
||||
recipient: recipient, |
||||
project: project3, |
||||
resource: work_package4, |
||||
reason: :mentioned |
||||
end |
||||
|
||||
let(:notifications) do |
||||
[notification, notification2, notification3, notification4] |
||||
end |
||||
|
||||
let(:center) { ::Pages::Notifications::Center.new } |
||||
let(:side_menu) { ::Components::Notifications::Sidemenu.new } |
||||
|
||||
before do |
||||
notifications |
||||
login_as recipient |
||||
center.visit! |
||||
end |
||||
|
||||
context 'with no notifications to show' do |
||||
let(:notifications) { nil } |
||||
|
||||
it 'still shows the sidebar and a placeholder' do |
||||
side_menu.expect_open |
||||
|
||||
expect(page).to have_text 'No unread notifications' |
||||
|
||||
center.expect_no_notification |
||||
|
||||
side_menu.expect_item_with_no_count 'Inbox' |
||||
side_menu.expect_item_with_no_count 'Assigned' |
||||
side_menu.expect_item_with_no_count '@mentioned' |
||||
side_menu.expect_item_with_no_count 'Accountable' |
||||
side_menu.expect_item_with_no_count 'Watching' |
||||
end |
||||
end |
||||
|
||||
it 'updates the numbers when a notification is read' do |
||||
side_menu.expect_open |
||||
|
||||
# Expect standard filters |
||||
side_menu.expect_item_with_count 'Inbox', 4 |
||||
side_menu.expect_item_with_count 'Assigned', 1 |
||||
side_menu.expect_item_with_count '@mentioned', 1 |
||||
side_menu.expect_item_with_count 'Accountable', 1 |
||||
side_menu.expect_item_with_count 'Watching', 1 |
||||
|
||||
# Expect project filters |
||||
side_menu.expect_item_with_count project.name, 1 |
||||
side_menu.expect_item_with_count project2.name, 1 |
||||
side_menu.expect_item_with_count "... #{project3.name}", 2 |
||||
|
||||
# Reading a notification... |
||||
center.mark_notification_as_read notification |
||||
|
||||
# ... will change the filter counts |
||||
side_menu.expect_item_with_count 'Inbox', 3 |
||||
side_menu.expect_item_with_count 'Assigned', 1 |
||||
side_menu.expect_item_with_count '@mentioned', 1 |
||||
side_menu.expect_item_with_count 'Accountable', 1 |
||||
side_menu.expect_item_with_no_count 'Watching' |
||||
|
||||
# ... and show only those projects with a notification |
||||
side_menu.expect_item_not_visible project.name |
||||
side_menu.expect_item_with_count project2.name, 1 |
||||
side_menu.expect_item_with_count "... #{project3.name}", 2 |
||||
|
||||
# Marking all as read |
||||
center.mark_all_read |
||||
side_menu.expect_item_with_no_count 'Inbox' |
||||
side_menu.expect_item_with_no_count 'Assigned' |
||||
side_menu.expect_item_with_no_count '@mentioned' |
||||
side_menu.expect_item_with_no_count 'Accountable' |
||||
side_menu.expect_item_with_no_count 'Watching' |
||||
|
||||
side_menu.expect_item_not_visible project.name |
||||
side_menu.expect_item_not_visible project2.name |
||||
side_menu.expect_item_not_visible "... #{project3.name}" |
||||
end |
||||
|
||||
it 'updates the content when a filter is clicked' do |
||||
# All notifications are shown |
||||
center.expect_work_package_item notification, notification2, notification3, notification4 |
||||
|
||||
# Filter for "Watching" |
||||
side_menu.click_item 'Watching' |
||||
side_menu.finished_loading |
||||
center.expect_work_package_item notification |
||||
center.expect_no_item notification2, notification3, notification4 |
||||
|
||||
# Filter for "Assignee" |
||||
side_menu.click_item 'Assigned' |
||||
side_menu.finished_loading |
||||
center.expect_work_package_item notification2 |
||||
center.expect_no_item notification, notification3, notification4 |
||||
|
||||
# Filter for "Accountable" |
||||
side_menu.click_item 'Accountable' |
||||
side_menu.finished_loading |
||||
center.expect_work_package_item notification3 |
||||
center.expect_no_item notification, notification2, notification4 |
||||
|
||||
# Filter for "@mentioned" |
||||
side_menu.click_item '@mentioned' |
||||
side_menu.finished_loading |
||||
center.expect_work_package_item notification4 |
||||
center.expect_no_item notification, notification2, notification3 |
||||
|
||||
# Filter for project1 |
||||
side_menu.click_item project.name |
||||
side_menu.finished_loading |
||||
center.expect_work_package_item notification |
||||
center.expect_no_item notification2, notification3, notification4 |
||||
|
||||
# Filter for project3 |
||||
side_menu.click_item "... #{project3.name}" |
||||
side_menu.finished_loading |
||||
center.expect_work_package_item notification3, notification4 |
||||
center.expect_no_item notification, notification2 |
||||
|
||||
# Reset by clicking on the Inbox |
||||
side_menu.click_item 'Inbox' |
||||
side_menu.finished_loading |
||||
center.expect_work_package_item notification, notification2, notification3, notification4 |
||||
end |
||||
end |
@ -0,0 +1,86 @@ |
||||
#-- copyright |
||||
# OpenProject is an open source project management software. |
||||
# Copyright (C) 2012-2021 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-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 COPYRIGHT and LICENSE files for more details. |
||||
#++ |
||||
|
||||
module Components |
||||
module Notifications |
||||
class Sidemenu |
||||
include Capybara::DSL |
||||
include RSpec::Matchers |
||||
|
||||
def initialize; end |
||||
|
||||
def expect_open |
||||
expect(page).to have_selector('[data-qa-selector="op-sidemenu"]') |
||||
end |
||||
|
||||
def expect_item_not_visible(item) |
||||
expect(page).to have_no_selector(item_selector, text: item) |
||||
end |
||||
|
||||
def expect_item_with_count(item, count) |
||||
within item_action_selector(item) do |
||||
expect(page).to have_text item |
||||
expect_count(count) |
||||
end |
||||
end |
||||
|
||||
def expect_item_with_no_count(item) |
||||
within item_action_selector(item) do |
||||
expect(page).to have_text item |
||||
expect_no_count |
||||
end |
||||
end |
||||
|
||||
def click_item(item) |
||||
page.find(item_action_selector(item), text: item).click |
||||
end |
||||
|
||||
def finished_loading |
||||
expect(page).to have_no_selector('.op-ian-center--loading-indicator') |
||||
end |
||||
|
||||
private |
||||
|
||||
def expect_count(count) |
||||
expect(page).to have_selector('.op-bubble', text: count) |
||||
end |
||||
|
||||
def expect_no_count |
||||
expect(page).to have_no_selector('.op-bubble') |
||||
end |
||||
|
||||
def item_action_selector(item) |
||||
"[data-qa-selector='op-sidemenu--item-action--#{item.delete(' ')}']" |
||||
end |
||||
|
||||
def item_selector |
||||
'[data-qa-selector="op-sidemenu--item"]' |
||||
end |
||||
end |
||||
end |
||||
end |
After Width: | Height: | Size: 455 B |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 1.7 KiB |
After Width: | Height: | Size: 638 B |