Merge pull request #9581 from opf/feature/38520-Sidebar-in-Notification-Center-with-project-filter

[38520] Sidebar in Notification Center with project filter
pull/9714/head
Oliver Günther 3 years ago committed by GitHub
commit 7dfc112fca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      app/controllers/angular_controller.rb
  2. 2
      app/controllers/api_docs_controller.rb
  3. 4
      app/controllers/my_controller.rb
  4. 2
      app/controllers/work_packages/calendars_controller.rb
  5. 9
      app/controllers/work_packages_controller.rb
  6. 0
      app/views/layouts/angular/angular.html.erb
  7. 40
      app/views/layouts/angular/notifications.html.erb
  8. 1
      app/views/notifications/_menu_notification_center.html.erb
  9. 6
      config/initializers/menus.rb
  10. 8
      config/locales/js-en.yml
  11. 2
      config/routes.rb
  12. 1
      docker-compose.yml
  13. 5
      frontend/src/app/core/apiv3/paths/apiv3-list-resource.interface.ts
  14. 11
      frontend/src/app/core/apiv3/types/hal-collection.type.ts
  15. 6
      frontend/src/app/core/setup/global-dynamic-components.const.ts
  16. 19
      frontend/src/app/core/state/hal-resource.ts
  17. 29
      frontend/src/app/core/state/in-app-notifications/in-app-notification.model.ts
  18. 4
      frontend/src/app/core/state/openproject-state.module.ts
  19. 32
      frontend/src/app/core/state/projects/project.model.ts
  20. 5
      frontend/src/app/core/state/projects/projects.query.ts
  21. 88
      frontend/src/app/core/state/projects/projects.service.ts
  22. 13
      frontend/src/app/core/state/projects/projects.store.ts
  23. 4
      frontend/src/app/features/in-app-notifications/center/in-app-notification-center.component.ts
  24. 4
      frontend/src/app/features/in-app-notifications/center/menu/menu.component.html
  25. 21
      frontend/src/app/features/in-app-notifications/center/menu/menu.component.sass
  26. 129
      frontend/src/app/features/in-app-notifications/center/menu/menu.component.ts
  27. 54
      frontend/src/app/features/in-app-notifications/center/menu/state/ian-menu.query.ts
  28. 75
      frontend/src/app/features/in-app-notifications/center/menu/state/ian-menu.service.ts
  29. 51
      frontend/src/app/features/in-app-notifications/center/menu/state/ian-menu.store.ts
  30. 25
      frontend/src/app/features/in-app-notifications/center/state/ian-center.query.ts
  31. 30
      frontend/src/app/features/in-app-notifications/center/state/ian-center.service.ts
  32. 3
      frontend/src/app/features/in-app-notifications/center/state/ian-center.store.ts
  33. 10
      frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.ts
  34. 2
      frontend/src/app/features/in-app-notifications/in-app-notifications.module.ts
  35. 7
      frontend/src/app/features/in-app-notifications/in-app-notifications.routes.ts
  36. 52
      frontend/src/app/shared/components/sidemenu/sidemenu.component.html
  37. 47
      frontend/src/app/shared/components/sidemenu/sidemenu.component.sass
  38. 44
      frontend/src/app/shared/components/sidemenu/sidemenu.component.ts
  39. 3
      frontend/src/app/shared/shared.module.ts
  40. 537
      frontend/src/assets/fonts/openproject_icon/openproject-icon-font.svg
  41. BIN
      frontend/src/assets/fonts/openproject_icon/openproject-icon-font.ttf
  42. BIN
      frontend/src/assets/fonts/openproject_icon/openproject-icon-font.woff
  43. BIN
      frontend/src/assets/fonts/openproject_icon/openproject-icon-font.woff2
  44. 3
      frontend/src/global_styles/common/bubble/bubble.sass
  45. 1074
      frontend/src/global_styles/fonts/_openproject_icon_definitions.scss
  46. 5
      frontend/src/global_styles/fonts/_openproject_icon_font.lsg
  47. 2
      modules/backlogs/app/controllers/rb_application_controller.rb
  48. 2
      modules/boards/app/controllers/boards/boards_controller.rb
  49. 2
      modules/grids/app/controllers/grids/base_in_project_controller.rb
  50. 2
      modules/reporting/app/controllers/cost_reports_controller.rb
  51. 1
      nix/shell.nix
  52. 3
      package.json
  53. 11
      spec/factories/notification_factory.rb
  54. 3
      spec/features/notifications/navigation_spec.rb
  55. 171
      spec/features/notifications/notification_center/notification_center_sidemenu_spec.rb
  56. 3
      spec/features/notifications/notification_center/notification_center_spec.rb
  57. 3
      spec/features/notifications/notification_center/split_screen_spec.rb
  58. 3
      spec/models/notification_spec.rb
  59. 86
      spec/support/components/common/sidemenu.rb
  60. 32
      spec/support/pages/notifications/center.rb
  61. 3
      vendor/openproject-icon-font/src/accountable.svg
  62. 6
      vendor/openproject-icon-font/src/assigned.svg
  63. 3
      vendor/openproject-icon-font/src/inbox.svg
  64. 5
      vendor/openproject-icon-font/src/mention.svg
  65. 3
      vendor/openproject-icon-font/src/watching.svg

@ -34,6 +34,12 @@ class AngularController < ApplicationController
def empty_layout
# Frontend will handle rendering
# but we will need to render with layout
render html: '', layout: 'angular'
render html: '', layout: 'angular/angular'
end
def notifications_layout
# Frontend will handle rendering
# but we will need to render with notification specific layout
render html: '', layout: 'angular/notifications'
end
end

@ -34,6 +34,6 @@ class APIDocsController < ApplicationController
def index
render_404 unless Setting.apiv3_docs_enabled?
render layout: 'angular', inline: ''
render layout: 'angular/angular', inline: '' # rubocop:disable Rails/RenderInline
end
end

@ -85,7 +85,7 @@ class MyController < ApplicationController
# Configure user's in app notifications
def notifications
render html: '',
layout: 'angular',
layout: 'angular/angular',
locals: {
menu_name: :my_menu,
page_title: [I18n.t(:label_my_account), I18n.t('js.notifications.settings.title')]
@ -95,7 +95,7 @@ class MyController < ApplicationController
# Configure user's mail reminders
def reminders
render html: '',
layout: 'angular',
layout: 'angular/angular',
locals: { menu_name: :my_menu }
end

@ -33,6 +33,6 @@ class WorkPackages::CalendarsController < ApplicationController
before_action :find_optional_project
def index
render layout: 'angular'
render layout: 'angular/angular'
end
end

@ -46,7 +46,9 @@ class WorkPackagesController < ApplicationController
def show
respond_to do |format|
format.html do
render :show, locals: { work_package: work_package, menu_name: project_or_wp_query_menu }, layout: 'angular'
render :show,
locals: { work_package: work_package, menu_name: project_or_wp_query_menu },
layout: 'angular/angular'
end
format.any(*WorkPackage::Exporter.single_formats) do
@ -66,8 +68,9 @@ class WorkPackagesController < ApplicationController
def index
respond_to do |format|
format.html do
render :index, locals: { query: @query, project: @project, menu_name: project_or_wp_query_menu },
layout: 'angular'
render :index,
locals: { query: @query, project: @project, menu_name: project_or_wp_query_menu },
layout: 'angular/angular'
end
format.any(*WorkPackage::Exporter.list_formats) do

@ -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 }) %>

@ -115,6 +115,12 @@ Redmine::MenuManager.map :application_menu do |menu|
last: true
end
Redmine::MenuManager.map :notifications_menu do |menu|
menu.push :notification_grouping_select,
{ controller: '/my', action: 'notifications' },
partial: 'notifications/menu_notification_center'
end
Redmine::MenuManager.map :my_menu do |menu|
menu.push :account,
{ controller: '/my', action: 'account' },

@ -576,6 +576,14 @@ en:
mark_as_read: 'Mark as read'
text_update_date: "%{date} by"
total_count_warning: "Showing the %{newest_count} most recent notifications. %{more_count} more are not displayed."
menu:
accountable: 'Accountable'
assigned: 'Assigned'
by_project: 'By Project'
by_reason: 'By Reason'
inbox: 'Inbox'
mentioned: '@mentioned'
watching: 'Watching'
settings:
title: "Notification settings"
notify_me: "Notify me"

@ -579,7 +579,7 @@ OpenProject::Application.routes.draw do
root to: 'account#login'
scope :notifications do
get '(/*state)', to: 'angular#empty_layout', as: :notifications_center
get '(/*state)', to: 'angular#notifications_layout', as: :notifications_center
end
# Development route for styleguide

@ -45,6 +45,7 @@ x-op-backend: &backend
OPENPROJECT_CACHE__MEMCACHE__SERVER: cache:11211
OPENPROJECT_RAILS__RELATIVE__URL__ROOT: "${OPENPROJECT_RAILS__RELATIVE__URL__ROOT:-}"
DATABASE_URL: postgresql://${DB_USERNAME:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_DATABASE:-openproject}
OPENPROJECT_EDITION: $OPENPROJECT_EDITION
volumes:
- ".:/home/dev/openproject"
- "opdata:/var/openproject/assets"

@ -35,6 +35,7 @@ export type ApiV3ListFilter = [string, FilterOperator, boolean|string[]];
export interface Apiv3ListParameters {
filters?:ApiV3ListFilter[];
sortBy?:[string, string][];
groupBy?:string;
pageSize?:number;
offset?:number;
}
@ -50,6 +51,10 @@ export function listParamsString(params?:Apiv3ListParameters):string {
queryProps.push(`sortBy=${JSON.stringify(params.sortBy)}`);
}
if (params && params.groupBy) {
queryProps.push(`groupBy=${params.groupBy}`);
}
// 0 should not be treated as false
if (params && params.pageSize !== undefined) {
queryProps.push(`pageSize=${params.pageSize}`);

@ -1,9 +1,20 @@
export interface IHALGrouping {
value:string;
count:number;
_links:{
valueLink:{
href:string;
}[];
};
}
export interface IHALCollection<T> {
_type:'Collection';
count:number;
total:number;
pageSize:number;
offset:number;
groups?:IHALGrouping[];
_embedded:{
elements:T[];
}

@ -172,6 +172,11 @@ import {
InAppNotificationBellComponent,
opInAppNotificationBellSelector,
} from 'core-app/features/in-app-notifications/bell/in-app-notification-bell.component';
import {
IanMenuComponent,
ianMenuSelector,
} from 'core-app/features/in-app-notifications/center/menu/menu.component';
export const globalDynamicComponents:OptionalBootstrapDefinition[] = [
{ selector: appBaseSelector, cls: ApplicationBaseComponent },
@ -221,4 +226,5 @@ export const globalDynamicComponents:OptionalBootstrapDefinition[] = [
{ selector: slideToggleSelector, cls: SlideToggleComponent },
{ selector: backupSelector, cls: BackupComponent },
{ selector: opInAppNotificationBellSelector, cls: InAppNotificationBellComponent },
{ selector: ianMenuSelector, cls: IanMenuComponent },
];

@ -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;
}

@ -1,17 +1,17 @@
import { ID } from '@datorama/akita';
export interface HalResourceLink {
href:string;
title:string;
}
export type InAppNotificationFormat = 'markdown'|'custom';
import {
HalResourceLink,
HalResourceLinks,
Formattable,
} from 'core-app/core/state/hal-resource';
export const NOTIFICATIONS_MAX_SIZE = 100;
export interface InAppNotificationDetail {
format:InAppNotificationFormat;
raw:string|null;
html:string;
export interface InAppNotificationHalResourceLinks extends HalResourceLinks {
actor:HalResourceLink;
project:HalResourceLink;
resource:HalResourceLink;
activity:HalResourceLink;
}
export interface InAppNotification {
@ -23,16 +23,11 @@ export interface InAppNotification {
readIAN:boolean|null;
readEmail:boolean|null;
details?:InAppNotificationDetail[];
details?:Formattable[];
// Mark a notification to be kept in the center even though it was saved as "read".
keep?:boolean;
// Show message of a notification?
expanded:boolean;
_links:{
actor?:HalResourceLink,
project?:HalResourceLink,
resource?:HalResourceLink,
activity?:HalResourceLink,
};
_links:InAppNotificationHalResourceLinks;
}

@ -29,11 +29,13 @@
import {
NgModule,
} from '@angular/core';
import { InAppNotificationsResourceService } from 'core-app/core/state/in-app-notifications/in-app-notifications.service';
import { InAppNotificationsResourceService } from './in-app-notifications/in-app-notifications.service';
import { ProjectsResourceService } from './projects/projects.service';
@NgModule({
providers: [
InAppNotificationsResourceService,
ProjectsResourceService,
],
})
export class OpenProjectStateModule {

@ -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());
}
}

@ -99,5 +99,9 @@ export class InAppNotificationCenterComponent extends UntilDestroyedMixin implem
ngOnInit():void {
this.storeService.setFacet('unread');
this.storeService.setFilters({
filter: this.uiRouterGlobals.params.filter, // eslint-disable-line @typescript-eslint/no-unsafe-assignment
name: this.uiRouterGlobals.params.name, // eslint-disable-line @typescript-eslint/no-unsafe-assignment
});
}
}

@ -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());
}
}

@ -1,15 +1,18 @@
import { Query } from '@datorama/akita';
import {
map,
switchMap,
} from 'rxjs/operators';
import {
IAN_FACET_FILTERS,
IanCenterState,
IanCenterStore,
} from 'core-app/features/in-app-notifications/center/state/ian-center.store';
import { InAppNotificationsResourceService } from 'core-app/core/state/in-app-notifications/in-app-notifications.service';
import {
map,
switchMap,
} from 'rxjs/operators';
import { Apiv3ListParameters } from 'core-app/core/apiv3/paths/apiv3-list-resource.interface';
ApiV3ListFilter,
Apiv3ListParameters,
} from 'core-app/core/apiv3/paths/apiv3-list-resource.interface';
import { InAppNotificationsResourceService } from 'core-app/core/state/in-app-notifications/in-app-notifications.service';
import { InAppNotification } from 'core-app/core/state/in-app-notifications/in-app-notification.model';
import { selectCollectionAsEntities$ } from 'core-app/core/state/collection-store';
@ -54,7 +57,17 @@ export class IanCenterQuery extends Query<IanCenterState> {
get params():Apiv3ListParameters {
const state = this.store.getValue();
return { ...state.params, filters: IAN_FACET_FILTERS[state.activeFacet] };
const hasFilters = state.filters.name && state.filters.filter;
return {
...state.params,
filters: [
...IAN_FACET_FILTERS[state.activeFacet],
...(hasFilters
? ([[state.filters.filter, '=', [state.filters.name]]] as ApiV3ListFilter[])
: []
),
],
};
}
constructor(

@ -2,25 +2,22 @@ import {
Injectable,
Injector,
} from '@angular/core';
import {
IanCenterStore,
InAppNotificationFacet,
} from './ian-center.store';
import {
map,
switchMap,
take,
} from 'rxjs/operators';
import { from } from 'rxjs';
import {
ID,
setLoading,
} from '@datorama/akita';
import {
markNotificationsAsRead,
notificationsMarkedRead,
} from 'core-app/core/state/in-app-notifications/in-app-notifications.actions';
import { InAppNotification } from 'core-app/core/state/in-app-notifications/in-app-notification.model';
import { IanCenterQuery } from 'core-app/features/in-app-notifications/center/state/ian-center.query';
import {
ID,
setLoading,
} from '@datorama/akita';
import {
EffectCallback,
EffectHandler,
@ -28,9 +25,13 @@ import {
import { ActionsService } from 'core-app/core/state/actions/actions.service';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { APIV3Service } from 'core-app/core/apiv3/api-v3.service';
import { from } from 'rxjs';
import { InAppNotificationsResourceService } from 'core-app/core/state/in-app-notifications/in-app-notifications.service';
import { selectCollectionAsHrefs$ } from 'core-app/core/state/collection-store';
import { INotificationPageQueryParameters } from 'core-app/features/in-app-notifications/in-app-notifications.routes';
import {
IanCenterStore,
InAppNotificationFacet,
} from './ian-center.store';
@Injectable()
@EffectHandler
@ -49,9 +50,14 @@ export class IanCenterService {
) {
}
setFilters(filters:INotificationPageQueryParameters):void {
this.store.update({ filters });
this.debouncedReload();
}
setFacet(facet:InAppNotificationFacet):void {
this.store.update({ activeFacet: facet });
this.reload();
this.debouncedReload();
}
markAsRead(notifications:ID[]):void {
@ -80,10 +86,12 @@ export class IanCenterService {
.resourceService
.removeFromCollection(this.query.params, action.notifications);
} else {
this.reload();
this.debouncedReload();
}
}
private debouncedReload = _.debounce(() => { this.reload(); });
private reload() {
this.resourceService
.fetchNotifications(this.query.params)

@ -1,6 +1,7 @@
import { Store, StoreConfig } from '@datorama/akita';
import { ApiV3ListFilter } from 'core-app/core/apiv3/paths/apiv3-list-resource.interface';
import { NOTIFICATIONS_MAX_SIZE } from 'core-app/core/state/in-app-notifications/in-app-notification.model';
import { INotificationPageQueryParameters } from 'core-app/features/in-app-notifications/in-app-notifications.routes';
export interface IanCenterState {
params:{
@ -8,6 +9,7 @@ export interface IanCenterState {
pageSize:number;
};
activeFacet:InAppNotificationFacet;
filters:INotificationPageQueryParameters;
/** Number of elements not showing after max values loaded */
notLoaded:number;
@ -26,6 +28,7 @@ export function createInitialState():IanCenterState {
pageSize: NOTIFICATIONS_MAX_SIZE,
page: 1,
},
filters: {},
activeFacet: 'unread',
notLoaded: 0,
};

@ -27,10 +27,8 @@ import { take } from 'rxjs/internal/operators/take';
import { StateService } from '@uirouter/angular';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { BackRouteOptions } from 'core-app/features/work-packages/components/back-routing/back-routing.service';
import {
InAppNotification,
InAppNotificationDetail,
} from 'core-app/core/state/in-app-notifications/in-app-notification.model';
import { Formattable } from 'core-app/core/state/hal-resource';
import { InAppNotification } from 'core-app/core/state/in-app-notifications/in-app-notification.model';
import { IanCenterService } from 'core-app/features/in-app-notifications/center/state/ian-center.service';
@Component({
@ -52,10 +50,10 @@ export class InAppNotificationEntryComponent implements OnInit {
loading$ = this.storeService.query.selectLoading();
// Formattable body, if any
body:InAppNotificationDetail[];
body:Formattable[];
// custom rendered details, if any
details:InAppNotificationDetail[];
details:Formattable[];
// Whether body and details are empty
unexpandable = false;

@ -10,6 +10,7 @@ import { ScrollingModule } from '@angular/cdk/scrolling';
import { IAN_ROUTES } from 'core-app/features/in-app-notifications/in-app-notifications.routes';
import { InAppNotificationCenterComponent } from 'core-app/features/in-app-notifications/center/in-app-notification-center.component';
import { InAppNotificationCenterPageComponent } from 'core-app/features/in-app-notifications/center/in-app-notification-center-page.component';
import { IanMenuComponent } from 'core-app/features/in-app-notifications/center/menu/menu.component';
import { OpenprojectWorkPackagesModule } from 'core-app/features/work-packages/openproject-work-packages.module';
import { DynamicModule } from 'ng-dynamic-component';
import { InAppNotificationStatusComponent } from './entry/status/in-app-notification-status.component';
@ -28,6 +29,7 @@ import { OpenprojectContentLoaderModule } from 'core-app/shared/components/op-co
NotificationSettingsButtonComponent,
ActivateFacetButtonComponent,
MarkAllAsReadButtonComponent,
IanMenuComponent,
],
imports: [
OPSharedModule,

@ -33,10 +33,15 @@ import { InAppNotificationCenterComponent } from 'core-app/features/in-app-notif
import { InAppNotificationCenterPageComponent } from 'core-app/features/in-app-notifications/center/in-app-notification-center-page.component';
import { WorkPackagesBaseComponent } from 'core-app/features/work-packages/routing/wp-base/wp--base.component';
export interface INotificationPageQueryParameters {
filter?:string;
name?:string;
}
export const IAN_ROUTES:Ng2StateDeclaration[] = [
{
name: 'notifications',
url: '/notifications',
url: '/notifications?{filter:string}&{name:string}',
data: {
bodyClasses: 'router--work-packages-base',
},

@ -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;
}
}

@ -82,6 +82,7 @@ import { OpCheckboxFieldComponent } from './components/forms/checkbox-field/chec
import { OpFormFieldComponent } from './components/forms/form-field/form-field.component';
import { OpFormBindingDirective } from './components/forms/form-field/form-binding.directive';
import { OpOptionListComponent } from './components/option-list/option-list.component';
import { OpSidemenuComponent } from './components/sidemenu/sidemenu.component';
export function bootstrapModule(injector:Injector) {
// Ensure error reporter is run
@ -184,6 +185,7 @@ export function bootstrapModule(injector:Injector) {
OpFormFieldComponent,
OpFormBindingDirective,
OpOptionListComponent,
OpSidemenuComponent,
],
declarations: [
OpDateTimeComponent,
@ -236,6 +238,7 @@ export function bootstrapModule(injector:Injector) {
OpFormFieldComponent,
OpFormBindingDirective,
OpOptionListComponent,
OpSidemenuComponent,
],
})
export class OPSharedModule {

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 166 KiB

@ -1,6 +1,9 @@
.op-bubble
@include indicator-bubble
&_alt_highlighting
background: #878787
&_mini
width: 12px
height: 12px

@ -4,6 +4,7 @@
<ul class="icon-list">
<li><span class="icon icon-accessibility"></span>accessibility</li>
<li><span class="icon icon-accountable"></span>accountable</li>
<li><span class="icon icon-add"></span>add</li>
<li><span class="icon icon-align-center"></span>align-center</li>
<li><span class="icon icon-align-justify"></span>align-justify</li>
@ -26,6 +27,7 @@
<li><span class="icon icon-arrow-up1"></span>arrow-up1</li>
<li><span class="icon icon-arrow-up2"></span>arrow-up2</li>
<li><span class="icon icon-assigned-to-me"></span>assigned-to-me</li>
<li><span class="icon icon-assigned"></span>assigned</li>
<li><span class="icon icon-attachment"></span>attachment</li>
<li><span class="icon icon-attention"></span>attention</li>
<li><span class="icon icon-back-up"></span>back-up</li>
@ -124,6 +126,7 @@
<li><span class="icon icon-image1"></span>image1</li>
<li><span class="icon icon-image2"></span>image2</li>
<li><span class="icon icon-import"></span>import</li>
<li><span class="icon icon-inbox"></span>inbox</li>
<li><span class="icon icon-info1"></span>info1</li>
<li><span class="icon icon-info2"></span>info2</li>
<li><span class="icon icon-input-disabled"></span>input-disabled</li>
@ -142,6 +145,7 @@
<li><span class="icon icon-mark-all-read"></span>mark-all-read</li>
<li><span class="icon icon-mark-read"></span>mark-read</li>
<li><span class="icon icon-meetings"></span>meetings</li>
<li><span class="icon icon-mention"></span>mention</li>
<li><span class="icon icon-menu"></span>menu</li>
<li><span class="icon icon-merge-branch"></span>merge-branch</li>
<li><span class="icon icon-microphone"></span>microphone</li>
@ -255,6 +259,7 @@
<li><span class="icon icon-view-timeline"></span>view-timeline</li>
<li><span class="icon icon-warning"></span>warning</li>
<li><span class="icon icon-watched"></span>watched</li>
<li><span class="icon icon-watching"></span>watching</li>
<li><span class="icon icon-wiki-edit"></span>wiki-edit</li>
<li><span class="icon icon-wiki"></span>wiki</li>
<li><span class="icon icon-wiki2"></span>wiki2</li>

@ -36,7 +36,7 @@ class RbApplicationController < ApplicationController
# Render angular layout to handle CSS loading
# from the frontend
layout 'angular'
layout 'angular/angular'
private

@ -10,7 +10,7 @@ module ::Boards
menu_item :board_view
def index
render layout: 'angular'
render layout: 'angular/angular'
end
current_menu_item :index do

@ -4,7 +4,7 @@ module ::Grids
before_action :authorize
def show
render layout: 'angular'
render layout: 'angular/angular'
end
end
end

@ -65,7 +65,7 @@ class CostReportsController < ApplicationController
before_action :set_cost_types # has to be set AFTER the Report::Controller filters run
layout 'angular'
layout 'angular/angular'
# Checks if custom fields have been updated, added or removed since we
# last saw them, to rebuild the filters and group bys.

@ -24,6 +24,7 @@ in
gems
op-get-test-failures
nodePackages.webfonts-generator
];
CHROME_BINARY = "${google-chrome}/bin/google-chrome";

@ -12,5 +12,8 @@
"engines": {
"node": "~14.17.0",
"npm": "~6.14.13"
},
"devDependencies": {
"webfonts-generator": "^0.4.0"
}
}

@ -8,13 +8,12 @@ FactoryBot.define do
recipient factory: :user
project { association :project }
resource { association :work_package, project: project }
actor { journal.try(:user) }
actor { nil }
journal { nil }
transient { journal }
callback(:after_create) do |notification, evaluator|
notification.journal = evaluator.journal || notification.work_package.journals.last
notification.save!
callback(:after_build) do |notification, _|
notification.journal ||= notification.resource.journals.last
notification.actor ||= notification.journal.try(:user)
end
end
end

@ -1,5 +1,4 @@
require 'spec_helper'
require 'support/components/notifications/center'
describe "Notification center navigation", type: :feature, js: true do
shared_let(:project) { FactoryBot.create :project }
@ -26,7 +25,7 @@ describe "Notification center navigation", type: :feature, js: true do
journal: second_work_package.journals.last
end
let(:center) { ::Components::Notifications::Center.new }
let(:center) { ::Pages::Notifications::Center.new }
let(:activity_tab) { ::Components::WorkPackages::Activities.new(work_package) }
let(:split_screen) { ::Pages::SplitWorkPackage.new work_package }

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

@ -1,5 +1,4 @@
require 'spec_helper'
require 'support/components/notifications/center'
describe "Notification center", type: :feature, js: true, with_settings: { journal_aggregation_time_minutes: 0 } do
# Notice that the setup in this file here is not following the normal rules as
@ -29,7 +28,7 @@ describe "Notification center", type: :feature, js: true, with_settings: { journ
work_package2.journals.first.notifications.first
end
let(:center) { ::Components::Notifications::Center.new }
let(:center) { ::Pages::Notifications::Center.new }
let(:activity_tab) { ::Components::WorkPackages::Activities.new(work_package) }
let(:split_screen) { ::Pages::SplitWorkPackage.new work_package }

@ -1,5 +1,4 @@
require 'spec_helper'
require 'support/components/notifications/center'
describe "Split screen in the notification center", type: :feature, js: true do
shared_let(:project) { FactoryBot.create :project }
@ -26,7 +25,7 @@ describe "Split screen in the notification center", type: :feature, js: true do
journal: second_work_package.journals.last
end
let(:center) { ::Components::Notifications::Center.new }
let(:center) { ::Pages::Notifications::Center.new }
let(:split_screen) { ::Pages::SplitWorkPackage.new work_package }
describe 'basic use case' do

@ -33,9 +33,10 @@ describe Notification,
type: :model do
describe '.save' do
context 'for a non existing journal (e.g. because it has been deleted)' do
let(:notification) { FactoryBot.build(:notification, journal_id: 55) }
let(:notification) { FactoryBot.build(:notification) }
it 'raises an error' do
notification.journal_id = 99999
expect { notification.save }
.to raise_error ActiveRecord::InvalidForeignKey
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

@ -26,19 +26,19 @@
# See COPYRIGHT and LICENSE files for more details.
#++
module Components
module Pages
module Notifications
class Center
include Capybara::DSL
include RSpec::Matchers
def initialize; end
class Center < ::Pages::Page
def open
bell_element.click
expect_open
end
def path
notifications_center_path
end
def close
page.find('button[data-qa-selector="op-back-button"]').click
expect_closed
@ -75,9 +75,11 @@ module Components
end
end
def expect_no_item(notification)
expect(page)
.to have_no_selector("[data-qa-selector='op-ian-notification-item-#{notification.id}']")
def expect_no_item(*notifications)
notifications.each do |notification|
expect(page)
.to have_no_selector("[data-qa-selector='op-ian-notification-item-#{notification.id}']")
end
end
def expect_read_item(notification)
@ -90,12 +92,14 @@ module Components
.not_to have_selector("[data-qa-selector='op-ian-notification-item-#{notification.id}'][data-qa-ian-read]")
end
def expect_work_package_item(notification)
work_package = notification.resource
raise(ArgumentError, "Expected work package") unless work_package.is_a?(WorkPackage)
def expect_work_package_item(*notifications)
notifications.each do |notification|
work_package = notification.resource
raise(ArgumentError, "Expected work package") unless work_package.is_a?(WorkPackage)
expect_item notification,
subject: "#{work_package.type.name.upcase} #{work_package.subject}"
expect_item notification,
subject: "#{work_package.type.name.upcase} #{work_package.subject}"
end
end
def expect_closed

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.2 9.0625C11.2 10.4113 10.128 11.5 8.8 11.5C7.472 11.5 6.4 10.4113 6.4 9.0625C6.4 7.71375 7.472 6.625 8.8 6.625C10.128 6.625 11.2 7.71375 11.2 9.0625ZM13.6 18H4V16.375C4 14.5794 6.152 13.125 8.8 13.125C11.448 13.125 13.6 14.5794 13.6 16.375V18ZM20 11.5V13.125H12.8V11.5H20ZM20 8.25V9.875H12.8V8.25H20ZM20 5V6.625H12.8V5H20Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 455 B

@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.1724 11.0138C13.0018 11.0138 14.4848 9.44374 14.4848 7.50692C14.4848 5.5701 13.0018 4 11.1724 4C9.34304 4 7.86005 5.5701 7.86005 7.50692C7.86005 9.44374 9.34304 11.0138 11.1724 11.0138Z" fill="black"/>
<path d="M8.25889 15.8957C8.26899 15.6108 8.35866 15.3354 8.51656 15.1045C8.67446 14.8735 8.89349 14.6972 9.14597 14.598C9.39845 14.4988 9.67305 14.4811 9.93504 14.5471C10.197 14.6131 10.4347 14.7598 10.6179 14.9688L12.4954 17.1059L15.8224 13.1767C14.4481 12.2071 12.8286 11.7008 11.1773 11.7245C10.0317 11.6938 8.89378 11.9292 7.84338 12.4141C6.79298 12.899 5.85566 13.6215 5.0973 14.5311C5.03315 14.6216 4.99896 14.732 5.00002 14.8452V17.6209C4.99991 17.8887 5.09838 18.1461 5.27455 18.3384C5.45072 18.5307 5.69072 18.6428 5.94363 18.6508H10.1121L8.61882 16.9514C8.49544 16.8097 8.40005 16.6433 8.33824 16.462C8.27644 16.2807 8.24945 16.0882 8.25889 15.8957Z" fill="black"/>
<path d="M16.4109 18.6508C16.6638 18.6428 16.9038 18.5307 17.08 18.3384C17.2562 18.1461 17.3546 17.8887 17.3545 17.6209V15.6846L14.8593 18.6508H16.4109Z" fill="black"/>
<path d="M18.8478 11.7605C18.8 11.7149 18.7442 11.6797 18.6835 11.657C18.6229 11.6343 18.5585 11.6246 18.4943 11.6285C18.4301 11.6323 18.3672 11.6496 18.3093 11.6793C18.2514 11.7091 18.1997 11.7507 18.1571 11.8017L12.5051 18.4963L9.97587 15.6176C9.93436 15.5662 9.88369 15.524 9.82675 15.4933C9.76981 15.4627 9.70773 15.4442 9.64405 15.4389C9.58036 15.4337 9.51632 15.4417 9.45558 15.4627C9.39484 15.4836 9.3386 15.517 9.29005 15.561C9.24447 15.6089 9.20828 15.6658 9.18359 15.7286C9.15889 15.7913 9.14618 15.8586 9.14618 15.9266C9.14618 15.9946 9.15889 16.0619 9.18359 16.1247C9.20828 16.1874 9.24447 16.2444 9.29005 16.2922L12.5392 20L18.8818 12.4712C18.9636 12.3709 19.0057 12.241 18.9994 12.1086C18.993 11.9763 18.9387 11.8516 18.8478 11.7605Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 7.42857C4 6.51926 4.36122 5.64719 5.00421 5.00421C5.64719 4.36122 6.51926 4 7.42857 4H16.5714C17.4807 4 18.3528 4.36122 18.9958 5.00421C19.6388 5.64719 20 6.51926 20 7.42857V16.5714C20 17.4807 19.6388 18.3528 18.9958 18.9958C18.3528 19.6388 17.4807 20 16.5714 20H7.42857C6.51926 20 5.64719 19.6388 5.00421 18.9958C4.36122 18.3528 4 17.4807 4 16.5714V7.42857ZM5.14286 12H9.14286C9.29441 12 9.43975 12.0602 9.54692 12.1674C9.65408 12.2745 9.71429 12.4199 9.71429 12.5714V12.584L9.71886 12.6526C9.74488 13.0319 9.84424 13.4025 10.0114 13.744C10.1554 14.0343 10.3634 14.3063 10.6651 14.5063C10.9623 14.7051 11.3851 14.8571 12 14.8571C12.6137 14.8571 13.0366 14.7051 13.3349 14.5063C13.6366 14.3063 13.8446 14.0343 13.9886 13.744C14.1662 13.3818 14.2674 12.987 14.2857 12.584V12.5691C14.2863 12.418 14.3468 12.2732 14.4539 12.1666C14.561 12.0599 14.706 12 14.8571 12H18.8571V7.42857C18.8571 6.82236 18.6163 6.24098 18.1877 5.81233C17.759 5.38367 17.1776 5.14286 16.5714 5.14286H7.42857C6.82236 5.14286 6.24098 5.38367 5.81233 5.81233C5.38367 6.24098 5.14286 6.82236 5.14286 7.42857V12Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 10.0444C10.92 10.0444 10.0444 10.92 10.0444 12C10.0444 13.08 10.92 13.9556 12 13.9556C13.08 13.9556 13.9556 13.08 13.9556 12C13.9556 10.92 13.08 10.0444 12 10.0444ZM8.26666 12C8.26666 9.93814 9.93813 8.26667 12 8.26667C14.0619 8.26667 15.7333 9.93814 15.7333 12C15.7333 14.0619 14.0619 15.7333 12 15.7333C9.93813 15.7333 8.26666 14.0619 8.26666 12Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 12C4 7.58161 7.58161 4 12 4C16.4184 4 20 7.58161 20 12C20 12.4909 19.602 12.8889 19.1111 12.8889C18.6202 12.8889 18.2222 12.4909 18.2222 12C18.2222 8.56345 15.4365 5.77778 12 5.77778C8.56345 5.77778 5.77778 8.56345 5.77778 12C5.77778 15.4365 8.56345 18.2222 12 18.2222C13.4024 18.2222 14.6938 17.7596 15.7333 16.9783C16.1257 16.6834 16.683 16.7624 16.9779 17.1548C17.2729 17.5472 17.1939 18.1045 16.8015 18.3994C15.4644 19.4045 13.8005 20 12 20C7.58161 20 4 16.4184 4 12Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.8445 8.26667C15.3354 8.26667 15.7333 8.66464 15.7333 9.15556V12C15.7333 12.2133 15.8047 12.5531 15.9925 12.814C16.1477 13.0295 16.4142 13.2444 16.9778 13.2444C17.5414 13.2444 17.8079 13.0295 17.9631 12.814C18.1509 12.5531 18.2222 12.2133 18.2222 12C18.2222 11.5091 18.6202 11.1111 19.1111 11.1111C19.602 11.1111 20 11.5091 20 12C20 12.4978 19.858 13.2247 19.4058 13.8527C18.921 14.526 18.1208 15.0222 16.9778 15.0222C15.8347 15.0222 15.0346 14.526 14.5498 13.8527C14.0976 13.2247 13.9556 12.4978 13.9556 12V9.15556C13.9556 8.66464 14.3535 8.26667 14.8445 8.26667Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 7C6.7512 7 4 10.8829 4 11.5C4 12.1155 6.7512 16 12 16C17.248 16 20 12.1155 20 11.5C20 10.8829 17.248 7 12 7ZM12 14.961C10.036 14.961 8.444 13.4117 8.444 11.5C8.444 9.5883 10.036 8.03741 12 8.03741C13.964 8.03741 15.5552 9.5883 15.5552 11.5C15.5552 13.4117 13.964 14.961 12 14.961ZM12 11.5C11.6744 11.1408 12.5304 9.76911 12 9.76911C11.0176 9.76911 10.2216 10.5446 10.2216 11.5C10.2216 12.4554 11.0176 13.2309 12 13.2309C12.9816 13.2309 13.7784 12.4554 13.7784 11.5C13.7784 11.0604 12.2768 11.8046 12 11.5Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 638 B

Loading…
Cancel
Save