diff --git a/app/menus/notifications/menu.rb b/app/menus/notifications/menu.rb index cc6961c823e..8b03c386428 100644 --- a/app/menus/notifications/menu.rb +++ b/app/menus/notifications/menu.rb @@ -63,7 +63,7 @@ module Notifications end def reason_filters - %w[mentioned assigned responsible watched dateAlert shared].map do |reason| + %w[mentioned assigned responsible watched dateAlert shared reminder].map do |reason| count = unread_by_reason[reason] menu_item(title: I18n.t("notifications.reasons.#{reason}"), icon_key: reason, @@ -128,7 +128,8 @@ module Notifications "responsible" => :"op-person-accountable", "watched" => :eye, "shared" => :"share-android", - "dateAlert" => :"op-calendar-alert" + "dateAlert" => :"op-calendar-alert", + "reminder" => :clock } end diff --git a/config/locales/en.yml b/config/locales/en.yml index 2f16a907b97..e42776da9bd 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1522,6 +1522,7 @@ en: login: "Username" mail: "Email" name: "Name" + note: "Note" password: "Password" priority: "Priority" project: "Project" @@ -2204,6 +2205,7 @@ en: responsible: "Accountable" shared: "Shared" watched: "Watcher" + reminder: "Reminder" facets: unread: "Unread" unread_title: "Show unread" diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index c5f781fb7d3..16d80a5b2dc 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -709,6 +709,8 @@ en: new_notifications: message: "There are new notifications." link_text: "Click here to load them." + reminders: + note: "Note: “%{note}”" settings: change_notification_settings: 'You can modify your notification settings to ensure you never miss an important update.' title: "Notification settings" diff --git a/frontend/src/app/core/state/in-app-notifications/in-app-notification.model.ts b/frontend/src/app/core/state/in-app-notifications/in-app-notification.model.ts index eeae2b406f0..1a12217b389 100644 --- a/frontend/src/app/core/state/in-app-notifications/in-app-notification.model.ts +++ b/frontend/src/app/core/state/in-app-notifications/in-app-notification.model.ts @@ -13,7 +13,7 @@ export interface IInAppNotificationHalResourceLinks extends IHalResourceLinks { activity:IHalResourceLink; } -export type IInAppNotificationDetailsAttribute = 'startDate'|'dueDate'|'date'; +export type IInAppNotificationDetailsAttribute = 'startDate'|'dueDate'|'date'|'note'; export interface IInAppNotificationDetailsResource { property:IInAppNotificationDetailsAttribute; diff --git a/frontend/src/app/features/in-app-notifications/center/state/ian-center.service.ts b/frontend/src/app/features/in-app-notifications/center/state/ian-center.service.ts index 0979826f650..9c41f1a4658 100644 --- a/frontend/src/app/features/in-app-notifications/center/state/ian-center.service.ts +++ b/frontend/src/app/features/in-app-notifications/center/state/ian-center.service.ts @@ -111,7 +111,28 @@ export class IanCenterService extends UntilDestroyedMixin { notifications$ = this .aggregatedCenterNotifications$ .pipe( - map((items) => Object.values(items)), + map((items) => { + return Object.values(items).reduce((acc, workPackageNotificationGroup) => { + const { reminders, others } = workPackageNotificationGroup.reduce((result, notification) => { + if (notification.reason === 'reminder') { + result.reminders.push(notification); + } else { + result.others.push(notification); + } + return result; + }, { reminders: [] as INotification[], others: [] as INotification[] }); + + // Extract reminders into standalone groups so they can be displayed individually + if (reminders.length > 0) { + reminders.forEach((reminder) => acc.push([reminder])); + } + if (others.length > 0) { + acc.push(others); + } + + return acc; + }, [] as INotification[][]); + }), distinctUntilChanged(), ); diff --git a/frontend/src/app/features/in-app-notifications/entry/actors-line/in-app-notification-actors-line.component.html b/frontend/src/app/features/in-app-notifications/entry/actors-line/in-app-notification-actors-line.component.html index 16ebc61cac3..1d6adf2d0b3 100644 --- a/frontend/src/app/features/in-app-notifications/entry/actors-line/in-app-notification-actors-line.component.html +++ b/frontend/src/app/features/in-app-notifications/entry/actors-line/in-app-notification-actors-line.component.html @@ -1,8 +1,6 @@ -
+
diff --git a/frontend/src/app/features/in-app-notifications/entry/actors-line/in-app-notification-actors-line.component.sass b/frontend/src/app/features/in-app-notifications/entry/actors-line/in-app-notification-actors-line.component.sass index 92a4de95273..376f23ed350 100644 --- a/frontend/src/app/features/in-app-notifications/entry/actors-line/in-app-notification-actors-line.component.sass +++ b/frontend/src/app/features/in-app-notifications/entry/actors-line/in-app-notification-actors-line.component.sass @@ -7,11 +7,6 @@ align-items: center color: var(--fgColor-muted) - &--date - @include text-shortener - max-width: 100% - line-height: 1rem - &--container @include text-shortener display: flex diff --git a/frontend/src/app/features/in-app-notifications/entry/actors-line/in-app-notification-actors-line.component.ts b/frontend/src/app/features/in-app-notifications/entry/actors-line/in-app-notification-actors-line.component.ts index e6bcd7b9f6d..3f8c271ce90 100644 --- a/frontend/src/app/features/in-app-notifications/entry/actors-line/in-app-notification-actors-line.component.ts +++ b/frontend/src/app/features/in-app-notifications/entry/actors-line/in-app-notification-actors-line.component.ts @@ -1,11 +1,8 @@ import { ChangeDetectionStrategy, Component, HostBinding, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { DeviceService } from 'core-app/core/browser/device.service'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; import { INotification } from 'core-app/core/state/in-app-notifications/in-app-notification.model'; import { PrincipalLike } from 'core-app/shared/components/principal/principal-types'; -import { Observable, timer } from 'rxjs'; -import { distinctUntilChanged, map } from 'rxjs/operators'; -import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { TimezoneService } from 'core-app/core/datetime/timezone.service'; -import { DeviceService } from 'core-app/core/browser/device.service'; @Component({ selector: 'op-in-app-notification-actors-line', @@ -24,13 +21,6 @@ export class InAppNotificationActorsLineComponent implements OnInit { // The actor, if any actors:PrincipalLike[] = []; - // Fixed notification time - fixedTime:string; - - // Format relative elapsed time (n seconds/minutes/hours ago) - // at an interval for auto updating - relativeTime$:Observable; - text = { and: this.I18n.t('js.notifications.center.label_actor_and'), and_other_singular: this.I18n.t('js.notifications.center.and_more_users.one'), @@ -41,21 +31,14 @@ export class InAppNotificationActorsLineComponent implements OnInit { loading: this.I18n.t('js.ajax.loading'), placeholder: this.I18n.t('js.placeholders.default'), mark_as_read: this.I18n.t('js.notifications.center.mark_as_read'), - updated_by_at: (age:string):string => this.I18n.t( - 'js.notifications.center.text_update_date_by', - { date: age }, - ), }; constructor( readonly deviceService:DeviceService, private I18n:I18nService, - private timezoneService:TimezoneService, ) { } ngOnInit():void { - this.buildTime(); - // Don't show the actor if the first item is actor-less (date alert) if (this.notification._links.actor) { this.buildActors(); @@ -70,22 +53,6 @@ export class InAppNotificationActorsLineComponent implements OnInit { return this.text.and_other_plural(number); } - private buildTime() { - this.fixedTime = this.timezoneService.formattedDatetime(this.notification.createdAt); - this.relativeTime$ = timer(0, 10000) - .pipe( - map(() => { - const time = this.timezoneService.formattedRelativeDateTime(this.notification.createdAt); - if (this.notification._links.actor) { - return this.text.updated_by_at(time); - } - - return time; - }), - distinctUntilChanged(), - ); - } - private buildActors() { const actors = this .aggregatedNotifications diff --git a/frontend/src/app/features/in-app-notifications/entry/date-alert/in-app-notification-date-alert.component.ts b/frontend/src/app/features/in-app-notifications/entry/date-alert/in-app-notification-date-alert.component.ts index 877f2b592be..fd4ddb132b6 100644 --- a/frontend/src/app/features/in-app-notifications/entry/date-alert/in-app-notification-date-alert.component.ts +++ b/frontend/src/app/features/in-app-notifications/entry/date-alert/in-app-notification-date-alert.component.ts @@ -6,9 +6,9 @@ import { OnInit, ViewEncapsulation, } from '@angular/core'; -import { INotification } from 'core-app/core/state/in-app-notifications/in-app-notification.model'; -import { I18nService } from 'core-app/core/i18n/i18n.service'; import { TimezoneService } from 'core-app/core/datetime/timezone.service'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { IInAppNotificationDetailsAttribute, INotification } from 'core-app/core/state/in-app-notifications/in-app-notification.model'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import * as moment from 'moment'; import { Moment } from 'moment'; @@ -49,6 +49,7 @@ export class InAppNotificationDateAlertComponent implements OnInit { dueDate: this.I18n.t('js.work_packages.properties.dueDate'), date: this.I18n.t('js.notifications.date_alerts.milestone_date'), due_today: this.I18n.t('js.notifications.date_alerts.property_today'), + note: '', // date alerts do not have notes }; constructor( @@ -71,7 +72,7 @@ export class InAppNotificationDateAlertComponent implements OnInit { } } - private deriveDueDate(value:string, property:'startDate'|'dueDate'|'date') { + private deriveDueDate(value:string, property:IInAppNotificationDetailsAttribute) { const dateValue = this.timezoneService.parseISODate(value).startOf('day'); const today = moment(); this.dateIsPast = dateValue.isBefore(today, 'day'); diff --git a/frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.html b/frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.html index 14868f1cb42..e0443e0a752 100644 --- a/frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.html +++ b/frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.html @@ -88,16 +88,22 @@
- - + /> + + + +
diff --git a/frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.ts b/frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.ts index 762fdc75730..c7725a20c70 100644 --- a/frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.ts +++ b/frontend/src/app/features/in-app-notifications/entry/in-app-notification-entry.component.ts @@ -29,6 +29,7 @@ export class InAppNotificationEntryComponent implements OnInit { workPackage$:Observable|null = null; showDateAlert = false; + hasReminderAlert = false; loading$ = this.storeService.query.selectLoading(); @@ -62,6 +63,7 @@ export class InAppNotificationEntryComponent implements OnInit { const href = this.notification._links.resource?.href; this.workPackageId = href && HalResource.matchFromLink(href, 'work_packages'); + this.hasReminderAlert = this.aggregatedNotifications.some((notification) => notification.reason === 'reminder'); this.showDateAlert = this.hasActiveDateAlert(); this.buildTranslatedReason(); this.buildProject(); diff --git a/frontend/src/app/features/in-app-notifications/entry/relative-time/in-app-notification-relative-time.component.html b/frontend/src/app/features/in-app-notifications/entry/relative-time/in-app-notification-relative-time.component.html new file mode 100644 index 00000000000..982ee91f405 --- /dev/null +++ b/frontend/src/app/features/in-app-notifications/entry/relative-time/in-app-notification-relative-time.component.html @@ -0,0 +1,5 @@ +
+
diff --git a/frontend/src/app/features/in-app-notifications/entry/relative-time/in-app-notification-relative-time.component.sass b/frontend/src/app/features/in-app-notifications/entry/relative-time/in-app-notification-relative-time.component.sass new file mode 100644 index 00000000000..547e9c4b48d --- /dev/null +++ b/frontend/src/app/features/in-app-notifications/entry/relative-time/in-app-notification-relative-time.component.sass @@ -0,0 +1,6 @@ +@import "helpers" + +.op-ian-relative-time + @include text-shortener + max-width: 100% + line-height: 1rem diff --git a/frontend/src/app/features/in-app-notifications/entry/relative-time/in-app-notification-relative-time.component.ts b/frontend/src/app/features/in-app-notifications/entry/relative-time/in-app-notification-relative-time.component.ts new file mode 100644 index 00000000000..153e658175a --- /dev/null +++ b/frontend/src/app/features/in-app-notifications/entry/relative-time/in-app-notification-relative-time.component.ts @@ -0,0 +1,63 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + OnInit, + ViewEncapsulation, +} from '@angular/core'; +import { TimezoneService } from 'core-app/core/datetime/timezone.service'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { INotification } from 'core-app/core/state/in-app-notifications/in-app-notification.model'; +import { Observable, timer } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; + +@Component({ + selector: 'op-in-app-notification-relative-time', + templateUrl: './in-app-notification-relative-time.component.html', + styleUrls: ['./in-app-notification-relative-time.component.sass'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, +}) +export class InAppNotificationRelativeTimeComponent implements OnInit { + @Input() notification:INotification; + @Input() hasActorByLine:boolean = true; + + // Fixed notification time + fixedTime:string; + + // Format relative elapsed time (n seconds/minutes/hours ago) + // at an interval for auto updating + relativeTime$:Observable; + + text = { + updated_by_at: (age:string):string => this.I18n.t( + 'js.notifications.center.text_update_date_by', + { date: age }, + ), + }; + + constructor( + private I18n:I18nService, + private timezoneService:TimezoneService, + ) { } + + ngOnInit():void { + this.buildTime(); + } + + private buildTime() { + this.fixedTime = this.timezoneService.formattedDatetime(this.notification.createdAt); + this.relativeTime$ = timer(0, 10000) + .pipe( + map(() => { + const time = this.timezoneService.formattedRelativeDateTime(this.notification.createdAt); + if (this.hasActorByLine && this.notification._links.actor) { + return this.text.updated_by_at(time); + } + + return `${time}.`; + }), + distinctUntilChanged(), + ); + } +} diff --git a/frontend/src/app/features/in-app-notifications/entry/reminder-alert/in-app-notification-reminder-alert.component.html b/frontend/src/app/features/in-app-notifications/entry/reminder-alert/in-app-notification-reminder-alert.component.html new file mode 100644 index 00000000000..1956ae5dfa8 --- /dev/null +++ b/frontend/src/app/features/in-app-notifications/entry/reminder-alert/in-app-notification-reminder-alert.component.html @@ -0,0 +1,8 @@ + + diff --git a/frontend/src/app/features/in-app-notifications/entry/reminder-alert/in-app-notification-reminder-alert.component.sass b/frontend/src/app/features/in-app-notifications/entry/reminder-alert/in-app-notification-reminder-alert.component.sass new file mode 100644 index 00000000000..02f042d770c --- /dev/null +++ b/frontend/src/app/features/in-app-notifications/entry/reminder-alert/in-app-notification-reminder-alert.component.sass @@ -0,0 +1,16 @@ +@import "helpers" + +.op-ian-reminder-alert + display: grid + grid-template-columns: auto 1fr + grid-column-gap: $spot-spacing-0_25 + align-items: center + color: var(--fgColor-muted) + + &--note + @include text-shortener + line-height: 1rem + color: var(--fgColor-default) + + > * + flex-shrink: 0 diff --git a/frontend/src/app/features/in-app-notifications/entry/reminder-alert/in-app-notification-reminder-alert.component.ts b/frontend/src/app/features/in-app-notifications/entry/reminder-alert/in-app-notification-reminder-alert.component.ts new file mode 100644 index 00000000000..84392aeabde --- /dev/null +++ b/frontend/src/app/features/in-app-notifications/entry/reminder-alert/in-app-notification-reminder-alert.component.ts @@ -0,0 +1,59 @@ +import { + ChangeDetectionStrategy, + Component, + HostBinding, + Input, + OnInit, + ViewEncapsulation, +} from '@angular/core'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { IInAppNotificationDetailsResource, INotification } from 'core-app/core/state/in-app-notifications/in-app-notification.model'; + +@Component({ + selector: 'op-in-app-notification-reminder-alert', + templateUrl: './in-app-notification-reminder-alert.component.html', + styleUrls: ['./in-app-notification-reminder-alert.component.sass'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, +}) +export class InAppNotificationReminderAlertComponent implements OnInit { + @Input() aggregatedNotifications:INotification[]; + + @HostBinding('class.op-ian-reminder-alert') className = true; + + reminderNote:string; + reminderAlert:INotification; + + constructor( + private I18n:I18nService, + ) { } + + ngOnInit():void { + this.reminderAlert = this.deriveMostRecentReminder(this.aggregatedNotifications); + this.reminderNote = this.extractReminderNoteValue(this.reminderAlert._embedded.details); + } + + private deriveMostRecentReminder(aggregatedNotifications:INotification[]):INotification { + const reminderAlerts = aggregatedNotifications.filter((notification:INotification) => notification.reason === 'reminder'); + + if (reminderAlerts.length > 1) { + const mostRecent = reminderAlerts.reduce((prev:INotification, current:INotification) => { + const prevDate = new Date(prev.createdAt); + const currentDate = new Date(current.createdAt); + return prevDate > currentDate ? prev : current; + }); + return mostRecent; + } + + return reminderAlerts[0]; + } + + private extractReminderNoteValue(details:IInAppNotificationDetailsResource[]):string { + const noteDetail = details.find((detail:IInAppNotificationDetailsResource) => detail.property === 'note'); + if (noteDetail?.value) { + return this.I18n.t('js.notifications.reminders.note', { note: (noteDetail?.value) }); + } + + return ''; + } +} diff --git a/frontend/src/app/features/in-app-notifications/in-app-notifications.module.ts b/frontend/src/app/features/in-app-notifications/in-app-notifications.module.ts index aade9f5cc27..d921e8f48c7 100644 --- a/frontend/src/app/features/in-app-notifications/in-app-notifications.module.ts +++ b/frontend/src/app/features/in-app-notifications/in-app-notifications.module.ts @@ -1,31 +1,33 @@ -import { NgModule } from '@angular/core'; -import { OpSharedModule } from 'core-app/shared/shared.module'; +import { ScrollingModule } from '@angular/cdk/scrolling'; import { CommonModule } from '@angular/common'; -import { IconModule } from 'core-app/shared/components/icon/icon.module'; +import { NgModule } from '@angular/core'; import { InAppNotificationBellComponent, } from 'core-app/features/in-app-notifications/bell/in-app-notification-bell.component'; -import { - InAppNotificationEntryComponent, -} from 'core-app/features/in-app-notifications/entry/in-app-notification-entry.component'; -import { OpenprojectPrincipalRenderingModule } from 'core-app/shared/components/principal/principal-rendering.module'; -import { ScrollingModule } from '@angular/cdk/scrolling'; +import { IanBellService } from 'core-app/features/in-app-notifications/bell/state/ian-bell.service'; import { InAppNotificationCenterComponent, } from 'core-app/features/in-app-notifications/center/in-app-notification-center.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'; -import { - OpenprojectContentLoaderModule, -} from 'core-app/shared/components/op-content-loader/openproject-content-loader.module'; -import { IanBellService } from 'core-app/features/in-app-notifications/bell/state/ian-bell.service'; -import { InAppNotificationActorsLineComponent } from './entry/actors-line/in-app-notification-actors-line.component'; -import { InAppNotificationDateAlertComponent } from './entry/date-alert/in-app-notification-date-alert.component'; +import { IanCenterService } from 'core-app/features/in-app-notifications/center/state/ian-center.service'; import { InAppNotificationsDateAlertsUpsaleComponent, } from 'core-app/features/in-app-notifications/date-alerts-upsale/ian-date-alerts-upsale.component'; -import { IanCenterService } from 'core-app/features/in-app-notifications/center/state/ian-center.service'; +import { + InAppNotificationEntryComponent, +} from 'core-app/features/in-app-notifications/entry/in-app-notification-entry.component'; +import { OpenprojectWorkPackagesModule } from 'core-app/features/work-packages/openproject-work-packages.module'; +import { IconModule } from 'core-app/shared/components/icon/icon.module'; +import { + OpenprojectContentLoaderModule, +} from 'core-app/shared/components/op-content-loader/openproject-content-loader.module'; +import { OpenprojectPrincipalRenderingModule } from 'core-app/shared/components/principal/principal-rendering.module'; +import { OpSharedModule } from 'core-app/shared/shared.module'; +import { DynamicModule } from 'ng-dynamic-component'; +import { InAppNotificationActorsLineComponent } from './entry/actors-line/in-app-notification-actors-line.component'; +import { InAppNotificationDateAlertComponent } from './entry/date-alert/in-app-notification-date-alert.component'; +import { InAppNotificationRelativeTimeComponent } from './entry/relative-time/in-app-notification-relative-time.component'; +import { InAppNotificationReminderAlertComponent } from './entry/reminder-alert/in-app-notification-reminder-alert.component'; +import { InAppNotificationStatusComponent } from './entry/status/in-app-notification-status.component'; @NgModule({ declarations: [ @@ -36,6 +38,8 @@ import { IanCenterService } from 'core-app/features/in-app-notifications/center/ InAppNotificationActorsLineComponent, InAppNotificationDateAlertComponent, InAppNotificationsDateAlertsUpsaleComponent, + InAppNotificationRelativeTimeComponent, + InAppNotificationReminderAlertComponent, ], imports: [ OpSharedModule, diff --git a/lib/api/v3/notifications/notification_representer.rb b/lib/api/v3/notifications/notification_representer.rb index 5b67c4dd9a3..6984fe23518 100644 --- a/lib/api/v3/notifications/notification_representer.rb +++ b/lib/api/v3/notifications/notification_representer.rb @@ -104,7 +104,7 @@ module API "Notification" end - self.to_eager_load = %i[actor journal] + self.to_eager_load = %i[actor journal reminder] end end end diff --git a/lib/api/v3/notifications/property_factory/reminder.rb b/lib/api/v3/notifications/property_factory/reminder.rb new file mode 100644 index 00000000000..e8bd4e210c2 --- /dev/null +++ b/lib/api/v3/notifications/property_factory/reminder.rb @@ -0,0 +1,45 @@ +# --copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 API::V3::Notifications::PropertyFactory + module Reminder + extend ::API::V3::Utilities::PathHelper + + module_function + + def for(notification) + return [] unless notification.reminder + + [ + ::API::V3::Values::PropertyGenericRepresenter + .new(::API::V3::Values::PropertyModel.new(:note, notification.reminder.note), + self_link: api_v3_paths.notification_detail(notification.id, 0)) + ] + end + end +end diff --git a/lib/api/v3/values/property_generic_representer.rb b/lib/api/v3/values/property_generic_representer.rb new file mode 100644 index 00000000000..ed814ef2fa3 --- /dev/null +++ b/lib/api/v3/values/property_generic_representer.rb @@ -0,0 +1,33 @@ +# --copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 API::V3::Values + class PropertyGenericRepresenter < PropertyRepresenter + property :value + end +end diff --git a/lib/api/v3/values/schemas/value_schema_factory.rb b/lib/api/v3/values/schemas/value_schema_factory.rb index de98a68e279..441bb673472 100644 --- a/lib/api/v3/values/schemas/value_schema_factory.rb +++ b/lib/api/v3/values/schemas/value_schema_factory.rb @@ -29,7 +29,7 @@ module API::V3::Values::Schemas module ValueSchemaFactory extend ::API::V3::Utilities::PathHelper - SUPPORTED = %w(start_date due_date date).freeze + SUPPORTED = %w(start_date due_date date note).freeze module_function @@ -64,10 +64,13 @@ module API::V3::Values::Schemas I18n.t("attributes.#{property}") end - def type_for(_property) - # This is but a stub. Currently, only 'start_date' and 'due_date' - # need to be supported so this simple approach works. - "Date" + def type_for(property) + case property + when "start_date", "due_date", "date" + "Date" + when "note" + "String" + end end end end diff --git a/spec/features/notifications/notification_center/notification_center_reminder_spec.rb b/spec/features/notifications/notification_center/notification_center_reminder_spec.rb new file mode 100644 index 00000000000..015aa20a36f --- /dev/null +++ b/spec/features/notifications/notification_center/notification_center_reminder_spec.rb @@ -0,0 +1,64 @@ +require "spec_helper" +require "features/page_objects/notification" + +RSpec.describe "Notification center reminder, mention and date alert", + :js, + :with_cuprite, + with_ee: %i[date_alerts], + with_settings: { journal_aggregation_time_minutes: 0 } do + shared_let(:project) { create(:project) } + shared_let(:actor) { create(:user, firstname: "Actor", lastname: "User") } + shared_let(:user) do + create(:user, + member_with_permissions: { project => %w[view_work_packages] }) + end + shared_let(:work_package) { create(:work_package, project:, due_date: 1.day.ago) } + + shared_let(:notification_mention) do + create(:notification, + reason: :mentioned, + recipient: user, + resource: work_package, + actor:) + end + + shared_let(:notification_date_alert) do + create(:notification, + reason: :date_alert_due_date, + recipient: user, + resource: work_package) + end + + shared_let(:notification_reminder) do + reminder = create(:reminder, remindable: work_package, creator: user, note: "This is an important reminder") + notification = create(:notification, + reason: :reminder, + recipient: user, + resource: work_package) + create(:reminder_notification, reminder:, notification:) + notification + end + + let(:center) { Pages::Notifications::Center.new } + + before do + login_as user + visit notifications_center_path + wait_for_reload + end + + it "shows the reminder alert in own entry" do + center.within_item(notification_reminder) do + expect(page).to have_text("##{work_package.id}\n- #{project.name} -\nReminder") + expect(page).to have_no_text("Actor user") + expect(page).to have_text("a few seconds ago.\nNote: “This is an important reminder”") + end + end + + it "shows other notification reasons aggregated" do + center.within_item(notification_date_alert) do + expect(page).to have_text("##{work_package.id}\n- #{project.name} -\nDate alert, Mentioned") + expect(page).to have_no_text("Actor user") + end + end +end diff --git a/spec/features/notifications/notification_center/notification_center_sidemenu_spec.rb b/spec/features/notifications/notification_center/notification_center_sidemenu_spec.rb index faf5a6209aa..c2cd4a83248 100644 --- a/spec/features/notifications/notification_center/notification_center_sidemenu_spec.rb +++ b/spec/features/notifications/notification_center/notification_center_sidemenu_spec.rb @@ -24,6 +24,7 @@ RSpec.describe "Notification center sidemenu", shared_let(:work_package4) { create(:work_package, project: project3, author: other_user) } shared_let(:work_package5) { create(:work_package, :is_milestone, project: project3, author: other_user) } shared_let(:work_package6) { create(:work_package, :is_milestone, project: project3, author: other_user) } + shared_let(:work_package7) { create(:work_package, project: project3, author: other_user) } let(:notification_watched) do create(:notification, @@ -67,9 +68,19 @@ RSpec.describe "Notification center sidemenu", reason: :shared) end + let(:notification_reminder) do + reminder = create(:reminder, remindable: work_package7, creator: other_user, note: "This is an important reminder") + notification = create(:notification, + recipient:, + resource: work_package7, + reason: :reminder) + create(:reminder_notification, reminder:, notification:) + notification + end + let(:notifications) do [notification_watched, notification_assigned, notification_responsible, notification_mentioned, notification_date, - notification_shared] + notification_shared, notification_reminder] end let(:center) { Pages::Notifications::Center.new } @@ -99,6 +110,7 @@ RSpec.describe "Notification center sidemenu", side_menu.expect_item_with_no_count "Watcher" side_menu.expect_item_with_no_count "Date alert" side_menu.expect_item_with_no_count "Shared" + side_menu.expect_item_with_no_count "Reminder" end end @@ -106,35 +118,37 @@ RSpec.describe "Notification center sidemenu", side_menu.expect_open # Expect standard filters - side_menu.expect_item_with_count "Inbox", 6 + side_menu.expect_item_with_count "Inbox", 7 side_menu.expect_item_with_count "Assignee", 1 side_menu.expect_item_with_count "Mentioned", 1 side_menu.expect_item_with_count "Accountable", 1 side_menu.expect_item_with_count "Watcher", 1 side_menu.expect_item_with_count "Date alert", 1 side_menu.expect_item_with_count "Shared", 1 + side_menu.expect_item_with_count "Reminder", 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}", 4 + side_menu.expect_item_with_count "... #{project3.name}", 5 # Reading a notification... center.mark_notification_as_read notification_watched # ... will change the filter counts - side_menu.expect_item_with_count "Inbox", 5 + side_menu.expect_item_with_count "Inbox", 6 side_menu.expect_item_with_count "Assignee", 1 side_menu.expect_item_with_count "Mentioned", 1 side_menu.expect_item_with_count "Accountable", 1 side_menu.expect_item_with_count "Date alert", 1 side_menu.expect_item_with_count "Shared", 1 + side_menu.expect_item_with_count "Reminder", 1 side_menu.expect_item_with_no_count "Watcher" # ... and show only those projects with a notification side_menu.expect_no_item project.name side_menu.expect_item_with_count project2.name, 1 - side_menu.expect_item_with_count "... #{project3.name}", 4 + side_menu.expect_item_with_count "... #{project3.name}", 5 # Empty filter sets have a separate message side_menu.click_item "Watcher" @@ -152,6 +166,7 @@ RSpec.describe "Notification center sidemenu", side_menu.expect_item_with_no_count "Watcher" side_menu.expect_item_with_no_count "Date alert" side_menu.expect_item_with_no_count "Shared" + side_menu.expect_item_with_no_count "Reminder" side_menu.expect_no_item project.name side_menu.expect_no_item project2.name @@ -160,66 +175,74 @@ RSpec.describe "Notification center sidemenu", it "updates the content when a filter is clicked" do # All notifications are shown - center.expect_work_package_item *notifications + center.expect_work_package_item(*notifications) # Filter for "Watcher" side_menu.click_item "Watcher" side_menu.finished_loading center.expect_work_package_item notification_watched center.expect_no_item notification_assigned, notification_responsible, notification_mentioned, notification_date, - notification_shared + notification_shared, notification_reminder # Filter for "Assignee" side_menu.click_item "Assignee" side_menu.finished_loading center.expect_work_package_item notification_assigned center.expect_no_item notification_watched, notification_responsible, notification_mentioned, notification_date, - notification_shared + notification_shared, notification_reminder # Filter for "Accountable" side_menu.click_item "Accountable" side_menu.finished_loading center.expect_work_package_item notification_responsible center.expect_no_item notification_watched, notification_assigned, notification_mentioned, notification_date, - notification_shared + notification_shared, notification_reminder # Filter for "Mentioned" side_menu.click_item "Mentioned" side_menu.finished_loading center.expect_work_package_item notification_mentioned center.expect_no_item notification_watched, notification_assigned, notification_responsible, notification_date, - notification_shared + notification_shared, notification_reminder # Filter for "Date alert" side_menu.click_item "Date alert" side_menu.finished_loading center.expect_work_package_item notification_date center.expect_no_item notification_watched, notification_assigned, notification_responsible, notification_mentioned, - notification_shared + notification_shared, notification_reminder # Filter for "Shared" side_menu.click_item "Shared" side_menu.finished_loading center.expect_work_package_item notification_shared center.expect_no_item notification_watched, notification_assigned, notification_responsible, notification_mentioned, - notification_date + notification_date, notification_reminder + + # Filter for "Reminder" + side_menu.click_item "Reminder" + side_menu.finished_loading + center.expect_work_package_item notification_reminder + center.expect_no_item notification_watched, notification_assigned, notification_responsible, notification_mentioned, + notification_date, notification_shared # Filter for project1 side_menu.click_item project.name side_menu.finished_loading center.expect_work_package_item notification_watched center.expect_no_item notification_assigned, notification_responsible, notification_mentioned, notification_date, - notification_shared + notification_shared, notification_reminder # Filter for project3 side_menu.click_item "... #{project3.name}" side_menu.finished_loading - center.expect_work_package_item notification_responsible, notification_mentioned, notification_date, notification_shared + center.expect_work_package_item notification_responsible, notification_mentioned, notification_date, notification_shared, + notification_reminder center.expect_no_item notification_watched, notification_assigned # Reset by clicking on the Inbox side_menu.click_item "Inbox" side_menu.finished_loading - center.expect_work_package_item *notifications + center.expect_work_package_item(*notifications) end end diff --git a/spec/lib/api/v3/notifications/notification_representer_rendering_spec.rb b/spec/lib/api/v3/notifications/notification_representer_rendering_spec.rb index c0fb12c42cb..bb3aef99788 100644 --- a/spec/lib/api/v3/notifications/notification_representer_rendering_spec.rb +++ b/spec/lib/api/v3/notifications/notification_representer_rendering_spec.rb @@ -267,5 +267,60 @@ RSpec.describe API::V3::Notifications::NotificationRepresenter, "rendering" do .at_path("_embedded/details") end end + + shared_examples_for "embeds a Values::Property for reminder note" do + it "embeds a Values::Property" do + expect(generated) + .to be_json_eql("Values::Property".to_json) + .at_path("_embedded/details/0/_type") + end + + it "has a note value for the `property` property" do + expect(generated) + .to be_json_eql("note".to_json) + .at_path("_embedded/details/0/property") + end + + it "has a reminder`s note for the value" do + expect(generated) + .to be_json_eql(notification.reminder.note.to_json) + .at_path("_embedded/details/0/value") + end + end + + context "for a reminder when embedding" do + let(:reminder) { build_stubbed(:reminder) } + let(:reason) { :reminder } + let(:embed_links) { true } + + before do + allow(notification).to receive(:reminder).and_return(reminder) + end + + it_behaves_like "embeds a Values::Property for reminder note" + end + + context "for a reminder when not embedding" do + let(:reminder) { build_stubbed(:reminder) } + let(:reason) { :reminder } + let(:embed_links) { false } + + before do + allow(notification).to receive(:reminder).and_return(reminder) + end + + it_behaves_like "embeds a Values::Property for reminder note" + end + + context "for a reminder with no notification" do + let(:reminder) { nil } + let(:reason) { :reminder } + + it "has an empty details array" do + expect(generated) + .to have_json_size(0) + .at_path("_embedded/details") + end + end end end