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 @@
-
+
1 && actors.length < 4" textContent=" {{ text.and }} ">
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